Skip to content

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:verb strings (e.g. tasks:write). write implies read. 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 specials owner and *. Personal Contacts (/api/apps/crm) is a SEPARATE contacts scope 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 explicit owner scope; * 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.ts was 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_token returns 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 (identityForToken parses + grandfathers, createApiToken validates + inserts).
  • Enforcement point: lib/auth.ts currentUser() calls tokenAllows(identity.token, x-ct-pathname, x-ct-method) for bearer calls; proxy.ts stamps x-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.ts listTasks/listArchivedTasks filter member workspaces by binding), the tasks write guard (app/api/tasks/route.ts), and the shared lib/app-access.ts blockedAppResponse gate (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)

  1. 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.
  2. Recommended Forge-bridge grant: tasks:read tasks:write notes:write workspaces:read, bound to the one working workspace, 90-day expiry.
  3. 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 shared blockedAppResponse gate. Still uncovered (RLS membership backstop only, same-user other-workspace, not cross-tenant): non-/api/apps routes that take an explicit workspaceId without the gate (finance /api/finance/*, direct /api/node + /api/collection writes, ideas/campaigns). Finance is additionally owner-gated. Tighten via the deferred RLS app.token_ws_allow GUC if universal binding is wanted.
  4. Unrelated latent bug: lib/workspace.ts:213 setMemberBlockedApps shares the drizzle bare-array binding bug that bigintArrayLiteral works around; fix opportunistically.
  • [[reference_creatortrack_bot_api]] (the PAT contract this hardens)
  • [[reference_creatortrack_dev_testing]] (WORKSPACE_DEV_AUTH slot the build was verified on)