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-idheader; error bodies repeat it.
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:4100Tests: npm test --workspace services/api (vitest, in-memory SQLite, fake clock, fake coach
model; nothing touches the network).
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.
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).
POST /api/auth/register→{user, tokens}(201). A verification code is sent.- Use
tokens.accessTokenon every request. When it expires (401unauthorized), callPOST /api/auth/refreshwith the refresh token, store both new tokens, and retry. If refresh fails, sign in again. POST /api/auth/verify-emailwith the code whenever the person enters it.POST /api/auth/logoutwith the refresh token on sign-out.
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.
GET /api/account/export returns:
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).
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.
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:
- Applies incoming records (habits first, so new entries can reference new habits), always under the authenticated user's id.
- Applies preferences.
- 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 newcursor, therejectedlist, andprogress.
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.
| 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.
| 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. |
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.
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.
- 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
authorizationheaders, passwords, tokens, codes and message text. Coach model errors are logged as error type/status only. - Generic 500 messages; request ids on every response.
{ "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" } }