feat(service-config): per-forge OAuth credentials editor (PR D of A/B/C/D) #830

Merged
reviewer merged 1 commit from feat/forge-oauth-credentials into main 2026-05-04 19:13:11 +00:00
Collaborator

Summary

Closes the URL-consolidation series. Operator can now register and rotate OAuth app credentials for Forgejo / GitHub / GitLab from /settings/service/forge instead of editing the systemd unit + restart.

Stacked on PR #829 (sub-routes restructure) — base branch reflects that. Once #829 merges, retarget to main.

Storage shape

Per specs/service-config-url-consolidation.md PR D:

  • client_id is non-secret OAuth registration metadata → new column on operator_oauth_tokens.
  • client_secret_ref points at a row in the encrypted secret table under canonical name <FORGE>_OAUTH_CLIENT_SECRET (same envelope as PENPOT_TOKEN, see migration 014).
  • base_url continues to live on operator_oauth_tokens (PR C). GitHub is hardcoded to https://github.com; Forgejo / GitLab are operator-set.

What changes

Server

  • DB schema: two new columns on operator_oauth_tokensclient_id + client_secret_ref. Idempotent ALTER TABLE in ensureSchema.
  • Migration 017 split into:
    • runPromoteOauthEnvToDbSchema — schema-only, runs in ensureSchema so even just agents-sync ExecStartPre processes pick up the columns.
    • runPromoteOauthEnvToDb — data backfill (encrypts env values into the secret table + populates the operator_oauth_tokens pointers). Called from main.ts AFTER initSecretsCrypto(). Idempotent — env unset OR row already populated → no-op.
  • Loader: new helper resolveForgeOAuthFromDb(forge) resolves credentials DB-first, env vars only as bootstrap fallback. The three OAuth blocks in loadWebhookConfig (Forgejo / GitLab / GitHub) all switch to the helper. Once both client_id + client_secret_ref are populated, env vars are ignored ("no compat shim" rule).
  • HTTP API: new handler at apps/server/src/http/handlers/forges-credentials.ts exposes GET /api/forges (list per-forge state) and PUT /api/forges/:forge (update base_url / client_id / client_secret). Wired into main.ts alongside the existing service-config CRUD.

SPA

  • New Forge sub-route at /settings/service/forge — first item in the side-nav, default redirect target (replaces /container).
  • apps/web/src/features/service-config/forge-section.tsx renders three per-forge cards (Forgejo / GitLab / GitHub):
    • OAuth-dance status pill (signed in as <login> ✓ vs no dance yet).
    • Editable base_url (disabled for GitHub).
    • Editable client_id text input.
    • Write-only client_secret editor mirroring PenpotTokenEditor — last-rotated timestamp + paste-to-replace.
    • Save fires PUT /api/forges/<forge>; toast warns the operator that new credentials take effect after the next service restart.
  • apps/web/src/lib/api.ts — typed client for /api/forges.
  • /settings/ hub card subtitle refreshed.

Test plan

  • just qa — 3135/3135 server tests + web typecheck green
  • just restart — service running, migration backfilled client_id=c1ccb3ef-… + client_secret_ref=FORGEJO_OAUTH_CLIENT_SECRET; log line confirms [017] forgejo: insertedSecret=true updatedRow=true stubCreated=false
  • Operator smoke — sign in, hit /settings/service/forge → see three forge cards. Edit client_id → save → toast appears + table reflects new value.
  • Restart smoke — after editing client_secret, restart service → [017] migration is no-op (env still set, row already populated → row-already-set skip).

Series complete

PR A → ripped 5 dead tabs + 4 dead columns.
PR B → Penpot URL/secret co-presented under "Design" tab.
PR C → forgejo_url relocated to operator_oauth_tokens.base_url.
PR D → forge OAuth credentials editor.

End state: /settings/service/{forge,container,watchdogs,design} — every URL lives with its credentials, every credential has a dashboard editor, no env-only knobs left for runtime config.

🤖 Generated with Claude Code

