Skip to content

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 (enum read|read_write — add apiKeyScopeEnum), lastUsedAt (timestamptz null), createdByEmail (text), revokedAt (timestamptz null), createdAt.
  • Rate limiting (in-DB fixed window): either a windowStartedAt + windowCount pair on api_keys, or a small api_key_usage(keyId, windowStart, count). Prefer columns on api_keys for 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 curl smoke: create a read key → list connections/moments (200), create-moment (403 insufficient scope); create a read_write key → 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).