docs(skill): smbCloud auth_user.id is per-AuthApp; reconcile local users by email

Document the identity-reconciliation rule in the smbcloud-auth skill: smbCloud's auth_user.id is per-AuthApp and changes when sigit-si is repointed at a different AuthApp/account (or the upstream account is recreated), so the local User upsert must key on email, not smbcloud_id. Also note the new "Continue with GitHub" controller. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Seto Elkahfi committed Jun 30, 2026 at 21:25 UTC 5c1f40baadab2ed82fa236e0c9522f896ee85160
1 file changed +35
.agents/skills/smbcloud-auth/SKILL.md
+35
@@ -23,6 +23,10 @@ SDKs, the web console), see the authoritative skill in
23 Do not call `SmbCloud::Auth` directly from controllers; go through this service.
24 - `app/controllers/sessions_controller.rb` — HTML sign in / sign out (`/auth`).
25 - `app/controllers/registrations_controller.rb` — HTML sign up (`/auth/signup`).
26 +- `app/controllers/oauth/github_controller.rb` — "Continue with GitHub"
27 + (`/auth/github` → smbCloud authorize; `/auth/github/callback` consumes the
28 + returned `access_token`). smbCloud brokers the GitHub OAuth flow; this app only
29 + starts it and reuses the same `me` → upsert → session path as `sessions#create`.
30 - `app/models/user.rb` — local mirror, upserted via
31 `User.find_or_create_from_smbcloud(profile.merge(access_token:))`.
32 - `Gemfile` — `gem "smbcloud-auth", "~> 0.3.35"` (native Rust/Magnus extension).
@@ -50,6 +54,34 @@ Class methods, all of which translate gem errors into local error types:
54 Controllers map these to flash + HTTP status (`:unprocessable_entity` for auth
55 failures, `:internal_server_error` for config/unexpected). Preserve that mapping.
56
57 +## Local user mirror & identity reconciliation
58 +
59 +`app/models/user.rb` mirrors the smbCloud account. The upsert keys on
60 +`smbcloud_id` (smbCloud's numeric `auth_user.id`) but **reconciles by email** —
61 +and that reconciliation is load-bearing, not a nicety:
62 +
63 +> **smbCloud's `auth_user.id` is per-AuthApp, not a stable global identity.**
64 +> The same email is a *different* `auth_user` (different id) in every AuthApp,
65 +> and the id also changes if the upstream account is deleted/recreated. So the
66 +> moment sigit-si is repointed at a different AuthApp — e.g. moving the app to a
67 +> different smbCloud **account** — every existing local `User.smbcloud_id` goes
68 +> stale: it points at an `auth_user` that no longer backs that email.
69 +
70 +Because `User.email` is locally unique, a `smbcloud_id`-only upsert then breaks:
71 +a login returns the email's *current* `auth_user.id`, no local row matches it,
72 +and inserting a new row collides on the unique email →
73 +`ActiveRecord::RecordInvalid`, which surfaces as a generic **"An unexpected error
74 +occurred"** on sign-in (this is exactly how the GitHub callback failed at
75 +launch — the GitHub login resolved to the email's *current* AuthApp `auth_user`,
76 +which differed from the id stored when the app pointed at an earlier
77 +AuthApp/account).
78 +
79 +`find_or_create_from_smbcloud` therefore does: look up by `smbcloud_id`; if that
80 +misses **and** the authenticated email already has a local user, adopt that row
81 +and move it onto the new `smbcloud_id`. Treat **email as the durable key** and
82 +`smbcloud_id` as a pointer that can change. Never reintroduce a
83 +`smbcloud_id`-only upsert. (Specs: `spec/models/user_smbcloud_spec.rb`.)
84 +
85 ## Credentials & environment
86
87 Required env vars (raise `KeyError` if missing — handle as a config error, not an
@@ -108,6 +140,9 @@ surface — the current controllers are HTML + session-cookie only. When adding
140 ## Common mistakes
141
142 - Calling `SmbCloud::Auth` directly from a controller instead of via the service
143 +- Keying the local `User` upsert **only** on `smbcloud_id` — it is per-AuthApp
144 + and changes when the app is repointed at a different AuthApp/account (or an
145 + upstream account is recreated); reconcile by email
146 - Treating `AccountIncompleteError` as bad credentials instead of "verify email"
147 - Leaking `SmbCloud::Auth::Error` (or its `error_code`) to the view layer
148 - Using the Rails session cookie to authenticate desktop-app API requests