## Summary Closes the URL-consolidation series. Operator can now register and rotate OAuth app credentials for Forgejo / GitHub / GitLab from `/settings/service/forge` instead of editing the systemd unit + restart. Stacked on PR #829 (sub-routes restructure) — base branch reflects that. Once #829 merges, retarget to `main`. ## Storage shape Per `specs/service-config-url-consolidation.md` PR D: - `client_id` is non-secret OAuth registration metadata → new column on `operator_oauth_tokens`. - `client_secret_ref` points at a row in the encrypted `secret` table under canonical name `<FORGE>_OAUTH_CLIENT_SECRET` (same envelope as `PENPOT_TOKEN`, see migration 014). - `base_url` continues to live on `operator_oauth_tokens` (PR C). GitHub is hardcoded to `https://github.com`; Forgejo / GitLab are operator-set. ## What changes **Server** - DB schema: two new columns on `operator_oauth_tokens` — `client_id` + `client_secret_ref`. Idempotent ALTER TABLE in `ensureSchema`. - Migration 017 split into: - `runPromoteOauthEnvToDbSchema` — schema-only, runs in `ensureSchema` so even `just agents-sync` ExecStartPre processes pick up the columns. - `runPromoteOauthEnvToDb` — data backfill (encrypts env values into the secret table + populates the `operator_oauth_tokens` pointers). Called from `main.ts` AFTER `initSecretsCrypto()`. Idempotent — env unset OR row already populated → no-op. - Loader: new helper `resolveForgeOAuthFromDb(forge)` resolves credentials DB-first, env vars only as bootstrap fallback. The three OAuth blocks in `loadWebhookConfig` (Forgejo / GitLab / GitHub) all switch to the helper. Once both `client_id` + `client_secret_ref` are populated, env vars are ignored ("no compat shim" rule). - HTTP API: new handler at `apps/server/src/http/handlers/forges-credentials.ts` exposes `GET /api/forges` (list per-forge state) and `PUT /api/forges/:forge` (update base_url / client_id / client_secret). Wired into `main.ts` alongside the existing service-config CRUD. **SPA** - New `Forge` sub-route at `/settings/service/forge` — first item in the side-nav, default redirect target (replaces `/container`). - `apps/web/src/features/service-config/forge-section.tsx` renders three per-forge cards (Forgejo / GitLab / GitHub): - OAuth-dance status pill (signed in as `<login>` ✓ vs no dance yet). - Editable `base_url` (disabled for GitHub). - Editable `client_id` text input. - Write-only `client_secret` editor mirroring `PenpotTokenEditor` — last-rotated timestamp + paste-to-replace. - Save fires `PUT /api/forges/<forge>`; toast warns the operator that new credentials take effect after the next service restart. - `apps/web/src/lib/api.ts` — typed client for `/api/forges`. - `/settings/` hub card subtitle refreshed. ## Test plan - [x] `just qa` — 3135/3135 server tests + web typecheck green - [x] `just restart` — service running, migration backfilled `client_id=c1ccb3ef-…` + `client_secret_ref=FORGEJO_OAUTH_CLIENT_SECRET`; log line confirms `[017] forgejo: insertedSecret=true updatedRow=true stubCreated=false` - [ ] Operator smoke — sign in, hit `/settings/service/forge` → see three forge cards. Edit `client_id` → save → toast appears + table reflects new value. - [ ] Restart smoke — after editing client_secret, restart service → `[017]` migration is no-op (env still set, row already populated → row-already-set skip). ## Series complete PR A → ripped 5 dead tabs + 4 dead columns. PR B → Penpot URL/secret co-presented under "Design" tab. PR C → `forgejo_url` relocated to `operator_oauth_tokens.base_url`. **PR D → forge OAuth credentials editor.** End state: `/settings/service/{forge,container,watchdogs,design}` — every URL lives with its credentials, every credential has a dashboard editor, no env-only knobs left for runtime config. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Closes the URL-consolidation series. Operator can now register and
rotate OAuth app credentials for Forgejo / GitHub / GitLab from
`/settings/service/forge` instead of editing the systemd unit + restart.

Storage shape (specs/service-config-url-consolidation.md PR D):

- `client_id` is non-secret OAuth registration metadata → new column on
  `operator_oauth_tokens`.
- `client_secret_ref` points at a row in the encrypted `secret` table
  under canonical name `<FORGE>_OAUTH_CLIENT_SECRET` (same envelope as
  `PENPOT_TOKEN`, see migration 014).
- `base_url` continues to live on `operator_oauth_tokens` (PR C). GitHub
  is hardcoded to `https://github.com`; Forgejo / GitLab are operator-set.

## Server

