CreatorTrack API token scoping (ctk_ PAT scopes + workspace binding + expiry)¶
URL: https://mkdocs.justinsforge.com/memory/general/reference_creatortrack_api_token_scopes/
Status: DEPLOYED to prod 2026-07-11 (merged to main 2947df6, migration applied to lifeos, creatortrack-prod restarted). Backward-compat verified (existing bot token still authenticates) and scoped enforcement verified live on prod. Backup of pre-migration core.api_tokens + old resolve_api_token def in forge/data/backups/. Plan: creatortrack-api-token-scoping-2026-07-11. Extends CT bot API (Workstream B of the dev-tool-primitives handoff).
What changed¶
A ctk_ Personal Access Token used to resolve to the owning user's FULL identity: every workspace, every capability, owner apps, no expiry. Now each token carries its own grant, enforced least-privilege on top of RLS.
- Scopes:
resource:verbstrings (e.g.tasks:write).writeimpliesread. 23 catalog entries (tasks, notes, workspaces, finance, calendar, habits, crm=Sales pipeline, contacts=Personal address book, campaigns, news, university, meals, ideas, clips, journal, goals, social, email, agents, business, notifications, fitness, search) plus specialsownerand*. Personal Contacts (/api/apps/crm) is a SEPARATEcontactsscope from Sales CRM (/api/apps/sales=crm). - Default deny: any route with no catalog entry, plus the hard-denied
/api/tokens,/api/auth,/api/account, requires*. The three owner-only apps (fitness, agents, business) also require the explicitownerscope;*alone does not grant owner identity. - Workspace binding:
workspace_ids bigint[](NULL = all). Bound tokens see only those workspaces; out-of-binding writes are 403. - Expiry:
expires_at timestamptz; excluded at the DB resolver, so an expired token is a clean 401. - Legacy grandfathering: pre-scoping tokens have
scopes = NULL-> treated as["*","owner"]so existing bots keep working. The column DEFAULT is'[]'(deny-all), so any NEW insert that omits scopes is deny-all, NOT full access.scripts/mint-bot-token.tswas updated to write explicit["*","owner"].
Where it lives (all in the creatortrack repo)¶
- Migration:
db/migrations/20260711T202617_feat_api_token_scoping_*.sql(adds columns;core.resolve_api_tokenreturns them + excludes expired). - Catalog + matcher (pure, client-safe):
lib/token-scope.ts(SCOPE_CATALOG,requiredScope,tokenAllows,isValidScope). Canonicalizes the path (percent-decode, dot-segment, case-fold) before matching, fails closed. - Grant on identity:
lib/rls.ts(Identity.token),lib/api-tokens.ts(identityForTokenparses + grandfathers,createApiTokenvalidates + inserts). - Enforcement point:
lib/auth.tscurrentUser()callstokenAllows(identity.token, x-ct-pathname, x-ct-method)for bearer calls;proxy.tsstampsx-ct-pathname+x-ct-method. Deny -> null -> route 401. - Workspace binding chokepoints:
lib/data.ts(listWorkspaces,getWorkspaceBySlug),lib/active-workspace.ts(workspaceAllowedForIdentity), the tasks aggregate reads (lib/tasks.tslistTasks/listArchivedTasksfilter member workspaces by binding), the tasks write guard (app/api/tasks/route.ts), and the sharedlib/app-access.tsblockedAppResponsegate (covers all 20/api/apps/*routes, read + write, in one place). - Mint API + UI:
app/api/tokens/route.ts(POST accepts scopes/workspaceIds/expiresInDays, session-only),components/profile/SettingsModal.tsx(ApiTokensPane). - Smoke tests:
scripts/dev-verify-token-scope.mts(unit, 53 assertions),scripts/dev-verify-token-scope-e2e.sh(live HTTP matrix, self-cleaning). - Docs:
docs/API-BOTS.md(scope catalog + recommended Forge-bridge grant).
Merge-day operational notes (do NOT skip)¶
- Prod migration required. Deploy does NOT run migrations (prod DB = lifeos). After merge, apply the migration to prod, then immediately restart
creatortrack-prod:core.resolve_api_token's return shape changed, and pooled prepared statements will 500 PAT calls ("cached plan must not change result type") until the process bounces. - Recommended Forge-bridge grant:
tasks:read tasks:write notes:write workspaces:read, bound to the one working workspace, 90-day expiry. - Binding coverage (after the final-review fix): enforced on
/api/workspace/list, active-workspace resolution, the Tasks app (aggregate +?workspace=+ appWorkspace reads AND writes), and every/api/apps/*route via the sharedblockedAppResponsegate. Still uncovered (RLS membership backstop only, same-user other-workspace, not cross-tenant): non-/api/appsroutes that take an explicitworkspaceIdwithout the gate (finance/api/finance/*, direct/api/node+/api/collectionwrites, ideas/campaigns). Finance is additionally owner-gated. Tighten via the deferred RLSapp.token_ws_allowGUC if universal binding is wanted. - Unrelated latent bug:
lib/workspace.ts:213setMemberBlockedAppsshares the drizzle bare-array binding bug thatbigintArrayLiteralworks around; fix opportunistically.
Related¶
- [[reference_creatortrack_bot_api]] (the PAT contract this hardens)
- [[reference_creatortrack_dev_testing]] (WORKSPACE_DEV_AUTH slot the build was verified on)