Skip to content

Latest commit

 

History

History
224 lines (175 loc) · 13.7 KB

API.md

File metadata and controls

224 lines (175 loc) · 13.7 KB

Seed API

The Seed backend (services/api) is a Fastify 5 server backed by SQLite (better-sqlite3). It handles accounts, sync, authoritative progression and the AI coach proxy. The HTTP contract (request/response schemas, error codes, API_VERSION) lives in packages/shared/src/api.ts and both the app and the server validate against it.

  • Base path: /api
  • Content type: JSON
  • Interactive docs (non-production only): GET /docs (OpenAPI generated from the zod schemas)
  • Every response carries an x-request-id header; error bodies repeat it.

Running

npm run gen:secret            # prints a JWT_SECRET
cp .env.example .env          # then paste the secret
npm run db:migrate            # optional: the server also migrates on start
npm run dev:api               # http://localhost:4100

Tests: npm test --workspace services/api (vitest, in-memory SQLite, fake clock, fake coach model; nothing touches the network).

Storage and authorization

SQLite with numbered migrations (src/database/migrations.ts, recorded in schema_migrations). Foreign keys are on; every user-owned table references users(id) ON DELETE CASCADE.

Authorization is enforced in every query by user_id. This replaces row-level security: synced tables use the composite primary key (user_id, id), and every repository statement filters on the authenticated user's id, which comes only from the verified access token. A client can never name another user, and a record id that exists under another user is a different row. The test suite (tests/authorization.test.ts) proves that user B cannot read, overwrite, tombstone, export or delete user A's records or conversations, and cannot attach entries to A's habits.

Later migration path: Postgres/Supabase. The schema is already normalized with typed columns plus a JSON data column (→ jsonb), and the user_id filter maps directly onto RLS policies (user_id = auth.uid()). The global sync_seq counter becomes a Postgres sequence.

Authentication

Email + password accounts. Email verification is not required to use the app; the user object reports emailVerified so the app can nudge.

Item Details
Password hashing scrypt (node:crypto), 16-byte random salt per password, N=2^15 r=8 p=1, 32-byte key. Stored as scrypt$N$r$p$salt$hash so parameters can be raised later. Compared with timingSafeEqual.
Login errors One generic message, Email or password is incorrect.; unknown emails still run a dummy scrypt so timing is similar.
Access token HS256 JWT built with node:crypto: {sub, iat, exp, typ:"access"}, 15 min (ACCESS_TOKEN_TTL_MIN). The header must say alg: "HS256" (anything else, including none, is rejected before signature checking); signature compared with timingSafeEqual; the user must still exist. Send as Authorization: Bearer <token>.
Refresh token 32 random bytes, base64url, 30 days (REFRESH_TOKEN_TTL_DAYS). Only its SHA-256 is stored. Rotating: each refresh token works once and is replaced by a new one in the same family. Presenting a token that was already rotated or revoked is treated as theft and revokes the whole family (every session descending from that login). Other logins are unaffected.
One-time codes 6 digits, HMAC-SHA256 (keyed with JWT_SECRET) at rest, 15-minute expiry, 5 attempts, newest code only, single use.

Duplicate registration returns 409 conflict. This reveals that an email has an account. It is a deliberate usability trade-off (a silent success would leave people stuck waiting for an email that tells them nothing new). Enumeration is limited by the strict auth rate limit. Password reset, in contrast, always returns 200.

Email delivery: EMAIL_DELIVERY=log writes codes to the server log (development only; the server refuses to start with it in production). none sends nothing. No real provider is wired up yet, so in production verification and password reset cannot complete until one is added (implement EmailSender in src/services/email.ts).

Typical flow

  1. POST /api/auth/register → {user, tokens} (201). A verification code is sent.
  2. Use tokens.accessToken on every request. When it expires (401 unauthorized), call POST /api/auth/refresh with the refresh token, store both new tokens, and retry. If refresh fails, sign in again.
  3. POST /api/auth/verify-email with the code whenever the person enters it.
  4. POST /api/auth/logout with the refresh token on sign-out.

