feat(auth): operator_oauth_tokens table + active-forge router (#481) #501
No reviewers
Labels
No labels
area:agents
area:dashboard
area:database
area:design
area:design-review
area:flows
area:infra
area:meta
area:security
area:sessions
area:webhook
area:workdir
security
type:bug
type:chore
type:meta
type:user-story
No milestone
No project
No assignees
2 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference
charles/claude-hooks!501
Loading…
Add table
Add a link
Reference in a new issue
No description provided.
Delete branch "boss/481"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Summary
F2 of the multi-forge OAuth track — the persistence + dispatch layer that everything else in the operator-login flow plugs into.
operator_oauth_tokens(forge_type PK) holds the operator's encrypted access/refresh tokens, scopes, account identity, and base URL. Tokens are sealed with libsodium secretbox keyed byOAUTH_ENCRYPTION_KEY; boot fails fast if the key is missing or shorter than 32 bytes. The decrypt helper redacts on log lines so a stack trace can't leak a token.service_settings.active_forge_typerecords which row the dispatcher should follow, withgetActiveForge/setActiveForge/clearActiveForgehelpers and an atomicupsertOperatorOAuthAndSetActiveso the OAuth callback flips the row + the pointer in one transaction.getOperatorAdapter()routes domain code at the active forge or raisesNoActiveForgeError. The HTTP layer maps that to412 Precondition Failed(extracted intonoActiveForgeResponseso the mapping is unit-testable without booting Hono) so the dashboard knows to redirect into OAuth instead of treating it as a 500.GET /mereturns{ forge_type, account_login, base_url, since }or 401 — never the access token, verified by a test that asserts the response body doesn't contain the plaintext.active_forge_typewithout deleting A's row, so a manualsetActiveForge("github")is enough to switch back without a fresh OAuth dance.Test plan
oauth-crypto: missing/short key boot rejection; hex / base64 / raw key formats; idempotent init; round-trip; fresh nonces produce different ciphertexts; tamper / wrong-key / short-ciphertext rejection; uninitialized error.created_atand bumpsupdated_at,deleteOperatorOAuthscope, active-forge get/set/clear, atomicupsertOperatorOAuthAndSetActive, forge switch keeps prior row.getOperatorAdapter: 412 when no forge / row missing; correct adapter type per active forge; switch flips the adapter; clear re-arms the error;noActiveForgeResponse412 mapping; null for unrelated errors.GET /me: 401 paths, 200 shape, doesn't expose tokens, clear → 401, switch surfaces new identity.bun x turbo run typecheck— 4/4 packages clean.bun x turbo run test— 2317 server tests + 587 web tests, all green.bun x @biomejs/biome@^2 check .— no errors in the new files.Closes #481
Adds the persistence + dispatch layer behind the operator login flow: - `operator_oauth_tokens` (forge_type PK) holds the operator's encrypted access/refresh tokens, scopes, account identity, and base URL. Tokens are sealed with libsodium secretbox keyed by `OAUTH_ENCRYPTION_KEY`; boot fails fast if the key is missing or shorter than 32 bytes. Decrypted values never appear in log lines. - `service_settings.active_forge_type` records which row the dispatcher should follow, with `getActiveForge` / `setActiveForge` / `clearActiveForge` helpers and an atomic `upsertOperatorOAuthAndSetActive` for the OAuth callback. - `getOperatorAdapter()` routes domain code at the active forge or raises `NoActiveForgeError`; the HTTP layer maps that to 412 so the dashboard can redirect into OAuth instead of treating it as a 500. - `GET /me` returns `{ forge_type, account_login, base_url, since }` or 401, never the access token. - Forge switching: OAuth-ing into B while A is active flips `active_forge_type` without deleting A's row, so a manual `setActiveForge("github")` is enough to switch back. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>All 9 ACs from #481 are met and CI is green.
Crypto layer is solid: libsodium secretbox with fresh nonces per call, hex-first key decoding so the recommended
openssl rand -hex 32recipe lands the right 32 bytes, idempotent init with concurrent-caller sharing, and intentionally generic error messages so a log tail can't distinguish "wrong key" from "corrupt ciphertext".redactedTokenLabel()centralises the log-hygiene contract in one place.DB layer:
upsertOperatorOAuthON CONFLICT preservescreated_at(soGET /me'ssincefield is stable), bumpsupdated_at, and atomically flipsactive_forge_typeinupsertOperatorOAuthAndSetActive.getActiveForge()validating the stored value viaisForgeTypebefore returning it means a hand-edited DB can't feed an invalid forge into the adapter path.noActiveForgeResponseextracted into its own helper is the right call — the 412 mapping is testable without booting Hono, and wiring it intoapp.onErrorkeeps the dispatch path clean.The
initOauthCrypto()call in the startup sequence (with the "so the operator sees what to fix" comment) is exactly where it should be — boot fails fast before any request is served.Nit (no block):
decryptRowthrowing on an invalidforge_typein the DB is unreachable in practice becausegetActiveForge()already validates viaisForgeTypebefore thegetOperatorOAuthcall — the defensive check is harmless but dead code.