Gatekeeper
| As of | |
|---|---|
| Version | 3.0.0 |
| Kind | plan |
| Length | 1,434 words, about 6 min |
| Tags | planliving-planphase-2api-guardrlsapiauthenticationfeature-system |
Gatekeeper is Phase 2: the single checkpoint every request passes. Scope: resolveApiCaller (who is asking), withApiGuard (scope, feature and RLS transaction), the feature registry, per-user entitlements, structured logging, /api/health and the ops scripts.
Milestone: The architectural backbone is live.
withApiGuardis implemented and tested. The feature registry exists. You could add any feature safely from here.Learning payoff: Middleware patterns, centralized auth, feature flags, the "why" behind the architecture.
Completed: October 2026 — walkthrough in Gatekeeper Walkthrough
Gate: ✅ Met — Front Door: Hardening was required before beginning the original Phase 2 tasks.
| # | Task | Complexity | Learning | Status |
|---|---|---|---|---|
| 2.1 | Implement resolveApiCaller(req) in packages/core/src/api/auth.ts — cookie sessions; Bearer API keys fail closed until Phase 9 | 🔴 | 🔐 🧩 | ✅ |
| 2.2 | Implement withApiGuard(handler, opts) in packages/core/src/api/guard.ts (see With API Guard and Gatekeeper Build Rules and Target Design) | 🔴 | 🔐 🧩 | ✅ |
| 2.3 | Wire withRLS inside withApiGuard | 🔴 | 🔐 🗄️ | ✅ |
| 2.4 | Add basic request logging inside withApiGuard (method, path, userId, latency) | 🟡 | 🧩 | ✅ |
| 2.5 | Add auth failure logging inside withApiGuard | 🟡 | 🧩 | ✅ |
| 2.6 | Define FeatureManifest type in packages/features-registry | 🟡 | 📦 | ✅ |
| 2.7 | Implement ALL_FEATURES array in packages/features-registry/src/index.ts — start with an empty array | 🟡 | 📦 | ✅ |
| 2.8 | Create user_feature_entitlements table in packages/core with userId, featureKey, RLS policy | 🔴 | 🗄️ 🔐 | ✅ |
| 2.9 | Implement getEnabledFeatures(tx) in packages/core — reads from entitlements table under RLS | 🟡 | 🗄️ | ✅ |
| 2.10 | Write a seed script to enable all features for your own user account during development (shipped as packages/ops, below) | 🟢 | 🗄️ | ✅ |
| 2.11 | Create a test API route /api/health using withApiGuard to verify the full guard chain works | 🟡 | 🧩 🔐 | ✅ |
| 2.12 | Verify 401 is returned when unauthenticated; 403 when a feature is disabled | 🟢 | ✅ |
Phase 2 Exit Criteria: ✅ All met, re-proven on main in lesson 8. withApiGuard is implemented and the /api/health route correctly returns 401/403 in the right conditions. The feature system can enable/disable features per user.
withApiGuardis implemented and tested: 14 unit tests and 5 integration tests inpackages/core./api/healthreturns 401 without a session and 200 with one, checked withcurl -iand a signed-in browser.- The 403 for a disabled feature is pinned by
packages/core/tests/integration/guard.test.ts(responds 403 to a user who is not entitled to the feature)./api/healthbelongs to no feature, so it can't return 403 by design. pnpm ops:entitlements:grant --email <you> --allenables features per user. Disabling is aDELETEwith the privileged role.ALL_FEATURESis still empty, so nothing is granted until Phase 4 registers Taxila.
Lessons
Phase 2 is built piece by piece, with Pratik learning as it goes (how we build). By default the coach hands over one small, explained piece at a time, and the two discuss it before it's final. Pratik writes the steps they call core themselves, and can ask the coach to code work with no learning payoff. Lessons run one at a time, in this order, each in its own thread. A lesson starts only after the previous lesson's PR is merged. The order starts with the smallest, self-contained work and builds up to the guard.
| Lesson | Brief | Tasks | What Pratik learns |
|---|---|---|---|
| 1 ✅ | Gatekeeper Lesson 1 (Tasks 2.6, 2.7): FeatureManifest and ALL_FEATURES | 2.6, 2.7 | TypeScript types, package exports, Vitest, branch → PR |
| 2 ✅ | Gatekeeper Lesson 2 (Task 2.1): resolveApiCaller | 2.1 | sessions vs. Bearer tokens, discriminated unions, mocking |
| 3 ✅ | Gatekeeper Lesson 3 (Tasks 2.8, 2.9): Entitlements Table and getEnabledFeatures | 2.8, 2.9 | schema, migrations, RLS policies, grants, integration tests |
| 4 ✅ | Gatekeeper Lesson 4 (Tasks 2.2, 2.3): withApiGuard With withRLS | 2.2, 2.3 | higher-order functions, generics, transactions |
| 5 ✅ | Gatekeeper Lesson 5 (Tasks 2.4, 2.5): Request and Auth-Failure Logging | 2.4, 2.5 | structured logging, ESLint overrides |
| 6 ✅ | Gatekeeper Lesson 6 (Tasks 2.11, 2.12): /api/health and Guard Verification | 2.11, 2.12 | route handlers, testing HTTP by hand |
| 7 ✅ | Gatekeeper Lesson 7 (Task 2.10): Dev Entitlement Seed Script | 2.10 | scripts, database roles, idempotent writes |
| 8 ✅ | Gatekeeper Lesson 8: Close-Out | none | writing it up |
To start a lesson, open a new thread and paste this, changing the lesson number:
Coach me through Phase 2 lesson 1, piece by piece as contracts.md describes:
hand me one explained piece at a time, and I'll say which ones I write myself. Read AGENTS.md,
docs/04-phases/02-gatekeeper/01-contracts.md
and that lesson's brief (the plan's Lessons table links each one) before we start.Every brief has the same parts: what gets built, the concepts, what the coach reads first, numbered steps, which steps are good to write yourself, questions to check understanding, and when the lesson is done. The coach builds from the target design in Gatekeeper Build Rules and Target Design, which changes when Pratik's own design wins.
Close-out (lesson 8): the coach wrote Gatekeeper Walkthrough, marked this plan complete the way Pre-2 was closed, replaced the sketch in With API Guard with the shipped behaviour, updated "Where things stand" in the agent guide, and recorded the phase's learnings in notes.md (publish: false). The brief is Gatekeeper Lesson 8: Close-Out.
Design decisions made while planning (details in Gatekeeper Build Rules and Target Design; each can be revisited in its lesson):
- Bearer API keys resolve to no caller (401) until Phase 9 adds
api_keys. getEnabledFeaturestakes the guard's RLS transaction and returns feature keys, sopackages/corestays feature-agnostic.- Users can read their own entitlements but can't write any. Only the privileged role writes them.
- The guard checks scope before opening the transaction and checks the feature inside it.
- Lesson 1: features are identified by
key(notslug), object shapes areinterfaces, and rollout gating stays out of the registry (post-MVP, B5). - Lesson 2: the caller variants are named interfaces with
kind: 'key', and anyAuthorizationheader resolves to no caller, even alongside a valid session. - Lesson 3: entitlements use a composite primary key
(user_id, feature_key)with no surrogateid,user_idreferencesprofiles, andapp_runtimecan onlySELECTthem. - After lesson 3:
resolveApiCallerreturnsundefined, notnull, for no caller (coding standard: preferundefined). - Lesson 4: handlers read
caller.userId(no separateuserId),featurestays optional and skips the entitlements query when unset, and the shared test helpers live inpackages/core/tests/helpers/. - Lesson 5: one place logs (
withApiGuard); the checks live inauthorizeAndHandle, which returns a response or a denial.api.deniedlogs the client'scode, every event carriesuserId(nullwithout a caller), and error logs drop Drizzle's query parameters. /api/healthrequires a session but no feature. The 403 path is proven by lesson 4's integration test.- Lesson 6:
/api/healthreturns{ status: 'ok' }, and the database client is created on first use (getUnsafeDb()), becausenext buildimports every route module and CI's build has noDATABASE_URL. - Lesson 7: the seed is
entitlements grantin a dedicatedpackages/opspackage for privileged scripts, which nothing may import. It runs undertsxwithdotenv-cli, writes only with--apply, and--applyis oneINSERT … ON CONFLICT DO NOTHING RETURNING.
Implementation notes:
- Task 2.10 shipped as a package, not a registry script.
packages/opsholds privileged scripts (dev seeds, provisioning, break-glass), runs them as thepostgresrole throughwithPrivilegedDb, and nothing may import it. The dependency direction gained a side branch,ops → registry, foundation(contracts). - The guard's order changed from the planning sketch. The scope check runs before the transaction and the feature check inside it, so a rejected key never takes a pooled connection and entitlements are read under RLS. With API Guard now describes the shipped order.
- Tasks 2.4 and 2.5 already cover Phase 10's 10.1 and 10.2 (request and auth-failure logging inside
withApiGuard), and the guard's errors already use 10.5's{ error, code }shape. Phase 10 still owns the shape for handler errors. - Late fixes landed as post notes in earlier walkthroughs: the Data API roles (Phase 1.1 and Pre-2, sidekick#17), the lazy database client (Pre-2, sidekick#21), the
opsboundaries element (Pre-2, sidekick#23) and pnpm build approvals (Phase 0, sidekick#14). The walkthrough lists them. - Added beyond the original list: a startup check for required variables (
apps/web/src/instrumentation.ts, sidekick#22), shared test helpers inpackages/core/tests/helpers/(sidekick#18), and theinterfacelint rule (sidekick#10).
Carried forward, not built in Phase 2:
opsfollow-ups: arevokecommand, apnpm ops <noun> <verb>dispatcher with the second command, and a--targetcheck once a second environment exists (B6).- CLI design belongs to Phase 9's
apps/cli, which calls the API with a key.opslogic moves into admin endpoints when a command is retired; only its interface carries over (Terminal). - A public liveness probe would be a recorded exception to
require-api-guard, decided by Pratik (B7). getClaims()instead ofgetUser()is the known latency lever, at the cost of revoked sessions working until their token expires (B8).