Endpoints

Auth column: public, or bearer (valid access token required). Rate limit column: auth means the stricter per-IP limit (AUTH_RATE_LIMIT_PER_MINUTE, default 10/min), otherwise the global limit (RATE_LIMIT_PER_MINUTE, default 120/min).

Method Path Auth Limit Body → Response
GET /api/health public global → healthResponseSchema (ai: configured/fallback; email: log_only — see notes)
POST /api/auth/register public auth registerRequestSchema → 201 authResponseSchema; 409 if the email exists
POST /api/auth/login public auth loginRequestSchema → authResponseSchema; 401 generic
POST /api/auth/refresh public auth refreshRequestSchema → authResponseSchema (new pair)
POST /api/auth/logout public auth refreshRequestSchema → {ok:true} (always)
POST /api/auth/verify-email bearer auth verifyEmailRequestSchema → publicUserSchema; 400 for wrong/expired/locked codes
POST /api/auth/resend-verification bearer auth → {ok:true} (no-op when already verified)
POST /api/auth/password-reset/request public auth passwordResetRequestSchema → {ok:true} always
POST /api/auth/password-reset/confirm public auth passwordResetConfirmSchema → {ok:true}; revokes all refresh tokens; also marks the email verified
GET /api/me bearer global → publicUserSchema
PATCH /api/me bearer global {displayName: string(≤40) | null} → publicUserSchema
POST /api/sync bearer global syncRequestSchema → syncResponseSchema (body limit 5 MB)
GET /api/account/export bearer global → full export (see below), cache-control: no-store
DELETE /api/account bearer global deleteAccountRequestSchema ({password, confirm:"DELETE"}) → {ok:true}; 403 wrong password
GET /api/coach/status bearer global → coachStatusSchema
POST /api/coach/messages bearer global coachSendRequestSchema → coachSendResponseSchema
POST /api/coach/plan bearer global coachPlanRequestSchema → coachPlanResponseSchema
GET /api/coach/conversations bearer global → conversationListSchema
GET /api/coach/conversations/:id bearer global → conversationDetailSchema; 404 if not yours
DELETE /api/coach/conversations/:id bearer global → {ok:true}; 404 if not yours
DELETE /api/coach/conversations bearer global → {ok:true} (deletes all of yours)

The coach is described in AI_COACH.md.

Account export

GET /api/account/export returns:

{
  "schemaVersion": 1,            // export format version
  "apiVersion": "1.0.0",
  "exportedAt": "2026-10-12T15:00:00.000Z",
  "user": { /* publicUser */ },
  "preferences": { /* or null */ },
  "records": { "habits": [], "entries": [], "focusSessions": [], "exerciseSessions": [], "reflections": [], "urgeSessions": [] },
  "coach": { "conversations": [ { "id", "title", "messages": [] } ] },
  "progress": { "asOf": "2026-10-12", "timezone": "UTC", "xpTotal", "level", "stage", "activeDays", "achievements", "unlockedCosmetics" }
}

Records include tombstones (deletedAt set) so the export matches what the server holds. Progress is computed for "today" in the stored preference time zone (UTC if none).

Account deletion

A hard delete in one transaction: refresh tokens are revoked and the users row is deleted, which cascades to every record, preferences, coach conversation/message/usage row, email code and refresh token. Access tokens stop working immediately because their user no longer exists. The email can be registered again as a new, empty account.

Sync protocol

POST /api/sync is the only data endpoint. The device sends what changed locally and receives what changed on the server, in one request:

{
  "cursor": 1234,              // from the previous response, or null for a full download
  "changes": { "habits": [...], "entries": [...], ... },   // full records, including tombstones
  "preferences": { ... },       // or null if unchanged
  "today": "2026-10-12",       // device local date
  "timezone": "Europe/Madrid"
}

