Skip to content

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 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. Replace core.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 60s last_used_at throttle. 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.py then 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 *), and tokenAllows(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 Identity with optional token?: { scopes: string[]; workspaceIds: number[] | null; expiresAt: string | null }. identityForToken() reads the new resolver columns; NULL scopes = legacy grant ["*","owner"]; isOwner becomes u.is_owner && grant includes "owner". createApiToken(id, {name, scopes, workspaceIds, expiresInDays}) validates scopes against the catalog and persists; ApiTokenRow + listApiTokens surface scopes, 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 stamps x-ct-method. In currentUser(), when identity came from a token: read x-ct-pathname/x-ct-method from headers(), run tokenAllows; on deny log one console.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, then curl -fsS -H "Authorization: Bearer $TOK" http://127.0.0.1:3061/api/tasks succeeds (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 to identity.token.workspaceIds when bound (active-workspace resolution, the tasks union, and slug lookups inherit it). Add assertWorkspaceAllowed(me, wsId) helper in active-workspace.ts; wire it into the tasks POST explicit-workspaceId path 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/list shows only that workspace; task POST with an out-of-scope workspaceId returns 4xx; task POST without workspaceId lands 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 a ctk_; 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/tokens 401 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.tsx if it outgrows the modal file)
  • What: Create form: per-app rows with View/Edit checkboxes rendered from SCOPE_CATALOG labels/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.md returns >= 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. Run bash /home/justinwieb/forge/scripts/forge_eval_run.sh.
  • Verification: bash /home/justinwieb/forge/scripts/forge_eval_run.sh passes; MEMORY.md line renders
  • Commit: docs(memory): creatortrack PAT scoping reference (forge repo)