- DB schema: two new columns on `operator_oauth_tokens` (`client_id`,
  `client_secret_ref`). Idempotent ALTER TABLE in `ensureSchema`.
- Migration 017 split into:
  - `runPromoteOauthEnvToDbSchema` — schema-only ALTER, runs in
    `ensureSchema` so even `just agents-sync` ExecStartPre processes
    pick up the columns.
  - `runPromoteOauthEnvToDb` — data backfill (encrypts env values into
    the secret table + populates the operator_oauth_tokens pointers).
    Called from `main.ts` AFTER `initSecretsCrypto()` so encryption
    works. Idempotent — env unset OR row already populated → no-op.
- Loader (`webhook-config.ts:resolveForgeOAuthFromDb`): single helper
  resolves OAuth credentials for any forge — DB row first, env vars only
  as bootstrap fallback. The three per-forge OAuth blocks in
  `loadWebhookConfig` (Forgejo / GitLab / GitHub) all switch to the
  helper; once the DB carries both client_id + client_secret_ref the
  env vars are ignored ("no compat shim" rule).
- HTTP API: new handler `apps/server/src/http/handlers/forges-credentials.ts`
  exposes `GET /api/forges` (list per-forge state — oauth_completed +
  account_login + base_url + client_id + client_secret_set +
  client_secret_rotated_at; client_secret value is write-only) and
  `PUT /api/forges/:forge` (update base_url / client_id / client_secret).
  Wired into `main.ts` alongside the existing service-config CRUD.

## SPA

- New `Forge` sub-route at `/settings/service/forge` — first item in
  the side-nav, default redirect target (replaces `/container`).
- `apps/web/src/features/service-config/forge-section.tsx` renders
  three per-forge cards (Forgejo / GitLab / GitHub):
  - OAuth-dance status pill (signed in as `<login>` ✓ vs no dance yet).
  - Editable `base_url` (disabled for GitHub — hardcoded to
    `https://github.com`).
  - Editable `client_id` text input.
  - Write-only `client_secret` editor mirroring `PenpotTokenEditor` —
    last-rotated timestamp + paste-to-replace.
  - Save fires `PUT /api/forges/<forge>`; toast warns the operator that
    new credentials take effect after the next service restart (the
    loader caches them in `WebhookConfig` at boot).
- `apps/web/src/lib/api.ts` — typed client for `/api/forges` (GET/PUT).
- `/settings/` hub card subtitle refreshed to mention forge OAuth
  credentials as one of the things this page covers.

`just qa` green (3135/3135 server tests + web typecheck). Service
restarted; live DB now carries `client_id=c1ccb3ef-…` +
`client_secret_ref=FORGEJO_OAUTH_CLIENT_SECRET` for the forgejo row;
the migration log line confirms `insertedSecret=true updatedRow=true`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
charles changed target branch from refactor/settings-service-subroutes to main 2026-05-04 19:00:39 +00:00
charles force-pushed feat/forge-oauth-credentials from ed5771e775 to e338250dd5
All checks were successful
qa / dockerfile (pull_request) Successful in 21s
qa / qa-1 (pull_request) Successful in 2m1s
qa / qa (pull_request) Successful in 0s
2026-05-04 19:03:07 +00:00
Compare
reviewer approved these changes 2026-05-04 19:12:59 +00:00
reviewer left a comment

CI green, security sound, AC met.

  • Secret never returned in GET/PUT response; write-only pattern matches PenpotTokenEditor.
  • Two-step PUT (secret table → token row) has no wrapping transaction, but idempotent retry makes it self-healing — not a blocker.
  • Minor UX: if user types then clears client_secret, dirty stays true but handleSave fires no mutation (empty patch). Save button appears enabled but click is a no-op. Nit only.
  • guardMutating on GET /api/forges matches existing service-config patterns.
CI green, security sound, AC met. - Secret never returned in GET/PUT response; write-only pattern matches `PenpotTokenEditor`. - Two-step PUT (secret table → token row) has no wrapping transaction, but idempotent retry makes it self-healing — not a blocker. - Minor UX: if user types then clears `client_secret`, `dirty` stays true but `handleSave` fires no mutation (empty patch). Save button appears enabled but click is a no-op. Nit only. - `guardMutating` on GET `/api/forges` matches existing service-config patterns.
Sign in to join this conversation.
No reviewers
No milestone
No project
No assignees
3 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!830
No description provided.