The server, in one SQLite transaction:

  1. Applies incoming records (habits first, so new entries can reference new habits), always under the authenticated user's id.
  2. Applies preferences.
  3. Returns every record of the user (including tombstones) whose change sequence is greater than cursor (null → everything), plus server copies of any record the client sent that lost a conflict, the stored preferences, the new cursor, the rejected list, and progress.

Every server write takes the next value of one global, monotonic sequence (sync_seq) and stores it in the row's seq column (indexed with user_id). The response cursor is the current sequence value, so nothing committed before the response can be missed next time.

Conflict rules

Rule Behaviour
Last writer wins The incoming record replaces the server copy only if its updatedAt is strictly newer (compared as instants, so +02:00 and Z timestamps compare correctly). On a tie the server copy stays. If the client's copy lost and differs, the server copy is returned in changes.
Clock skew updatedAt more than 5 minutes ahead of the server clock is rejected with reason updated_at_in_future (records and preferences). Fix the device clock and sync again.
Deletes Tombstones: a record with deletedAt set. They follow the same LWW rule and propagate to other devices like any change. Undo = a newer copy with deletedAt: null.
Entry ownership An entry must reference a habit owned by the same user (live or tombstoned). Otherwise it is rejected with habit_not_found (also enforced by a composite foreign key).
One entry per occurrence At most one live entry per (habit, localDate) (a unique partial index). When an incoming live entry collides with a different live entry, the one with the newer updatedAt stays live; on a tie the lexicographically smaller id wins. The loser is stored as a tombstone (deletedAt = server time, updatedAt never moved backwards), reported in rejected with reason occurrence_conflict, and both server copies are returned in changes.
Progress The client never sends XP, levels or achievements (the schema has no such fields; unknown fields are stripped). progress is computed by computeProgress from the server's full copy of the user's records, using the request's today/timezone and the stored weekStartsOn.

Idempotency. Replaying the same request is harmless: every record ties with the stored copy, nothing is written, no sequence values are consumed, and the response is the same.

Limits. Up to 500 habits, 5 000 entries and 2 000 of each other collection per request; body up to 5 MB on this route (64 KB elsewhere). Larger histories can be uploaded across several syncs.

Rejection reasons

reason Meaning
updated_at_in_future updatedAt more than 5 minutes ahead of the server clock. collection: "preferences", id: "preferences" for preferences.
habit_not_found Entry's habitId is not a habit of this user.
occurrence_conflict This entry lost the one-entry-per-occurrence rule and is now a tombstone.

Errors

Every error uses the shared envelope:

{ "error": { "code": "bad_request", "message": "Invalid request: …", "requestId": "…", "retryable": false } }
code HTTP When
bad_request 400 (413 for oversized bodies) Validation failure (zod), malformed JSON, wrong/expired one-time code
unauthorized 401 Missing/invalid/expired access token; bad login; refresh token unknown, expired, revoked or reused
forbidden 403 Wrong password when deleting the account
not_found 404 Unknown route; a conversation that does not exist or is not yours
conflict 409 Email already registered
rate_limited 429 Rate limit exceeded (retryable: true)
internal 500 Unexpected server error; the message is generic and details are only in the server log

email_unverified and ai_unavailable exist in the shared contract but are not currently returned: verification is optional, and the coach falls back instead of failing.

Messages are safe to show to people. 5xx and 429 are retryable: true.

Rate limits

Per client IP (trustProxy is on, so put the server behind a proxy that sets X-Forwarded-For):

  • Global: RATE_LIMIT_PER_MINUTE (default 120) on every route.
  • Auth: AUTH_RATE_LIMIT_PER_MINUTE (default 10) on each /api/auth/* route.
  • Coach: additionally COACH_DAILY_LIMIT (default 30) AI messages + plans per user per local day.

Security notes

  • helmet headers; no CORS (native app only).
  • zod validation on every request body/params and every response (responses that do not match the contract become a generic 500 and are logged).
  • Body limit 64 KB (5 MB on /api/sync).
  • Logs: request bodies are never logged; pino redaction covers authorization headers, passwords, tokens, codes and message text. Coach model errors are logged as error type/status only.
  • Generic 500 messages; request ids on every response.