Plan: CreatorTrack API token scoping (scopes + workspace binding + expiry for ctk_ PATs)¶
URL: https://mkdocs.justinsforge.com/memory/plans/creatortrack-api-token-scoping-2026-07-11/
Date: 2026-07-11
Branch / slot: feat/api-token-scoping, worktree /home/justinwieb/forge-suite-wt/feat/api-token-scoping, dev server http://100.97.43.104:3061, DB ct_feat_api_token_scoping
Handoff: creatortrack-dev-tool-primitives-tokens-forge-bridge-2026-07-11, Workstream B
Approved spec: Personal Access Tokens (ctk_) gain per-app capability scopes (plain-English picker, resource:verb stored), per-workspace binding (workspace_ids, NULL = all), and expiry (expires_at). Enforcement is central (in currentUser(), keyed on x-ct-pathname + a new x-ct-method stamp from proxy.ts), zero edits across the 185 routes. Any unmapped /api path requires * (default-deny). is_owner no longer passes through unless the owner scope is granted. Existing tokens (scopes NULL) are grandfathered as full access + owner. UI: per-app View/Edit checkbox rows, workspace multi-select, expiry dropdown, badges on the token list, "Full access (legacy)" label for old rows.
Out of scope:
- RLS-level app.token_ws_allow GUC defense-in-depth (policy blast radius too wide this round; app-level binding only)
- The separate CAPTURE_TOKEN / jobs-token doors (stay as-is)
- Workstreams A (task primitives), C (forge client), D (dev-queue skill)
- Prod deploy and prod migration (this is an isolated worktree build; deploy does NOT run migrations, so merge requires a manual migration apply to prod lifeos, flagged in the final handoff)
Scope catalog (verified against the live route tree)¶
Stored strings are resource:verb; UI renders plain-English labels from the same SCOPE_CATALOG object (single source of truth).
| UI row | Scopes | Route prefixes (verified) |
|---|---|---|
| Tasks | tasks:read/write |
/api/tasks, /api/projects, /api/reminders |
| Notes and pages | notes:read/write |
/api/node, /api/quick-note, /api/block, /api/comments, /api/collection, /api/attachments, /api/files, /api/trash, /api/changes |
| Workspaces | workspaces:read |
/api/workspace/list only (workspace mutations stay *) |
| Finance | finance:read/write |
/api/finance |
| Calendar | calendar:read/write |
/api/apps/calendar |
| Habits | habits:read/write |
/api/apps/habits |
| Sales CRM | crm:read/write |
/api/apps/sales, /api/apps/crm |
| Campaigns | campaigns:read/write |
/api/campaigns |
| News | news:read/write |
/api/apps/rss |
| University | university:read/write |
/api/apps/university, /api/gamification, /api/connections, /api/certificates |
| Meals and intake | meals:read/write |
/api/apps/meals, /api/apps/intake |
| Ideas and research | ideas:read/write |
/api/ideas, /api/research, /api/planner |
| Clips and footage | clips:read/write |
/api/clips, /api/footage |
| Journal | journal:read/write |
/api/apps/journal |
| Goals | goals:read/write |
/api/apps/goals |
| Social | social:read/write |
/api/apps/social |
email:read/write |
/api/apps/email |
|
| Agents | agents:read/write |
/api/apps/agents |
| Business | business:read/write |
/api/apps/business |
| Notifications | notifications:read/write |
/api/notifications |
| Fitness (owner) | fitness:read/write |
/api/fitness (also owner-gated, needs owner) |
| Search | search:read |
/api/search, /api/media/search |
| Owner apps | owner |
unlocks is_owner passthrough |
| Full access | * |
everything, including unmapped routes |
Mechanics:
- /api/apps/<key>/... derives its scope from <key> automatically (aliases: rss to news, sales/crm to crm); adding an app later is one catalog entry.
- Verb from method: GET/HEAD = read, everything else = write. Per-entry overrides for POST-that-reads: /api/search, /api/media/search, /api/apps/meals/foodsearch, /api/apps/meals/barcode all map to read.
- HARD-DENY for token identities regardless of scopes (session only): /api/tokens, /api/auth, /api/account. Tokens can never manage tokens or delete the account.
- Unmatched path (ai, chat, settings, connections, profile, onboarding, workspace mutations, hooks, links, presence, sidebar, etc.): requires *.
Task list¶
Task 1: Add scoping columns and expiry to core.api_tokens¶
- Files: new migration via
python3 ~/forge/scripts/forge_workspace_new_migration.py "api token scoping scopes workspaces expiry"in/home/justinwieb/forge-suite-wt/feat/api-token-scoping/db/migrations/ - What:
ALTER TABLE core.api_tokens ADD COLUMN IF NOT EXISTS scopes jsonb, workspace_ids bigint[], expires_at timestamptz. Replacecore.resolve_api_token(p_sha256)to return(user_id, is_owner, email, scopes, workspace_ids, expires_at)and exclude expired rows (expires_at IS NULL OR expires_at > now()), keeping the 60slast_used_atthrottle. Idempotent DDL. - Verification:
cd /home/justinwieb/forge-suite-wt/feat/api-token-scoping && PGDATABASE=ct_feat_api_token_scoping WORKSPACE_MIGRATIONS_DIR=db/migrations python3 ~/forge/scripts/forge_workspace_migrate.pythen confirm columns exist via the same env's psql:\d core.api_tokens - Commit:
feat(tokens): schema for scopes, workspace binding, expiry
Task 2: Scope catalog and route matcher (lib/token-scope.ts)¶
- Files:
/home/justinwieb/forge-suite-wt/feat/api-token-scoping/lib/token-scope.ts(new),/home/justinwieb/forge-suite-wt/feat/api-token-scoping/scripts/dev-verify-token-scope.mts(new) - What: Pure module (no server-only import):
SCOPE_CATALOG(scope string, label, description, prefixes, verb overrides),requiredScope(pathname, method)implementing the table above (prefix map,/api/apps/<key>derivation + aliases, HARD-DENY list, default*), andtokenAllows(grant, pathname, method)(handles*, write-implies-read). Verify script asserts ~15 representative mappings (tasks GET, node append POST, finance 401 case, hard-deny, unmapped, alias, POST-read override). - Verification:
cd /home/justinwieb/forge-suite-wt/feat/api-token-scoping && npx tsx scripts/dev-verify-token-scope.mts && npx tsc --noEmit - Commit:
feat(tokens): scope catalog + route capability matcher
Task 3: Token grant on Identity; resolution and minting carry scopes¶
- Files:
/home/justinwieb/forge-suite-wt/feat/api-token-scoping/lib/rls.ts,/home/justinwieb/forge-suite-wt/feat/api-token-scoping/lib/api-tokens.ts - What: Extend
Identitywith optionaltoken?: { scopes: string[]; workspaceIds: number[] | null; expiresAt: string | null }.identityForToken()reads the new resolver columns; NULL scopes = legacy grant["*","owner"];isOwnerbecomesu.is_owner && grant includes "owner".createApiToken(id, {name, scopes, workspaceIds, expiresInDays})validates scopes against the catalog and persists;ApiTokenRow+listApiTokenssurfacescopes,workspaceIds,expiresAt. - Verification:
cd /home/justinwieb/forge-suite-wt/feat/api-token-scoping && npx tsc --noEmit - Commit:
feat(tokens): identity carries token grant; owner passthrough gated
Task 4: Central enforcement in currentUser + method stamp in proxy¶
- Files:
/home/justinwieb/forge-suite-wt/feat/api-token-scoping/proxy.ts,/home/justinwieb/forge-suite-wt/feat/api-token-scoping/lib/auth.ts - What: proxy.ts
withPath()additionally stampsx-ct-method. IncurrentUser(), when identity came from a token: readx-ct-pathname/x-ct-methodfromheaders(), runtokenAllows; on deny log oneconsole.warn(token id + path, never the secret) and return null (route 401s). Missing path header on a token call fails closed. - Verification: mint a legacy token via psql into
ct_feat_api_token_scoping, thencurl -fsS -H "Authorization: Bearer $TOK" http://127.0.0.1:3061/api/taskssucceeds (grandfathered) and a garbage token returns 401 - Commit:
feat(tokens): enforce scopes centrally in currentUser
Task 5: Workspace binding at the listWorkspaces chokepoint¶
- Files:
/home/justinwieb/forge-suite-wt/feat/api-token-scoping/lib/data.ts,/home/justinwieb/forge-suite-wt/feat/api-token-scoping/lib/active-workspace.ts,/home/justinwieb/forge-suite-wt/feat/api-token-scoping/app/api/tasks/route.ts - What:
listWorkspaces()filters results toidentity.token.workspaceIdswhen bound (active-workspace resolution, the tasks union, and slug lookups inherit it). AddassertWorkspaceAllowed(me, wsId)helper in active-workspace.ts; wire it into the tasks POST explicit-workspaceIdpath so a bound token cannot write into an out-of-scope workspace it is otherwise a member of. - Verification: with a token bound to one workspace:
curl /api/workspace/listshows only that workspace; task POST with an out-of-scopeworkspaceIdreturns 4xx; task POST withoutworkspaceIdlands in the bound workspace - Commit:
feat(tokens): per-token workspace binding
Task 6: /api/tokens accepts scopes, workspaces, expiry¶
- Files:
/home/justinwieb/forge-suite-wt/feat/api-token-scoping/app/api/tokens/route.ts - What: POST body gains
scopes: string[],workspaceIds: number[] | null,expiresInDays: number | null; validate against the catalog (unknown scope = 400, empty scopes = 400), workspaceIds validated against the caller's memberships. GET returns the new fields. Route stays sessionUser-only. - Verification:
curl -X POST http://127.0.0.1:3061/api/tokens -d '{"name":"forge-bridge","scopes":["tasks:read","tasks:write","notes:write","workspaces:read"],"expiresInDays":90}'(dev-auth session) returns actk_; that token gets 200 on/api/tasks, 401 on/api/finance/accounts, 401 on/api/tokens - Commit:
feat(tokens): mint scoped tokens via API
Task 7: End-to-end scope matrix against the slot¶
- Files:
/home/justinwieb/forge-suite-wt/feat/api-token-scoping/scripts/dev-verify-token-scope.mts(extend with a curl matrix section or companion shell block in the same commit) - What: Scripted matrix on :3061: tasks-only token (tasks 200, finance 401, node append 401 without notes:write), notes:write token (append 200), workspace-bound token (list filtered, cross-ws write denied), expired token via psql backdate (401), legacy NULL-scopes token (full access, owner passthrough intact), hard-deny (
/api/tokens401 for any token). - Verification: the script exits 0 with a printed PASS line per case
- Commit:
test(tokens): scope enforcement matrix
Task 8: ApiTokensPane UI (scopes, workspaces, expiry, badges)¶
- Files:
/home/justinwieb/forge-suite-wt/feat/api-token-scoping/components/profile/SettingsModal.tsx(ApiTokensPane, line ~251; extract to/home/justinwieb/forge-suite-wt/feat/api-token-scoping/components/profile/ApiTokensPane.tsxif it outgrows the modal file) - What: Create form: per-app rows with View/Edit checkboxes rendered from
SCOPE_CATALOGlabels/descriptions, "Full access" supersedes and greys the rest, "Owner apps" its own row, workspace multi-select (default "All workspaces"), expiry dropdown (30/90/365 days/never, default 90). Token list rows show plain-English scope badges, workspace count, expiry date; NULL-scopes rows labeled "Full access (legacy)". - Verification:
npx tsc --noEmit; visual check at http://100.97.43.104:3061 (avatar, Settings, API tokens): mint a tasks-only token in the UI and confirm badges render - Commit:
feat(tokens): scope/workspace/expiry picker in settings
Task 9: Update docs/API-BOTS.md¶
- Files:
/home/justinwieb/forge-suite-wt/feat/api-token-scoping/docs/API-BOTS.md - What: Replace the "tokens have no scopes" section: scope catalog table, default-deny rule, workspace binding, expiry, legacy grandfathering, owner gating, recommended Forge-bridge grant (
tasks:read tasks:write notes:write workspaces:read, one workspace, 90d). - Verification:
grep -c "tasks:read" /home/justinwieb/forge-suite-wt/feat/api-token-scoping/docs/API-BOTS.mdreturns >= 1 and the old "no scopes" claim is gone (! grep -qi "no scopes" docs/API-BOTS.md) - Commit:
docs(tokens): scoped PAT contract for bots
Task 10: Register and document¶
- Files:
/home/justinwieb/forge/memory/general/reference_creatortrack_api_token_scopes.md(new, with mkdocs URL line),/home/justinwieb/forge/MEMORY.md(one index line under CreatorTrack),/home/justinwieb/forge/memory/general/reference_creatortrack_bot_api.md(link to the new file) - What: Topic file: catalog summary, enforcement point (
currentUser+x-ct-pathname/x-ct-method), legacy grandfathering, HARD-DENY list, prod-migration-required flag for merge day. Runbash /home/justinwieb/forge/scripts/forge_eval_run.sh. - Verification:
bash /home/justinwieb/forge/scripts/forge_eval_run.shpasses; MEMORY.md line renders - Commit:
docs(memory): creatortrack PAT scoping reference(forge repo)