Public API /api/v1 (Phase 3) Implementation Plan
For Claude: REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
Goal: A versioned, API-key-authenticated /api/v1 letting other apps read Tending data, run the programmatic export, and create moments — the inbound half of interoperability.
Architecture: An src/lib/api-keys/ module (generate, hash, verify, scope, rate-limit) and read/write routes under src/app/api/v1/. Keys are hashed at rest (shown once), org-scoped, with read vs read-write scope. Read routes reuse the existing query logic; the export route reuses collectOrgData + the renderers from Phase 1. Depends on Phase 1 (exports) being present on this branch — it is (this branch is off feat/data-portability).
Tech Stack: Next.js 15 App Router, Drizzle (Neon), Vitest, Node crypto (no new deps), Zod. Reference design: docs/plans/2026-07-09-data-portability-design.md (Part 2).
Testing note: Pure pieces (key generation/hashing/parsing, rate-limit window math) are TDD'd with Vitest; routes verified by tsc + build + review + a manual curl smoke with a real key.
Task 1: Schema — api_keys (+ usage) + migration
Files: Create src/lib/db/schema/api-keys.ts; export from index.ts; npm run db:generate.
api_keys:id(uuid pk),organisationId(fk, cascade),name(text — human label),hashedKey(text — sha256 of the key, unique),prefix(text — first ~10 chars, shown in UI to identify),scope(enumread|read_write— addapiKeyScopeEnum),lastUsedAt(timestamptz null),createdByEmail(text),revokedAt(timestamptz null),createdAt.- Rate limiting (in-DB fixed window): either a
windowStartedAt+windowCountpair onapi_keys, or a smallapi_key_usage(keyId, windowStart, count). Prefer columns onapi_keysfor simplicity.
Review generated SQL; commit schema + migration. db:push is user-gated ([[mycelia-local-uses-prod-db]]) — do not run from a subagent.
Commit: feat(api): api_keys table
Task 2: Key generation + hashing (pure, TDD)
Files: Create src/lib/api-keys/keys.ts + keys.test.ts.
generateApiKey(): { key: string; prefix: string; hashedKey: string }— key =tnd_live_+ 32 random bytes base64url;prefix= first 12 chars;hashedKey= sha256 hex of the full key.hashApiKey(key: string): string— sha256 hex (for lookup on incoming requests).
Test: key starts with tnd_live_; hashApiKey(generated.key) === generated.hashedKey; two generations differ (call twice); prefix is a stable slice of the key.
Commit: feat(api): key generation + hashing
Task 3: Rate-limit window (pure, TDD)
Files: Create src/lib/api-keys/rate-limit.ts + rate-limit.test.ts.
checkWindow({ windowStartedAt, windowCount }, now, limit, windowMs): { allowed: boolean, next: { windowStartedAt, windowCount } } — fixed window: if now - windowStartedAt > windowMs, reset to { now, 1 } allowed; else increment; allowed while windowCount < limit. Test reset, increment, and the over-limit boundary (pass now in — no real clock).
Commit: feat(api): fixed-window rate limit
Task 4: getApiContext auth middleware
Files: Create src/lib/api-keys/context.ts; reference src/lib/utils/api.ts.
getApiContext(request, requiredScope): Promise<{ organisationId, scope }> — read Authorization: Bearer <key>, hashApiKey, look up an unrevoked api_keys row by hashedKey; throw "Invalid API key" (→401) if none; if requiredScope === "read_write" and row scope is read → throw "Insufficient scope" (→403); enforce the rate-limit window (throw "Rate limit exceeded" →429, updating the window); update lastUsedAt. Add a matching error-mapping helper for the v1 routes.
Commit: feat(api): getApiContext (auth + scope + rate limit)
Task 5: Read routes
Files: Create src/app/api/v1/connections/route.ts (+ [id]), .../moments/route.ts, .../observations/route.ts, .../spaces/route.ts.
Each GET: getApiContext(request, "read"); query the org's rows (reuse existing query shapes / validators like listMomentsSchema); return a stable public JSON shape { data: [...], pagination? }. Keep responses versioned and minimal; do not leak internal-only fields (mirror the export allowlist discipline — no Stripe/secret columns).
Commit: feat(api): v1 read routes (connections, moments, observations, spaces)
Task 6: Programmatic export route
Files: Create src/app/api/v1/export/route.ts.
GET with getApiContext(request, "read") and ?format=json|yaml|okf (default json). Reuse collectOrgData(orgId) + renderJson/renderYaml/buildExportArchive from Phase 1. json/yaml → the single file with the right content-type; okf → the .zip. This is the programmatic counterpart of the UI export deferred here from Phase 1.
Commit: feat(api): v1 programmatic export
Task 7: Create-moment write route
Files: Create src/app/api/v1/moments/route.ts POST.
getApiContext(request, "read_write"); validate with a public create schema (content + optional connection refs/space/eventDate); create the moment attributed to the API key (source "email"/a new "api" source — check the moment_source enum; add "api" if wanted, else reuse an existing value). Run the SAME best-effort AI/link side-effects as the session route (or factor the moment-creation core into a shared helper to avoid divergence). Return the created moment. This generalises the "external app creates a moment" inbound flow.
Commit: feat(api): v1 create moment (read-write scope)
Task 8: API keys settings UI
Files: Create src/components/developers/api-key-manager.tsx (client) + CRUD API (src/app/api/api-keys/route.ts GET/POST, .../[id]/route.ts DELETE for revoke; admin+, session-auth via getOrgContext). Add to the "Developers" settings section (shared with the webhooks manager from Phase 2).
UI: list keys (name, prefix, scope, last used, created-by), create-key form (name + scope) that reveals the full key once, and revoke. Never re-display a full key after creation. Admin+ only.
Commit: feat(api): api key management UI
Task 9: Final verification
npx tsc --noEmit,npm run lint,npm test,npm run build— green.- Manual
curlsmoke: create areadkey → list connections/moments (200), create-moment (403 insufficient scope); create aread_writekey → create-moment (201); revoked key → 401; hammer to confirm 429. - Fresh-eyes review (data-safety: no secret fields in responses, scope enforced on every write, keys only sha256-hashed and never logged; rate-limit correctness).
- Update
STATE.md.
Out of scope
- OAuth / per-user tokens (API keys are org-scoped machine auth).
- Redis/Upstash rate limiting (only if in-DB proves insufficient).
- Webhook/pairing coupling to the Watershed spine (later).