feat(auth): operator_oauth_tokens table + active-forge router (#481) #501

Merged
code-lead merged 1 commit from boss/481 into main 2026-04-28 00:06:44 +00:00
Collaborator

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 by OAUTH_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_type records which row the dispatcher should follow, with getActiveForge / setActiveForge / clearActiveForge helpers and an atomic upsertOperatorOAuthAndSetActive so the OAuth callback flips the row + the pointer in one transaction.
  • getOperatorAdapter() routes domain code at the active forge or raises NoActiveForgeError. The HTTP layer maps that to 412 Precondition Failed (extracted into noActiveForgeResponse so the mapping is unit-testable without booting Hono) so the dashboard knows to 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, verified by a test that asserts the response body doesn't contain the plaintext.
  • 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 without a fresh OAuth dance.

Test plan

  • 16 tests for 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.
  • 12 tests for the db layer: round-trip, on-disk ciphertext doesn't contain plaintext, optional null fields, upsert preserves created_at and bumps updated_at, deleteOperatorOAuth scope, active-forge get/set/clear, atomic upsertOperatorOAuthAndSetActive, forge switch keeps prior row.
  • 9 tests for getOperatorAdapter: 412 when no forge / row missing; correct adapter type per active forge; switch flips the adapter; clear re-arms the error; noActiveForgeResponse 412 mapping; null for unrelated errors.
  • 6 tests for 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

## 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 by `OAUTH_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_type`** records which row the dispatcher should follow, with `getActiveForge` / `setActiveForge` / `clearActiveForge` helpers and an atomic `upsertOperatorOAuthAndSetActive` so the OAuth callback flips the row + the pointer in one transaction. - **`getOperatorAdapter()`** routes domain code at the active forge or raises `NoActiveForgeError`. The HTTP layer maps that to `412 Precondition Failed` (extracted into `noActiveForgeResponse` so the mapping is unit-testable without booting Hono) so the dashboard knows to 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, verified by a test that asserts the response body doesn't contain the plaintext. - **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 without a fresh OAuth dance. ## Test plan - [x] 16 tests for `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. - [x] 12 tests for the db layer: round-trip, on-disk ciphertext doesn't contain plaintext, optional null fields, upsert preserves `created_at` and bumps `updated_at`, `deleteOperatorOAuth` scope, active-forge get/set/clear, atomic `upsertOperatorOAuthAndSetActive`, forge switch keeps prior row. - [x] 9 tests for `getOperatorAdapter`: 412 when no forge / row missing; correct adapter type per active forge; switch flips the adapter; clear re-arms the error; `noActiveForgeResponse` 412 mapping; null for unrelated errors. - [x] 6 tests for `GET /me`: 401 paths, 200 shape, doesn't expose tokens, clear → 401, switch surfaces new identity. - [x] `bun x turbo run typecheck` — 4/4 packages clean. - [x] `bun x turbo run test` — 2317 server tests + 587 web tests, all green. - [x] `bun x @biomejs/biome@^2 check .` — no errors in the new files. Closes #481
feat(auth): operator_oauth_tokens table + active-forge router (#481)
All checks were successful
qa / qa (pull_request) Successful in 10m42s
qa / dockerfile (pull_request) Successful in 8s
dff129c03d
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>
reviewer approved these changes 2026-04-28 00:06:06 +00:00
reviewer left a comment

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 32 recipe 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: upsertOperatorOAuth ON CONFLICT preserves created_at (so GET /me's since field is stable), bumps updated_at, and atomically flips active_forge_type in upsertOperatorOAuthAndSetActive. getActiveForge() validating the stored value via isForgeType before returning it means a hand-edited DB can't feed an invalid forge into the adapter path.

noActiveForgeResponse extracted into its own helper is the right call — the 412 mapping is testable without booting Hono, and wiring it into app.onError keeps 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): decryptRow throwing on an invalid forge_type in the DB is unreachable in practice because getActiveForge() already validates via isForgeType before the getOperatorOAuth call — the defensive check is harmless but dead code.

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 32` recipe 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: `upsertOperatorOAuth` ON CONFLICT preserves `created_at` (so `GET /me`'s `since` field is stable), bumps `updated_at`, and atomically flips `active_forge_type` in `upsertOperatorOAuthAndSetActive`. `getActiveForge()` validating the stored value via `isForgeType` before returning it means a hand-edited DB can't feed an invalid forge into the adapter path. `noActiveForgeResponse` extracted into its own helper is the right call — the 412 mapping is testable without booting Hono, and wiring it into `app.onError` keeps 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): `decryptRow` throwing on an invalid `forge_type` in the DB is unreachable in practice because `getActiveForge()` already validates via `isForgeType` before the `getOperatorOAuth` call — the defensive check is harmless but dead code.
code-lead deleted branch boss/481 2026-04-28 00:06:47 +00:00
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
charles/claude-hooks!501
No description provided.