diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md index b9d5a129..87b5d732 100644 --- a/.github/CONTRIBUTING.md +++ b/.github/CONTRIBUTING.md @@ -252,7 +252,7 @@ Before opening a PR, run the 3 suites: ```bash pnpm --filter @nodea/api test # ~3 min pnpm --filter @nodea/web test # ~5 s -pnpm --filter @nodea/e2e test # ~3-5 min, requires Postgres + Mailpit + Chromium +pnpm --filter @nodea/e2e e2e # ~3-5 min, requires Postgres + Mailpit + Chromium ``` A PR with red tests will be put on hold until fixed before review. diff --git a/.github/SECURITY.md b/.github/SECURITY.md index d55472ea..821fe0a7 100644 --- a/.github/SECURITY.md +++ b/.github/SECURITY.md @@ -32,7 +32,7 @@ When reporting, please include: - The official server code (`packages/api/`, including OPAQUE / WebAuthn / TOTP / session flows). - The web bundle served by the official instance (`packages/web/`, crypto helpers, response handling, key-material lifecycle). - The shared schemas (`packages/shared/`) when they affect server-side validation. -- The deployment manifests in `infra/` (docker-compose, nginx config) when they introduce attack surface. +- The deployment manifests — the root `docker-compose.yml` and the web container's nginx config — when they introduce attack surface. - Cryptographic invariants documented at [`nodea.app/docs/security/tech`](https://nodea.app/docs/security/tech) — e.g. main key never leaves WebCrypto, HMAC guards never persisted, HKDF domain separation. **Out of scope:** diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a91d3cf6..f268dc76 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -71,7 +71,7 @@ jobs: - name: Setup Node uses: actions/setup-node@v6 with: - node-version: '22' + node-version: '24' cache: 'pnpm' - name: Install @@ -112,6 +112,12 @@ jobs: - name: Audit dependencies run: pnpm audit --audit-level=high + # Doc↔code drift guard: dead path references + count parity + # (store slices / i18n namespaces / entry tables). See + # scripts/check-docs.mjs. No network, no DB — runs in <1s. + - name: Check docs + run: pnpm check:docs + - name: Lint run: pnpm lint diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 00000000..a45fd52c --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +24 diff --git a/CLAUDE.md b/CLAUDE.md index c7ec3ce6..56b7a233 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -32,7 +32,7 @@ Modules **Mood · Goals · Journal · Library · Review · HRT** are shipping. * - **Backend**: Node 24 · Hono · Drizzle ORM · PostgreSQL 16 · Zod · session cookies (not JWT) - **Frontend**: React 19 · Vite · Tailwind · React Router v7 · **TypeScript strict** · Zustand · React Hook Form + Zod -- **Monorepo**: pnpm workspaces (`packages/api`, `packages/web`, `packages/shared`) +- **Monorepo**: pnpm workspaces (`packages/api`, `packages/web`, `packages/shared`, `packages/e2e`) - **Crypto**: WebCrypto (AES-GCM + HMAC-SHA-256) + OPAQUE (`@serenity-kit/opaque`) + WebAuthn (`@simplewebauthn/{server,browser}`) - **Deployment**: docker-compose (postgres + api + web); Drizzle migrations run on api boot (non-destructive). Postgres data is a bind mount under `$HOME/data/nodea/postgres/`, so **`docker compose down -v` / `docker volume prune` are no-ops on data — but never run them on a Nodea host anyway.** - **Tests**: Vitest + Playwright (e2e) diff --git a/README.md b/README.md index b6e994b5..478d41e9 100644 --- a/README.md +++ b/README.md @@ -73,13 +73,13 @@ docker compose up -d postgres # Postgres en container, le reste via pnpm pnpm install pnpm --filter @nodea/api db:migrate pnpm dev:api # port 3000 -pnpm dev:web # port 5173 +pnpm dev:web # port 8089 ``` Tests : ```sh -pnpm -r test # 383 tests d'intégration api + 489 tests unitaires web +pnpm -r test # plusieurs centaines de tests : intégration api + unitaires web ``` Trois bases Postgres coexistent sur la même instance, jamais diff --git a/docs/Architecture.md b/docs/Architecture.md index edd70aec..0461a63d 100644 --- a/docs/Architecture.md +++ b/docs/Architecture.md @@ -38,14 +38,16 @@ Docker-compose deployment bundle. ### Runtime - **Hono** on `@hono/node-server`, Node 24 ESM. -- **Drizzle ORM** against **PostgreSQL 16**. Schema: - [`packages/api/src/db/schema.ts`](../packages/api/src/db/schema.ts). +- **Drizzle ORM** against **PostgreSQL 16**. Table DDL lives under + [`packages/api/src/db/schema/`](../packages/api/src/db/schema/) (split + by domain: `users`, `auth`, `admin`, `entries`, `modules`, `enums`), + re-exported through `db/schema.ts`. Migrations in [`packages/api/drizzle/`](../packages/api/drizzle/). Run `pnpm --filter @nodea/api db:generate` then `db:migrate` to create and apply. - **Zod** at every request boundary, sharing schemas with the web package via `@nodea/shared`. -- **Session cookies** (HttpOnly, Signed, `SameSite=Lax`, `Secure` in +- **Session cookies** (HttpOnly, Signed, `SameSite=Strict`, `Secure` in prod). Backing `sessions` table; revoking a row kills the session immediately. - **Argon2id** password hashing via `@node-rs/argon2`, server-side. @@ -61,19 +63,21 @@ Docker-compose deployment bundle. /announcements → routes/announcements.ts (requireUser, public read) /modules-config → routes/modules-config.ts (1:1 per user, encrypted blob) /user-preferences → routes/user-preferences.ts (1:1 per user, encrypted blob) -/{collection} → routes/collection-factory.ts (one per entry table) +/records → routes/records.ts (unified, all entry tables) ``` -`/{collection}` is driven by `src/collections/registry.ts` — adding a -new module = adding an entry in that array, which is the single source -the factory loops over. There is nowhere to forget a guard. +`/records` is driven by the `COLLECTIONS` array in `src/collections.ts` +— adding a new module = adding an entry in that array, which is the +single source `createRecordsRoutes` loops over. The target collection +travels in the `X-Collection` header (issue #67), never in the URL. +There is nowhere to forget a guard. ### Middleware - `requireUser` — resolves the session cookie to a row on the `users` table and `c.set('user', …)`. - `requireAdmin` — stacks on `requireUser` and 403s non-admin roles. -- `requireGuard` — inside `collection-factory`, validates the +- `requireGuard` — inside `records.ts`, validates the `(moduleUserId, guard)` headers on update/delete operations. **No `user_id` involvement** : entry rows carry no FK to `users`, the server cannot link a row to a specific user. The @@ -318,7 +322,7 @@ the factory loops over. There is nowhere to forget a guard. sessions — the client unwraps the KEK + main key locally while the session is still pending (Auth-Spec §7.2.bis: no leak because no full cookie = no data routes accessible). - - Helpers: `auth/totp.ts` wraps `otplib@13.4.0` with the spec + - Helpers: `auth/totp.ts` wraps `otplib@13.4.1` with the spec params (SHA-1 / 6 / 30s, ±1 window skew, returns matched window for anti-replay). `auth/totp-backup-codes.ts` generates 10 × 120-bit base32 codes with 4-4-4-4-4-4 hyphenation, @@ -406,8 +410,8 @@ sequential to avoid row-level interference. Setup under [`packages/api/src/test/setup.ts`](../packages/api/src/test/setup.ts) runs `TRUNCATE … CASCADE` before each test, and forces `EMAIL_SERVICE_IMPL=recording` (cf. `vitest.config.ts`) so suites can -assert on outgoing mail without spinning up Mailpit. 383 integration -tests at the time of writing, covering register / login / activation +assert on outgoing mail without spinning up Mailpit. Several hundred +integration tests cover register / login / activation gates, OPAQUE round-trips, OPAQUE re-auth, change-password / reset / change-email / delete-self, recovery-code KEK, passkey enroll + login + PRF unwrap, TOTP enroll + verify, stepped MFA (TOTP + @@ -435,9 +439,9 @@ end Playwright smoke + TOTP scenarios live in `packages/e2e/`. covers only the i18n provider. - **Zustand** is the single application store, see [`src/core/store/nodea-store.ts`](../packages/web/src/core/store/nodea-store.ts). - Slices: `auth`, `crypto`, `modules`, `preferences`, `notifications`, - `mobileMenuOpen`, `flow`. There is **no** parallel singleton or Context - reducer. + Slices (8): `auth`, `crypto`, `modules`, `preferences`, + `notifications`, `ui`, `flow`, `versions`. There is **no** parallel + singleton or Context reducer. - **React Hook Form + Zod** for every form that ships to the server — resolver built from the shared schema. - **Routing**: URL stays at `/flow` regardless of the active module. @@ -496,9 +500,10 @@ end Playwright smoke + TOTP scenarios live in `packages/e2e/`. - **Branded types** (`AesMainKey`, `HmacMainKey`, `Base64`, `CipherIV`, `EncryptedBlob`) from `@nodea/shared/crypto-types` prevent mixing domains at compile time. -- **Guard derivation** (`guard-derivation.ts`): deterministic - HMAC-SHA-256 over `moduleUserId || ':' || recordId` with the HMAC - sub-key. No network round-trip. +- **Guard derivation** (`guard-derivation.ts`): deterministic, two + HMAC-SHA-256 passes with the HMAC sub-key — first a key scoped per + module (`HMAC(hmacKey, "guard:" + moduleUserId)`), then the tag over + `recordId`. No network round-trip. - **Two-layer wrap** (`factor-wrap.ts`): the main key is wrapped under a random KEK (label `nodea:wrap-main`), the KEK is wrapped under an HKDF sub-key of the OPAQUE `exportKey` (label @@ -541,8 +546,8 @@ stays a Tailwind class (universal CSS, no token needed). Vitest + jsdom. Crypto round-trips (AES, HKDF, factor-wrap, guard derivation, passkey-PRF unwrap), base64 encoders, the typed HTTP -client (mocked fetch), and the Zustand store. 489 unit tests at the -time of writing. +client (mocked fetch), and the Zustand store — several hundred unit +tests. --- @@ -605,8 +610,9 @@ knob (Postgres, cookie secret, SMTP, `WEB_BASE_URL`, web port). The seven modules (Mood, Goals, Journal, Habits, Library, Review, HRT) all build on the same encrypted technical base: one `*_entries` table per module (HRT spreads across four : `hrt_admin_logs_entries`, -`hrt_lab_results_entries`, `hrt_suppliers_entries`, -`hrt_schedules_entries`), two-phase creation with HMAC validation, +`hrt_lab_results_entries`, `hrt_suppliers_entries` (whose payload and +export plugin are named `hrt_products` — renamed at the payload layer +only), `hrt_schedules_entries`), two-phase creation with HMAC validation, server-unreadable data. See [`docs/Modules/.md`](./Modules/) for each module's cleartext payload and module-specific rules. diff --git a/docs/Auth-Spec.md b/docs/Auth-Spec.md index debe8b1e..5a835fce 100644 --- a/docs/Auth-Spec.md +++ b/docs/Auth-Spec.md @@ -197,8 +197,11 @@ re-opening the spec: The detail of primitives, key hierarchy (KEK / main key / wraps), frozen HKDF labels (`nodea:wrap-kek`, `nodea:wrap-main`, -`nodea:aes`, `nodea:hmac`), and AAD construction via `buildAAD()` -lives in the "Modèle cryptographique" doc in +`nodea:aes`, `nodea:hmac`), and AAD construction via the +`buildKekAAD` / `buildMainKeyAAD` / `buildPasskeyAAD` builders in +`core/crypto/factor-wrap.ts` (format +`nodea:v1\x1f\x1f`) lives in the "Modèle +cryptographique" doc in [`tech.md`](../packages/web/src/app/pages/docs/content/tech.md) (rendered at [`nodea.app/docs/security/tech`](https://nodea.app/docs/security/tech)). That doc is the source of truth; any evolution happens there, not @@ -235,8 +238,15 @@ All other tables (auth + MFA + sessions) are defined in §4.1. ### 4.1 Tables (Drizzle PostgreSQL) +The auth schema is split across three files: `schema/enums.ts` +(all `pgEnum`s), `schema/users.ts` (the `users` and `sessions` +tables), and `schema/auth.ts` (everything else — `opaque_records`, +`auth_factors`, the MFA tables, `email_verifications`, +`password_reset_tokens`). The blocks below group fields by table; +the file-path comments point at where each table actually lives. + ```ts -// packages/api/src/db/schema/users.ts +// packages/api/src/db/schema/enums.ts export const securityMode = pgEnum('security_mode', [ 'password_or_passkey', // default: one factor unlocks @@ -252,6 +262,8 @@ export const registerState = pgEnum('register_state', [ 'complete', // optionals (TOTP, passkey) handled, full session emitted ]); +// packages/api/src/db/schema/users.ts + export const users = pgTable('users', { id: uuid('id').primaryKey().defaultRandom(), email: text('email').notNull().unique(), @@ -282,7 +294,7 @@ export const users = pgTable('users', { ``` ```ts -// packages/api/src/db/schema/opaque.ts +// packages/api/src/db/schema/auth.ts // 1:1 with users. No separate PK — keyed on user_id. // The table exists to decouple OPAQUE rotation from other fields. @@ -300,7 +312,7 @@ export const opaqueRecords = pgTable('opaque_records', { ``` ```ts -// packages/api/src/db/schema/auth-factors.ts +// packages/api/src/db/schema/auth.ts (enum in schema/enums.ts) export const authFactorKind = pgEnum('auth_factor_kind', ['passkey']); @@ -331,7 +343,7 @@ export const authFactors = pgTable('auth_factors', { ``` ```ts -// packages/api/src/db/schema/mfa.ts +// packages/api/src/db/schema/auth.ts (mfa_factor enum in schema/enums.ts) export const mfaTotp = pgTable('mfa_totp', { userId: uuid('user_id').primaryKey().references(() => users.id, { onDelete: 'cascade' }), @@ -376,7 +388,7 @@ export const mfaBypassRequests = pgTable('mfa_bypass_requests', { ``` ```ts -// packages/api/src/db/schema/email-verifications.ts +// packages/api/src/db/schema/auth.ts (enum in schema/enums.ts) export const emailVerificationKind = pgEnum('email_verification_kind', [ 'register', // step 2 of multi-step register @@ -399,7 +411,7 @@ export const emailVerifications = pgTable('email_verifications', { ``` ```ts -// packages/api/src/db/schema/sessions.ts +// packages/api/src/db/schema/users.ts (session_kind enum in schema/enums.ts) export const sessionKind = pgEnum('session_kind', [ 'full', // fully authenticated session @@ -500,49 +512,50 @@ The whole sequence in **one transaction**. ### 5.1 Cookies -| Cookie | Lifetime | Routes accepted (middleware) | Issued when | Promoted to | -|---|---|---|---|---| -| `__Host-nodea_register` ✅ | 24h | `/auth/register/*` | After successful email verification (wizard step 2) | Cleared at the end of register | -| `__Host-nodea_mfa` ✅ | 5 min | `/auth/mfa/*` | After OPAQUE/passkey login finish | `__Host-nodea_session` once MFA completes | -| `__Host-nodea_migrate` (vestigial) | 30 min | `/auth/migrate/*` | No longer issued — no code path mints it since the Argon2id model was removed | `__Host-nodea_session` after crypto migration | -| `nodea_session` ✅ | 7 days (fixed, **no** slide) | everything else | Full login | Forced re-login after 7 days or revocation | +There is a **single** session cookie, `nodea_session` +(`auth/cookies.ts`). It is not segmented per phase: the same +cookie carries the register, `mfa_pending`, and full sessions; the +DB `kind` column (§5.2) discriminates server-side which phase the +session is in. The cookie's lifetime tracks the session row's +`expires_at` (5 min for `mfa_pending`, 24 h for `register`, 7 days +fixed — **no** slide — for `full`). -Every cookie: +The cookie: - `HttpOnly` -- `Secure` in prod (and every non-localhost environment) -- `SameSite=Lax` +- `Secure` in prod (and every non-localhost environment, driven by + `COOKIE_SECURE`) +- `SameSite=Strict` (SEC-08) — refuses to ride any cross-site + navigation. Email magic links (activation / reset / MFA-bypass + confirm) still work because they **set** a cookie on the response + rather than **read** one on the request, and once landed the SPA + stays same-origin. - Signed (`COOKIE_SECRET`, min 32 chars) -- `__Host-` prefix (locks to the domain, **forces** `Path=/` on - the browser side, and requires `Secure`). - -**Important note on scoping**: the `__Host-` prefix forces -`Path=/`, so every cookie travels with **every** request to the -domain. The "Routes accepted" column above is **not** a cookie -attribute: it's what the `loadSession` middleware checks. The -`requireUser`, `requireRegisterSession`, `requireMfaPending`, -`requireMigrate` middlewares only read **their** expected cookie -and refuse the others. If multiple cookies are present, each is -valid only on its own route set. - -This choice sacrifices a bit of "least-privilege" on the browser -side in favor of `__Host-`'s anti-subdomain property (no cookie -poisoned by a compromised subdomain). Trade-off accepted. +- `Path=/` + +**No `__Host-` prefix.** Earlier drafts of this spec described four +`__Host-`-prefixed cookies (`__Host-nodea_register`, +`__Host-nodea_mfa`, `__Host-nodea_migrate`, `__Host-nodea_session`) +gated by route; the implementation collapsed them into the one +`nodea_session` cookie above. `SameSite=Strict` carries the +anti-cross-site weight; the per-phase scoping that the prefix model +provided is now done by the `kind` column instead. ### 5.2 Unified session model -The four kinds live in `sessions` with a `kind` column. The -`loadSession` middleware: +All session kinds live in the `sessions` table with a `kind` column +(`session_kind` enum: `full` / `mfa_pending` / `register` / +`migrate`, the last vestigial). There is no `loadSession` +middleware: each phase-specific middleware reads the one +`nodea_session` cookie, loads the row, and asserts its expected +`kind`: + +- `requireUser` → `kind = 'full'`. +- `requireMfaPending` → `kind = 'mfa_pending'`. +- the register routes resolve their own `register` session inline. -1. Reads the cookie matching the route: - - `/auth/register/*` → `__Host-nodea_register` → `kind = 'register'` - - `/auth/mfa/*` → `__Host-nodea_mfa` → `kind = 'mfa_pending'` - - `/auth/migrate/*` → `__Host-nodea_migrate` → `kind = 'migrate'` - - otherwise → `__Host-nodea_session` → `kind = 'full'` -2. Loads the row, checks correct `kind` + `expires_at > now()`. -3. Silently refuses (cookie ignored) if the `kind` doesn't match - the route. -4. Updates `last_seen_at` (atomic, debounced to 1 min to avoid - spamming the DB). +Each loads the row, checks the expected `kind` + `expires_at > +now()`, and refuses (401) on a mismatch — so a `mfa_pending` cookie +can't be replayed against a `full`-only route and vice versa. ### 5.3 Re-auth fresh @@ -559,10 +572,12 @@ The matrix (§6) requires "fresh re-auth < 5 min". Implementation: `session.reauth_password_at >= now() - 5min`. Otherwise 401 with `reauth_required: 'password'`. - `requireFreshPasswordOrPasskey`: checks one OR the other. -- Dedicated re-auth routes: - - `POST /auth/reauth/password` → OPAQUE login lite, updates - `reauth_password_at` on the current session. - - `POST /auth/reauth/passkey` → WebAuthn assertion, updates +- Dedicated re-auth routes (each is a 2-step handshake, like login): + - `POST /auth/reauth/password/start` → OPAQUE login start; + `POST /auth/reauth/password/finish` → OPAQUE login finish, + updates `reauth_password_at` on the current session. + - `POST /auth/reauth/passkey/start` → WebAuthn assertion options; + `POST /auth/reauth/passkey/finish` → assertion verify, updates `reauth_passkey_at`. - `reauth_*_at` expires at logout, change-password, security-mode change. @@ -600,7 +615,7 @@ The matrix (§6) requires "fresh re-auth < 5 min". Implementation: | Regenerate KEK recovery code | password | Invalidates the previous `wrapped_kek_recovery` | | Change password | password **OR** passkey | Password is the only factor changeable via an alternative factor | | Change email | password | Triggers OPAQUE re-register + email re-verification | -| Delete account | password **AND** (passkey if enabled) **AND** (TOTP if enabled) | Confirmation by typed phrase | +| Delete account | password (fresh, via `requireFreshPassword`) | `DELETE /auth/me`, empty body. The UI gates on email retype + fresh password + a confirm dialog | | Reveal recovery code | **N/A**: not supported in V1 | Code generated once at signup, never re-shown | | Start a TOTP bypass | password (not fresh — direct OPAQUE login) | This is the "I lost my TOTP" screen on `mfa_pending` | | Current logout | none | | @@ -750,30 +765,32 @@ Server: `POST /auth/mfa/totp/verify` -Body: `{ code: "123456" }`. +Body: `{ code }`. The **same** field carries either a 6-digit TOTP +code **or** a backup code; the server disambiguates by format +(6 digits → TOTP, 24–64 chars → backup). There is no separate +`verify-backup` route. Server: -1. Loads `mfa_totp` (refuses if `enabled_at IS NULL`). -2. Computes TOTP for windows `[current-1, current, current+1]`. -3. Compares constant-time. -4. On match: - - `last_window = matched_window` (anti-replay). +1. Loads `mfa_totp` (refuses 400 `totp_not_enabled` if + `enabled_at IS NULL`). +2. If `code` matches `/^\d{6}$/` → **TOTP path**: + - Computes TOTP for windows `[current-1, current, current+1]`, + compares constant-time. + - On match: `last_window = matched_window` (anti-replay — refuse + if the matched window isn't strictly after the stored one). - `mfaTotpVerified = true` on the pending session. -5. Otherwise: try the backup codes. - -`POST /auth/mfa/totp/verify-backup` - -Body: `{ code: "xxxx-xxxx-xx" }` (the user can enter a backup -code in the same field — UI distinguishes by format). - -Server: -1. SHA-256 hash. -2. SELECT FROM `mfa_totp_recovery_codes WHERE user_id = $1 AND - code_hash = $2 AND used_at IS NULL`. -3. If found: `used_at = now()` (single-use). -4. `mfaTotpVerified = true`. -5. If every backup code is used: email "You used your last backup - code. Regenerate new ones in Settings." +3. Otherwise → **backup-code path**: + - Normalise (strip hyphens, uppercase), SHA-256 hash. + - `UPDATE mfa_totp_recovery_codes SET used_at = now() WHERE + user_id = $1 AND code_hash = $2 AND used_at IS NULL` (single + statement so a concurrent racer can't double-spend). + - No row updated → 401 `invalid_code`. + - `mfaTotpVerified = true`. +4. After verification, the route **finalizes inline** if no factor + remains (cf. §7.4): it returns `{ finalized: true }` and swaps + the cookie, or `{ finalized: false, missing: [...] }` otherwise. +5. If every backup code is now used: email "You used your last + backup code. Regenerate new ones in Settings." ### 8.4 Disable @@ -796,7 +813,7 @@ in cleartext (shown only once). ## 9. Passkey — details > Code: routes `packages/api/src/routes/auth-passkey.ts`, client -> orchestrator `packages/web/src/core/auth/passkey-flow.ts`, +> orchestrator `packages/web/src/core/auth/passkey/` (enroll/login), > dedicated Settings page `/passkeys` (and the "Passkey" > SecuritySection in Account → Security). Dismissable amber > sidebar tip prompts enrollment when `passkeysCount === 0` @@ -845,7 +862,8 @@ Client: "nodea:wrap-kek")`, wraps the existing KEK (which the client has in memory after a recent login or in-progress register): `wrapped_kek = AES-GCM(wk_passkey, kek, - AAD=users.id||"passkey"||credential_id)`. + AAD=buildPasskeyAAD(users.id, credentialId))` — + `nodea:v1\x1f\x1fpasskey\x1f`. 4. POST `/auth/passkeys/enroll/finish` with: - WebAuthn attestation response - `prf_supported: bool` @@ -872,8 +890,12 @@ the model. **V1 decision**: `userVerification: 'required'` for both enrollment and authentication. The server validates -`authData.flags.uv === true` on every assertion. 400 refusal -otherwise. +`authData.flags.uv === true` on every assertion. The failure code +differs by context: at **enrollment** a UV miss returns a distinct +`400 user_verification_required` (the user can act on it); at +**login / MFA** it collapses into the uniform +`401 invalid_credentials` so a UV-less assertion is indistinguishable +from any other failed assertion (anti-enumeration). At enrollment time, the browser itself refuses authenticators that don't support UV (or don't have it configured — for a @@ -908,20 +930,27 @@ Cf. §7.3. - The UI must guide clearly: "This passkey validates your identity but can't alone decrypt your data. Type your password to continue." +**The non-PRF handling is entirely client-side.** The server has no +`prfSupported` gate: it issues whatever session the mode warrants +(full or `mfa_pending`) on a valid assertion. The client detects +`!prfSupported` locally and reacts — there is no server round-trip +that says "this passkey can't decrypt, retry". + - **Session handling depends on the mode** (`core/auth/session/passkeys.ts`): - `password_or_passkey` — a passkey assertion is a *complete* login, so `/finish` returns a **full** session. A non-PRF credential would leave that session authenticated-but-keyless, which the Layout's key-missing guard bounces out of on the first - `/flow` hop. So the client **drops** the session (`/auth/logout`) - and resets its store before showing the prompt; the password - form then runs a normal full login that derives the key. The - passkey is rejected as a standalone login rather than half- - accepted. + `/flow` hop. So the client **detects `!prfSupported` and tears + the session down** (`/auth/logout`), resets its store before + showing the prompt; the password form then runs a normal full + login that derives the key. The passkey is rejected as a + standalone login rather than half-accepted. - `always_2fa` / `maximum` — the passkey was never a complete login, so `/finish` returns `mfa_pending`. That session is **kept** and the password is added as a second factor - (`mfa_password_verified`) before `/auth/mfa/finalize`. + (`mfa_password_verified`); the last factor-verify finalizes + inline (cf. §7.4 — no `/auth/mfa/finalize` route). ### 9.5 Fixed PRF input @@ -960,6 +989,10 @@ stays there). `GET /auth/passkeys/list`: list of credentials with `label`, `created_at`, `last_used_at`, `prf_supported`, `transports`. +`PATCH /auth/passkeys/{id}/label`: `requireFreshPassword`. Renames a +single credential (updates `auth_factors.label` for the row owned by +the caller). 404 if the id doesn't belong to this user. + The Settings UI must **visually distinguish** PRF-capable passkeys (badge "decrypts your data") from login-only ones (badge "login only, doesn't decrypt your data"). This distinction @@ -1049,15 +1082,18 @@ Per-email rate-limits (default, env-tunable): | Name | Checks | Failure output | |---|---|---| -| `loadSession` | Cookie + sessions row | 401 | -| `requireUser` | `loadSession` + `kind = 'full'` | 401 | -| `requireRegisterSession` | `kind = 'register'` | 401 | -| `requireMfaPending` | `kind = 'mfa_pending'` | 401 | -| `requireMigrate` | `kind = 'migrate'` | 401 | +| `requireUser` | `nodea_session` cookie + sessions row, `kind = 'full'` | 401 | +| `requireMfaPending` | `nodea_session` cookie + sessions row, `kind = 'mfa_pending'` | 401 | | `requireFreshPassword` | `reauth_password_at > now-5min` | 401 `{ reauth_required: 'password' }` | | `requireFreshPasswordOrPasskey` | one OR the other | 401 `{ reauth_required: 'password_or_passkey' }` | | `requireAdmin` | `requireUser` + `users.is_admin` | 403 | -| `rateLimit(opts)` | Per-IP/email rate-limit | 429 | +| `requireGuard` | per-collection HMAC guard match on a record mutation | 4xx | +| `rateLimit(opts)` | Per-IP (or keyed) rate-limit | 429 | + +There is no `loadSession`, `requireRegisterSession`, or +`requireMigrate` middleware: each session-kind check loads the one +`nodea_session` cookie and asserts the expected `kind` inline (§5.2); +the register routes resolve their own `register` session in-handler. ### 11.2 Composition @@ -1077,9 +1113,11 @@ const route = createRoute({ When a middleware refuses for missing re-auth, the front intercepts the `reauth_required` and shows a modal: -- `password` → single password field → POST `/auth/reauth/password`. +- `password` → single password field → `POST /auth/reauth/password/start` + then `/auth/reauth/password/finish`. - `password_or_passkey` → "Re-auth password" / "Re-auth passkey" - buttons. + buttons (the passkey path is `POST /auth/reauth/passkey/start` + then `/auth/reauth/passkey/finish`). After success, the original request is automatically retried. @@ -1133,11 +1171,29 @@ PR + this section's revision + a rotation plan.) | TOTP bypass | request TTL | 7 days | | Password policy | zxcvbn min score | 3 | | Password policy | min length | 8 | -| Rate limits | `/auth/register/*` | 5/h IP, 3/h email | -| Rate limits | `/auth/login/*` | 10/min IP, 20/h email | -| Rate limits | `/auth/migrate/*` | 10/min IP, 20/h email (aligned with login) | -| Rate limits | `/auth/recover-kek/*` | 5/h IP, 3/h email (130 bits BIP39 already protect from brute-force) | -| Rate limits | `/auth/mfa/*` | 5/min session | +| Rate limits | register start | 10/h IP | +| Rate limits | register finish | 5/h IP | +| Rate limits | register activate | 20/h IP | +| Rate limits | register invite-info | 30/h IP | +| Rate limits | login (start + finish) | 10/min IP | +| Rate limits | passkey login (start + finish) | 20/15min IP | +| Rate limits | request-reset | 5/h IP | +| Rate limits | reset (start + finish) | 10/min IP | +| Rate limits | recover-kek (start + finish) | 5/h IP (130 bits BIP39 already protect from brute-force) | +| Rate limits | recover-kek verify | 3/h IP | +| Rate limits | recovery-code setup | 5/h IP | +| Rate limits | MFA TOTP verify | 10/5min (keyed on pending session's user) | +| Rate limits | MFA passkey (start + finish) | 10/5min (keyed on pending session's user) | +| Rate limits | MFA bypass request | 3/h IP | +| Rate limits | MFA bypass confirm link | 20/h IP | +| Rate limits | reauth (password + passkey) | 10/15min IP | +| Rate limits | change-password | 5/h IP | +| Rate limits | change-email | 1/24h (keyed on user) | +| Rate limits | security-mode change | 10/15min IP | +| Rate limits | TOTP enroll | 10/15min IP | +| Rate limits | TOTP manage | 30/15min IP | +| Rate limits | passkey enroll | 10/15min IP | +| Rate limits | passkey manage | 30/15min IP | | Cooldown | change-email (between changes) | 7 days | ### 13.1 Environment variables @@ -1179,7 +1235,7 @@ The repo never contains credentials, nor a committed `.env` file. A single job: `cleanup-unactivated-accounts`, scheduled via `node-cron` at **03:00 every Monday (UTC, container TZ)**, in the -API process. Cf. `packages/api/src/cron/index.ts` — +API process. Cf. `packages/api/src/cron.ts` — `startCronScheduler()` is called from `index.ts` at startup. | Target | Purge condition | Why | @@ -1281,9 +1337,11 @@ To copy into PR checks or custom linters: with a `[REDACTED]` censor. This second layer protects even when a route forgets its Layer A blacklist, or when application code logs sensitive objects from elsewhere. -19. **NEVER** build an AES-GCM AAD other than via `buildAAD()` - from `@nodea/shared/crypto-types`. The linter / tests must - fail loudly on any other usage. +19. **NEVER** build an AES-GCM AAD other than via the dedicated + builders in `core/crypto/factor-wrap.ts` (`buildKekAAD`, + `buildMainKeyAAD`, `buildPasskeyAAD`, + `buildSessionDeviceLabelAAD`). The linter / tests must fail + loudly on any other usage. --- @@ -1291,11 +1349,13 @@ To copy into PR checks or custom linters: Mandatory tests **before** merging each phase. Locations: -- Vitest unit: `packages/api/test/auth/**` and - `packages/web/test/auth/**`. -- Vitest integration: `packages/api/test/integration/auth.test.ts` - (with `testcontainers` PostgreSQL). -- Playwright: `packages/web/e2e/auth.spec.ts`. +- Vitest unit: colocated `*.test.ts` (e.g. + `packages/shared/src/schemas/auth.test.ts`, + `packages/web/src/core/crypto/*.test.ts`). +- Vitest integration: `packages/api/src/test/auth*.test.ts` + (real Postgres; CI spins up a `postgres:16` service). +- Playwright: `packages/e2e/tests/*.spec.ts` (e.g. + `packages/e2e/tests/01-register-activate-login.spec.ts`). ### 15.1 Crypto unit tests @@ -1303,10 +1363,10 @@ Mandatory tests **before** merging each phase. Locations: |---|---| | AES-GCM round-trip with AAD | unit | | Distinct HKDF labels produce different keys | unit | -| `buildAAD([users.id, "password"])` is deterministic | unit | -| `buildAAD([a, b])` ≠ `buildAAD([a+b])` (length-prefix prevents collisions) | unit | -| `buildAAD([])` returns 0 bytes (degenerate case) | unit | -| `buildAAD` refuses a part > 65535 bytes (u16 limit) | unit | +| `buildKekAAD(userId, "password")` is deterministic | unit | +| `buildKekAAD` differs per tag (`password` ≠ `recovery`) and per userId | unit | +| `buildPasskeyAAD` binds the credential id (two creds → distinct AAD) | unit | +| `buildMainKeyAAD(userId)` ≠ `buildKekAAD(userId, …)` (domain separation) | unit | | KEK wrap/unwrap under wk_password (fixed export_key) | unit | | KEK wrap/unwrap under wk_passkey (fixed prf_output) | unit | | KEK wrap/unwrap under wk_recovery (fixed recovery_code) | unit | @@ -1389,7 +1449,7 @@ Mandatory tests **before** merging each phase. Locations: | Valid recovery code → KEK unwrap + new password OK + recovery code regenerated | integration | | Recovery code with invalid BIP39 checksum → client-side rejection (no server hit) | integration | | `recovery_code_hash` mismatch on the server → 401, **no** mutation applied | integration | -| `recovery_code_hash` mismatch logged as `auth.recover.hash_mismatch` | integration | +| `recovery_code_hash` mismatch logged as `[auth/recover-kek] hash_mismatch` | integration | | Regeneration in Settings → old `wrapped_kek_recovery` + old `recovery_code_hash` invalidated simultaneously | integration | | Destructive reset → `wrapped_kek_recovery` + `recovery_code_hash` NULL | integration | | `recovery_session_id` consumed only once | integration | diff --git a/docs/Database.md b/docs/Database.md index 08467966..e76cffb9 100644 --- a/docs/Database.md +++ b/docs/Database.md @@ -1,7 +1,9 @@ # Database -PostgreSQL 16 schema defined via **Drizzle ORM** in -[`packages/api/src/db/schema.ts`](../packages/api/src/db/schema.ts). +PostgreSQL 16 schema defined via **Drizzle ORM**. Table DDL lives under +[`packages/api/src/db/schema/`](../packages/api/src/db/schema/) (split +by domain: `users`, `auth`, `admin`, `entries`, `modules`, `enums`), +re-exported through `db/schema.ts`. Migrations generated by `drizzle-kit` live under [`packages/api/drizzle/`](../packages/api/drizzle/) and are applied automatically by the API container on boot. @@ -69,8 +71,10 @@ id; rights and TTL live here so logout/revocation is immediate. | `mfa_totp_verified` | `bool` | Stepped-MFA flag set after `/auth/mfa/totp/verify` succeeds. | | `pending_webauthn_challenge` | `text?` | WebAuthn challenge persisted on the row for `/auth/passkeys/{enroll,login}/finish` and `/auth/reauth/passkey/finish`. | | `pending_webauthn_challenge_at` | `ts+tz?` | TTL anchor on the challenge — 5 min, after which the value is rejected. | -| `ip_hash` | `text?` | Per-deployment salted hash. Audit trail only. | -| `user_agent` | `text?` | Audit trail only. | +| `ip_hash` | `text?` | **Deprecated** — never written since auth-v2; nullable for back-compat. | +| `user_agent` | `text?` | **Deprecated** — superseded by `device_label_cipher`. | +| `device_label_cipher` | `text?` | AES-GCM ciphertext of the client-computed device label (« MacBook », « iPhone »…) for the active-sessions UI. AAD-bound to `users.id + "session-device-label"`; server never sees cleartext. Issue #47. | +| `device_label_iv` | `text?` | IV for `device_label_cipher`. Both null until the client PATCHes a label. | | `last_seen_at` | `ts+tz?` | Debounced touch on each request (≤ 1/min/session). | | `expires_at` | `ts+tz` | Checked on every request. 7-day fixed (no slide). | | `created_at` | `ts+tz` | `defaultNow()`. | @@ -245,10 +249,11 @@ for the public feed query. ### Entry tables (one per module) -All 9 entry tables share the exact same shape, produced by the -`createEntryTable` factory in `schema.ts`. The structural uniformity -is what lets the route factory loop over a single array of collections -(`src/collections/registry.ts`) without per-module branches. +All 13 entry tables share the exact same shape, produced by the +`createEntryTable` factory in `schema/entries.ts`. The structural +uniformity is what lets `createRecordsRoutes` loop over a single array +of collections (the `COLLECTIONS` array in `src/collections.ts`) +without per-module branches. **Minimum-readable-surface design.** The server is never told which entry belongs to which user — access is scoped by @@ -281,7 +286,7 @@ Each row carries the strict minimum of plaintext columns: | `module_user_id` | `text` | Opaque per-module sub-identifier (sid). Client-generated. **Sole access scope** ; the user→sids mapping lives encrypted in `modules_config.payload`. | | `cipher_iv` | `text` | AES-GCM IV (96 bits, base64). Required to decrypt `payload`. | | `payload` | `text` | AES-GCM ciphertext of the cleartext JSON. Modules that need application-level timestamps (`updated_at`, etc.) put them here, inside the encrypted blob. | -| `guard` | `text` | HMAC-SHA-256 over `sid:id`. `"init"` on creation, promoted to `g_<64 hex>` via a second PATCH. Never returned in read responses. | +| `guard` | `text` | Two-pass HMAC-SHA-256 `HMAC(HMAC(hmacKey, "guard:"+sid), id)`. `"init"` on creation, promoted to `g_<64 hex>` via a second PATCH. Never returned in read responses. | **No `user_id`, no `created_at`, no `updated_at`.** The server cannot link a row to a user, and cannot order rows chronologically — clients @@ -354,8 +359,9 @@ The pair `(cipher_iv, payload)` is an AES-GCM ciphertext keyed by the AES sub-key derived from the user's main key via HKDF label `"nodea:aes"`. -`guard` is a deterministic HMAC-SHA-256 over `moduleUserId || ':' || -recordId`, keyed by the HMAC sub-key (HKDF label `"nodea:hmac"`). It +`guard` is a deterministic two-pass HMAC-SHA-256 keyed by the HMAC +sub-key (HKDF label `"nodea:hmac"`): a module-scoped sub-key +`HMAC(hmacKey, "guard:" + moduleUserId)`, then the tag over `recordId`. It is **hidden** in read responses and compared on update/delete via the `X-Guard` request header (post-SEC-01 ; never as a query parameter, which would land in `hono/logger()` output). The server diff --git a/docs/Internationalisation.md b/docs/Internationalisation.md index c25f40c2..61ffdcd9 100644 --- a/docs/Internationalisation.md +++ b/docs/Internationalisation.md @@ -28,12 +28,15 @@ packages/web/src/i18n/ │ ├── common.json │ ├── errors.json │ ├── goals.json + │ ├── habits.json │ ├── home.json + │ ├── hrt.json + │ ├── journal.json │ ├── layout.json + │ ├── library.json │ ├── modals.json │ ├── modules.json │ ├── mood.json - │ ├── journal.json │ ├── review.json │ └── settings.json └── en/ @@ -136,7 +139,7 @@ Review module are in `review.json` and go through `t()`. Two tools keep the locales aligned: -1. **Vitest test** (`parity.test.ts`) — iterates over the 14 +1. **Vitest test** (`parity.test.ts`) — iterates over the 17 namespaces and `expect(... onlyFr / onlyEn)` to be `[]`. Runs automatically in CI via `pnpm test`. 2. **CLI script**: `pnpm --filter @nodea/web i18n:diff` prints a diff --git a/docs/Modules/Goals.md b/docs/Modules/Goals.md index 4b46a133..f4cc3378 100644 --- a/docs/Modules/Goals.md +++ b/docs/Modules/Goals.md @@ -6,24 +6,35 @@ Tracking of annual (or multi-year) goals. - One entry = one goal. - Goals can be grouped by thread (free tag) and filtered by status (`open` → `wip` → `done`). -- No automatic logs. The encrypted payload carries an `updated_at` +- No automatic logs. The encrypted payload carries an `updatedAt` that the client bumps on every save (used by the "Recent" sort) and - a `completed_at` set when the status flips to `done`. + a `completedAt` set when the status flips to `done`. ## Expected cleartext payload -```json +```jsonc { - "date": "YYYY-MM-DD", // reference date (creation, deadline...) - "title": "string", // headline - "note": "string|optional", // free description - "status": "open|wip|done", // progression - "thread": "string" // free tag / group (optional) + "date": "YYYY-MM-DD", // reference date (creation, deadline...) + "title": "string", // headline + "note": "string|optional", // free description + "status": "open|wip|done", // progression (see enum note below) + "thread": "string", // free tag / group (optional) + "completedAt": "ISO|null", // set when status flips to `done`, else null + "updatedAt": "ISO|string" // bumped on every save (defaults to "") } ``` - `title` and `status` are required. - `thread` powers the history view and the form autocomplete. +- The full `status` enum is `open | wip | done | active | archived`. + `active` / `archived` are **legacy import aliases** kept for + forwards-compat with older export files ; they are collapsed in + `Goals/lib/mappers.ts` (`normalizeStatus`) to `open` / `done` + respectively, so the UI only ever surfaces the three canonical + states. +- `completedAt` defaults to `null` (cleared when the goal cycles back + to `open` / `wip`) ; `updatedAt` defaults to `""`. Both live in the + encrypted payload, not in a server column. ## Security @@ -52,13 +63,17 @@ Goals follows the rules shared by every module — see "title": "Learn React", "note": "Build a small side project", "status": "wip", - "thread": "#learning" + "thread": "#learning", + "completedAt": null, + "updatedAt": "2025-02-10T09:30:00.000Z" }, { "date": "2025-03-15", "title": "Tennis every week", "status": "open", - "thread": "#sport" + "thread": "#sport", + "completedAt": null, + "updatedAt": "2025-03-15T08:00:00.000Z" } ] } diff --git a/docs/Modules/Habits.md b/docs/Modules/Habits.md index 79da98f8..147f2a5f 100644 --- a/docs/Modules/Habits.md +++ b/docs/Modules/Habits.md @@ -1,5 +1,14 @@ # Habits module (habit tracking) +> **Status: DORMANT.** The module is hidden in the UI — +> `to_toggle: false` / `display: false` in +> `packages/web/src/app/modules-registry.tsx` (issue #98). Its **data +> layer stays intact**: the `habits-items` / `habits-logs` collections, +> the Zod schemas (`packages/shared/src/schemas/modules/habits.ts`), and +> the `flow/Habits/` directory still ship via import/export, so the +> invariants below must be preserved on any shared path. This doc +> describes the dormant-but-live shape, not a removed feature. + ## Layout Two tables, like Library: @@ -26,11 +35,11 @@ HMAC guard, two-phase creation, `requireGuard` validation). ```json { "title": "string", // e.g. "Tennis" - "category": "sport|health|creativity|relationship|other", + "category": "sport|santé|créativité|relation|autre", "frequency": "daily|weekly|monthly|custom", "target": "number|optional", // count/day or count/week if applicable "duration": "P6M|optional", // expected period, ISO 8601 format - "started_at": "YYYY-MM-DD", + "startedAt": "YYYY-MM-DD", "archived": "boolean|optional" } ``` @@ -40,7 +49,9 @@ HMAC guard, two-phase creation, `requireGuard` validation). ```json { "date": "YYYY-MM-DD", - "item_rid": "string", // UUID of the related habit (server-side id) + "itemRid": "string", // references the parent habits_items record by + // its **server id** (cf. Architecture §7.3 — + // cross-account restore can't yet remap these) "done": true } ``` @@ -62,12 +73,12 @@ Cleartext export format (same shape as Mood / Goals / Library): "frequency": "weekly", "target": 1, "duration": "P6M", - "started_at": "2025-08-01" + "startedAt": "2025-08-01" } ], "habits_logs": [ - { "date": "2025-08-05", "item_rid": "rec_abc123", "done": true }, - { "date": "2025-08-12", "item_rid": "rec_abc123", "done": true } + { "date": "2025-08-05", "itemRid": "rec_abc123", "done": true }, + { "date": "2025-08-12", "itemRid": "rec_abc123", "done": true } ] } } diff --git a/docs/Modules/Journal.md b/docs/Modules/Journal.md index 4e6fbd34..4013c58c 100644 --- a/docs/Modules/Journal.md +++ b/docs/Modules/Journal.md @@ -20,7 +20,7 @@ thematic threads. ```jsonc { - "type": "passage.entry", + "type": "journal.entry", "date": "YYYY-MM-DD", "thread": "string", // free thematic thread, optional "title": "string|null", // title, optional @@ -49,7 +49,7 @@ Journal follows the rules shared by every module — see ## Export / Import -- Cleartext export: `modules.passage[]` array in `export.json`. +- Cleartext export: `modules.journal[]` array in `export.json`. - Import: re-encrypts locally, then replays the POST init → PATCH promotion flow. - Read pagination: 200 entries per request. diff --git a/docs/Modules/Library.md b/docs/Modules/Library.md index 87ad6ebb..7e770793 100644 --- a/docs/Modules/Library.md +++ b/docs/Modules/Library.md @@ -57,9 +57,9 @@ deterministic HMAC `guard`, two-phase creation). ], "year": 1862, "language": "fr", // language of the read edition - "original_language": "fr", - "page_count": 1463, + "originalLanguage": "fr", "publisher": "Folio classique", + "collection": "Folio classique", // collection éditoriale, optional "summary": "Short text, optional", "series": { // optional "name": "Les Misérables", @@ -69,17 +69,16 @@ deterministic HMAC `guard`, two-phase creation). // Cover stored as an encrypted blob in `library_covers_entries` // (cf. §6). Only a logical pointer is kept here. - "cover_rid": "rec_cov_xyz", // null if no cover + "coverRid": "rec_cov_xyz", // null if no cover // State and personal experience. "status": "in_progress", // planned | in_progress | finished | abandoned "format": "paper", // paper | ebook | audio | unknown - "started_at": "2024-11-03", // optional - "finished_at": null, // null until finished - "current_page": 318, // useful when status === in_progress + "startedAt": "2024-11-03", // optional + "finishedAt": null, // null until finished "rating": 4, // 0..5, optional "tags": ["classic", "to gift"], // free, user-specific - "is_favorite": false + "isFavorite": false } ``` @@ -95,7 +94,7 @@ user likes, mid-reading thoughts, or the wrap-up notes live. ```jsonc { - "item_rid": "rec_abc123", // required — id of the related work + "itemRid": "rec_abc123", // required — id of the related work "date": "2025-01-08T19:42:00.000Z", "kind": "quote", // quote | note "title": "Chapter 12 — Cosette", // optional @@ -117,11 +116,11 @@ normal prose for notes) without forking the model. ```jsonc { - "item_rid": "rec_abc123", // required — join key + "itemRid": "rec_abc123", // required — join key "mime": "image/jpeg", - "blob_b64": "/9j/4AAQSkZJRgABAQ…", // reasonable size (≤80 KB) - "fetched_from": "openlibrary", // info, can be null - "fetched_at": "2026-04-26T12:00:00Z" + "blobB64": "/9j/4AAQSkZJRgABAQ…", // reasonable size (≤80 KB) + "fetchedFrom": "openlibrary", // info, can be null + "fetchedAt": "2026-04-26T12:00:00Z" } ``` @@ -243,7 +242,7 @@ provider-specific parser (Goodreads / Babelio / …) │ → normalise to `library_items_entries` ▼ (optional) metadata enrichment via /library/lookup - │ → known ISBN → fetch missing cover + page_count + │ → known ISBN → fetch missing cover ▼ local encryption + upload (POST guard:"init" → PATCH promote) │ @@ -324,7 +323,7 @@ without breaking the existing format. | Q2 | Metadata fetch | **Server proxy** with API key shared per Nodea instance. Preferences toggle to disable. | | Q3 | Covers | **Encrypted blob** in dedicated `library_covers_entries` table. | | Q4 | Priority imports | **Babelio** (format confirmed, cf. §5.1), **Inventaire.io**, then **Goodreads** / **StoryGraph** / generic CSV. | -| Q6 | Multi-reads | **No `reads[]` array** — flat `started_at` / `finished_at`. | +| Q6 | Multi-reads | **No `reads[]` array** — flat `startedAt` / `finishedAt`. | | Q7 | Review distinction | **Two kinds**: `quote` (passages / quotes) and `note` (everything else). | | Q8 | Tags | **Free-form** at MVP, no pre-defined taxonomy. | @@ -338,7 +337,7 @@ without breaking the existing format. `LibraryReviewPayload`, `LibraryCoverPayload`). - ✅ Drizzle tables: `library_items_entries`, `library_reviews_entries`, `library_covers_entries`. -- ✅ Backend routes via `collection-factory`. +- ✅ Backend routes via the unified `/records` factory (`routes/records.ts`). - ✅ K-page module with catalog and three URL-driven sub-views (`?subview=livres|extraits|notes`): - **`livres`**: grouped catalog, four display modes @@ -352,8 +351,8 @@ without breaking the existing format. - ✅ Five grouping axes: `status` (default), `author`, `tag`, `publisher`, `collection`. The `tag` axis lets a book appear in several groups (intentional). -- ✅ Composer add / edit of items and reviews via the global - `ComposerModal`. +- ✅ Composer add / edit of items and reviews via the per-module + inline composer (Markdown editor, `packages/web/src/ui/dirk/forms/`). - ✅ `BookPickerModal` for the "+ New quote / New note" flow: pick the parent book first, then the composer opens pre-filled. @@ -362,7 +361,7 @@ without breaking the existing format. - ✅ `POST /library/lookup/by-isbn` and `POST /library/lookup/by-query` endpoints. - ✅ In-memory server-side cache - (`packages/api/src/lookup/cache.ts`). + (`packages/api/src/services/library-lookup/cache.ts`). - ✅ Six adapters: Open Library, Google Books, BNF, BNE, Wikidata (via SPARQL), Amazon headless scraping (cf. §4.1). One more than the three originally planned. @@ -373,7 +372,7 @@ without breaking the existing format. ### Phase 3 — Covers · **partial** - ✅ Storage: encrypted `library_covers_entries` table, bulk - decryption at mount, mapped front-side by `cover_rid` (`` + decryption at mount, mapped front-side by `coverRid` (`` as a `data:;base64,…` URL). - ⚠️ **TODO**: `/library/cover/proxy` endpoint for server-side download of remote URLs (avoids mixed-content + client-side IP @@ -406,10 +405,10 @@ unmaintainable. ## 11. References -- Reused composer / Markdown editor: `packages/web/src/ui/dirk/ComposerModal.tsx` +- Reused composer / Markdown editor: `packages/web/src/ui/dirk/forms/MarkdownEditor.tsx` - K-page model: `packages/web/src/app/flow/Journal/index.tsx` (grouped list + Markdown viewer) - `collection-client` pattern: `packages/web/src/core/api/modules/collection-client.ts` -- Backend routes factory: `packages/api/src/routes/collection-factory.ts` +- Backend routes factory: `packages/api/src/routes/records.ts` - Modular split pattern (to mirror in the refactor): `packages/web/src/app/flow/Habits/` and `packages/web/src/app/flow/Review/` diff --git a/docs/Modules/Mood.md b/docs/Modules/Mood.md index 4ad5857c..afe7be02 100644 --- a/docs/Modules/Mood.md +++ b/docs/Modules/Mood.md @@ -12,8 +12,8 @@ Daily module for tracking mood and recording three positive things. ```json { "date": "YYYY-MM-DD", - "mood_score": "<-2..+2|string|number>", - "mood_emoji": "🙂", + "moodScore": "<-2..+2|string>", + "moodEmoji": "🙂", "positive1": "string", "positive2": "string", "positive3": "string", @@ -23,6 +23,12 @@ Daily module for tracking mood and recording three positive things. } ``` +`moodScore` is always a **string** (one of `-2 -1 0 1 2`) — never a +number, even though the value is numeric. Stored as a string for +forwards-compat with legacy entries. +`moodEmoji` is **legacy / optional** (`default('')` in the schema): +pre-Direction-K entries carried an emoji, the Sauge redesign drops it +from the form but old payloads still decode. The `positive1..3` fields are required (the "gratitude" goal). `question` / `answer` feed downstream analysis modules. @@ -55,8 +61,8 @@ Mood follows the rules shared by every module — see "mood": [ { "date": "2025-08-20", - "mood_score": 1, - "mood_emoji": "😊", + "moodScore": "1", + "moodEmoji": "😊", "positive1": "Walk with Eva", "positive2": "Made progress on Nodea", "positive3": "Good meal", diff --git a/docs/Modules/Review.md b/docs/Modules/Review.md index 818bbb29..6625a5f6 100644 --- a/docs/Modules/Review.md +++ b/docs/Modules/Review.md @@ -27,7 +27,7 @@ pass by the `collection-factory`. | ---------------- | ------------- | -------- | ---------------------------------------------- | | `id` | `text` PK | yes | UUID generated server-side, handle for `/records/:id` | | `module_user_id` | `text` | yes | Opaque sid — **the only access key** (the user → sid mapping lives encrypted in `modules_config`) | -| `payload` | `text` | yes | Base64 of an AES-GCM blob (encrypted content, **+ application-level `updated_at`** for the "modified on" sort) | +| `payload` | `text` | yes | Base64 of an AES-GCM blob (encrypted content, **+ application-level `updatedAt`** for the "modified on" sort) | | `cipher_iv` | `text` | yes | AES-GCM IV (12 bytes, base64) | | `guard` | `text` | yes | Stored HMAC, never returned on read | @@ -44,11 +44,23 @@ a `z.looseObject(...)` — adding or removing a field doesn't break validation, but the wizard and the reader only render what's listed here. +> **Envelope vs. content.** Only the **top-level envelope** is +> constrained by the schema: `year` (number), `lastYear` / `nextYear` +> / `closing` (each an optional `record(string, unknown)`), and +> `updatedAt` (the ISO write timestamp the List view reads for the +> « modifié le … » label, defaults to `""`). Note `closing` is a +> **top-level** key alongside `lastYear` / `nextYear` — not nested +> under `lastYear`. Everything **inside** those records is +> `z.unknown()` : the wizard builds it step by step (`config/steps.ts` +> + `config/step-fields.ts`), so the deep structure below is +> **illustrative**, not enforced. + ```json { "year": 2026, + "updatedAt": "2026-01-15T20:00:00.000Z", - "last_year": { + "lastYear": { // Page 4 — Review your calendar "agenda_review": ["string"], @@ -103,22 +115,23 @@ here. "forgiveness": "string", // Page 11 — Letting go (free text, replaces the drawing) - "letting_go": "string", - - // Page 12 — Closing the past year - "closing": { - "three_words": ["string"], // The past year in three words - "book_title": "string", // The book / film of my past year - "farewell": "string" // Say goodbye to your past year - } + "letting_go": "string" + }, + + // Page 12 — Closing the past year. + // TOP-LEVEL key (per schema), not nested under `lastYear`. + "closing": { + "three_words": ["string"], // The past year in three words + "book_title": "string", // The book / film of my past year + "farewell": "string" // Say goodbye to your past year }, - "next_year": { + "nextYear": { // Page 14 — Dare to dream big! "dream_big": "string", // Page 15 — This new year will look like this for me - // (same eight life areas as last_year) + // (same eight life areas as lastYear) "life_areas": { "personal_family": ["string"], "career_studies": ["string"], @@ -198,7 +211,8 @@ here. "review": [ { "year": 2026, - "last_year": { + "updatedAt": "2026-01-15T20:00:00.000Z", + "lastYear": { "agenda_review": ["trip to Tana", "leaving the mission"], "life_areas": { "personal_family": ["closer to my sister"], @@ -220,14 +234,14 @@ here. "challenges_how": "my sister pulled me back up" }, "forgiveness": "I forgive…", - "letting_go": "I let go of…", - "closing": { - "three_words": ["tired", "learning", "love"], - "book_title": "A long road", - "farewell": "Goodbye 2025, thanks for everything." - } + "letting_go": "I let go of…" + }, + "closing": { + "three_words": ["tired", "learning", "love"], + "book_title": "A long road", + "farewell": "Goodbye 2025, thanks for everything." }, - "next_year": { + "nextYear": { "dream_big": "see myself in a job that fits", "life_areas": { "...": "..." }, "triplets": { "self_love": ["my hands"], "...": "..." }, diff --git a/docs/adr/0007-hand-rolled-api-client.md b/docs/adr/0007-hand-rolled-api-client.md index 15ad0c37..3b9a9124 100644 --- a/docs/adr/0007-hand-rolled-api-client.md +++ b/docs/adr/0007-hand-rolled-api-client.md @@ -30,6 +30,15 @@ why didn't we adopt it? **Keep the hand-rolled dedicated functions, don't adopt `hc`.** +## Update (2026-06) — the client grew, the decision stands + +The "14" in the title is historical. The client is now **15 files** in +`core/api/` exposing **~71 `api*` functions** (`auth.ts` alone holds +~28). The decision is unchanged: wrappers stay hand-rolled over a +shared `request()`, typed against `@nodea/shared` schemas rather +than `hc`. Read "14" as "one `request()` wrapper + one +dedicated function per endpoint". + ## Consequences **Positive:** diff --git a/docs/adr/0011-drizzle-forward-only-migrations.md b/docs/adr/0011-drizzle-forward-only-migrations.md index be2b0858..041593a6 100644 --- a/docs/adr/0011-drizzle-forward-only-migrations.md +++ b/docs/adr/0011-drizzle-forward-only-migrations.md @@ -59,7 +59,7 @@ of the manual cleanup needed. - **Strong backup dependency.** If the operator hasn't configured the backup procedure (OPS-05), a broken migration = a potentially long outage. This dependency must be visible in the - runbook (`docs/Operations.md` §5 already mentions it). + runbook (the self-host operations notes cover this). - **No "quick undo" for a controversial migration.** If a migration lands and we realise the next day that it caused trouble (e.g. it subtly changed an index's semantics), there's diff --git a/docs/adr/0013-zustand-slice-pattern.md b/docs/adr/0013-zustand-slice-pattern.md index 35a528ed..c1d79fbb 100644 --- a/docs/adr/0013-zustand-slice-pattern.md +++ b/docs/adr/0013-zustand-slice-pattern.md @@ -81,6 +81,16 @@ The `resetAll` action stays in the assembly file, not in a slice: it touches every slice at once and that's precisely where ADR-0006's atomicity guarantee lives. +## Update (2026-06) — the `composer` slice was removed + +The store now has **8 slices**, not nine: `composer.ts` was dropped +when the message-composer state moved into the form components +(`ui/dirk/forms/*`). Current slices on disk: `auth`, `crypto`, +`modules`, `preferences`, `notifications`, `ui`, `flow`, `versions`. +This ADR is the system-of-record for the slice inventory; the passing +`composer` mentions in ADR-0002/0006 predate the removal. The slice +pattern and every other point of this ADR are unchanged. + ## Consequences **Positive:** diff --git a/docs/auth/BypassMfa.md b/docs/auth/BypassMfa.md index 564ceb83..2824346d 100644 --- a/docs/auth/BypassMfa.md +++ b/docs/auth/BypassMfa.md @@ -102,7 +102,8 @@ when the remainder drops below 24h). **No cancellation email link.** A pending request is auto-cancelled at the next promotion to a `full` session (`cancelPendingBypassesForUser` wired into `/auth/login/finish`, -`/auth/passkeys/login/finish`, `/auth/mfa/{totp,passkey}/finish`, +`/auth/passkeys/login/finish`, `/auth/mfa/totp/verify`, +`/auth/mfa/passkey/finish`, and the recovery code reset). A successful complete login proves the user still controls the allegedly-lost factor — the request is moot and gets cancelled. The legitimate owner of a compromised diff --git a/docs/auth/ChangePassword.md b/docs/auth/ChangePassword.md index b185f102..34909bf7 100644 --- a/docs/auth/ChangePassword.md +++ b/docs/auth/ChangePassword.md @@ -15,30 +15,30 @@ locally. Hence the 2-step pattern, mirrored from register / login. ### `POST /auth/change-password/start` -**Body**: +**Body** (`ChangePasswordStartBodySchema`): ```json { - "proofLoginToken": "...", - "proofFinishLoginRequest": "...", "registrationRequest": "..." } ``` -The client has already run a `/auth/login/start` round-trip with -the current password to produce the proof (cf. §13.X -`OpaquePasswordProofSchema`). `registrationRequest` comes from -`client.startRegistration(newPassword)`. +There is **no** `proofLoginToken` / `proofFinishLoginRequest` in +this body. Re-auth of the current password is handled **out of +band**: the route is gated by `requireFreshPasswordOrPasskey`, and +the client proves the current factor via a separate +`/auth/reauth/password/{start,finish}` round-trip *before* calling +`/change-password/start`. (Re-auth: see Auth-Spec §6, row *Change +password* — password **OR** passkey.) `registrationRequest` comes +from `client.startRegistration(newPassword)`. **Server**: -1. Precondition `requireUser` (valid session). -2. `verifyPasswordProof(user, body)`: consume the `loginToken`, - require `userIdentifier === user.email`, run `server.finishLogin`. - Failure → 401 `invalid_credentials`. -3. `server.createRegistrationResponse({ userIdentifier: user.email, +1. Preconditions `requireUser` + `requireFreshPasswordOrPasskey` + (valid session + fresh re-auth). +2. `server.createRegistrationResponse({ userIdentifier: user.email, registrationRequest })` → `registrationResponse`. -4. Stores a single-use `changePasswordToken` (TTL 5 min, in-memory +3. Stores a single-use `changePasswordToken` (TTL 5 min, in-memory `auth/opaque-pending-state.ts`) bound to `users.id`. -5. Response `200 { registrationResponse, changePasswordToken }`. +4. Response `200 { registrationResponse, changePasswordToken }`. ### `POST /auth/change-password/finish` @@ -66,8 +66,8 @@ pre-rotation ciphertext stays readable. 4. **Session ID rotation**: DELETE every session for this user (including the current one). INSERT a new `kind = 'full'` session with `reauth_password_at = now()`. -5. Issue a fresh signed `__Host-nodea_session` cookie. The old one - is explicitly cleared via `Set-Cookie` with a past date. +5. Issue a fresh signed `nodea_session` cookie. The old one is + explicitly cleared via `Set-Cookie` with a past date. 6. Response `200`. ### Front-end UX diff --git a/docs/auth/Lifecycle.md b/docs/auth/Lifecycle.md index f0b88f03..ccdd86f5 100644 --- a/docs/auth/Lifecycle.md +++ b/docs/auth/Lifecycle.md @@ -8,12 +8,25 @@ ## 7.9 Destructive reset (existing, preserved) -Functionally unchanged from the existing flow, but extended to purge -every new table: +Functionally unchanged in spirit from the existing flow, but +extended to purge every new table. The reset is an OPAQUE +re-registration, so it runs in **two** steps (the client needs the +server's `registrationResponse` before it can produce a +`registrationRecord`): `POST /auth/request-reset` → email with token (if email is verified). -`POST /auth/reset` → token + new password. Server: see purge §4.3, -then creation of the new wraps like in register (but keep + +`POST /auth/reset/start` → `{ token, registrationRequest }`. Server +validates the token and returns `{ registrationResponse, resetToken, +userId }`. + +`POST /auth/reset/finish` → `{ resetToken, registrationRecord, +wrappedMainKey, wrappedMainKeyIv, wrappedKekPassword, +wrappedKekPasswordIv }`. The old main key is unrecoverable, so the +client ships a **fresh** `wrappedMainKey`. Server: purges every +user-owned encrypted row (see [Auth-Spec §4.3 — *Data purged at +destructive reset*](../Auth-Spec.md#43-data-purged-at-destructive-reset)), +replaces every credential blob, marks the reset token used (keeps `email_verified_at`). The reset screen explicitly states: "All your encrypted data will be @@ -36,21 +49,30 @@ specific session by ID. 404 if the ID doesn't belong to this user (constant-time to avoid enumeration). 400 if `id == current` (use `/auth/logout` for that case). +`PATCH /auth/sessions/current/device-label`: `requireUser`. Sets the +encrypted device label on the current session row (decorates the +"Sessions actives" UI, issue #47). The label is wrapped client-side +under `buildSessionDeviceLabelAAD(users.id)` — +`nodea:v1\x1f\x1fsession-device-label`. + Client side: `resetAll()` on the Zustand store → main key and sub-keys become garbage-collectable (we can't wipe them, cf. CLAUDE.md rule 7). ## 7.11 Account deletion -`POST /auth/account/delete` +`DELETE /auth/me` -Preconditions: fresh password re-auth + (passkey re-auth if -`auth_factors.passkey` exists) + (live TOTP code if `mfa_totp.enabled_at` -is non-null). +Preconditions: `requireUser` + `requireFreshPassword` (fresh +password re-auth). Re-auth: see Auth-Spec §6 (row: *Delete +account*). -Body: `{ confirmation_phrase: "supprimer mon compte" }` (in French, -exact match — preserved as the literal user-facing confirmation -phrase). +Body: **empty** (`DeleteSelfBodySchema` — a loose `{}`). There is no +`POST /auth/account/delete` route and no `confirmation_phrase` field +on the wire. The confirmation gating lives entirely **client-side**: +the UI requires the user to retype their email, pass a fresh +password re-auth, and accept a confirm dialog before firing the +request. Server: §4.3 purge transaction + `DELETE FROM users WHERE id`. Cascade DELETE across every FK. diff --git a/docs/auth/Login.md b/docs/auth/Login.md index df4ebda4..5ef85cf0 100644 --- a/docs/auth/Login.md +++ b/docs/auth/Login.md @@ -108,9 +108,12 @@ Client Server │ verified) before finalize │ │ │ │ If mode = password_or_passkey: │ - │ POST /auth/mfa/finalize │ + │ done — /finish already returned │ + │ a full session │ │ If mode = always_2fa/maximum: │ - │ ... TOTP, plus password if max ...│ + │ ... TOTP, plus password if max; │ + │ the last factor-verify finalizes│ + │ inline (no /auth/mfa/finalize) ...│ ``` **PRF input**: a fixed input `"nodea:prf-v1"` (32 bytes, zero-padded) @@ -119,9 +122,13 @@ credential, independent of the WebAuthn challenge (which changes on every login). **Non-PRF passkey on `password_or_passkey`** (`core/auth/session/passkeys.ts`): -in this mode a passkey assertion is a *complete* login, so `/finish` -returns a **full** session — but a non-PRF credential can't unwrap the -main key. We do **not** keep that session: an authenticated-but-keyless +this handling is **entirely client-side** — the server has no +`prfSupported` gate and issues whatever session the mode warrants +on a valid assertion; the client detects `!prfSupported` locally and +reacts. In this mode a passkey assertion is a *complete* login, so +`/finish` returns a **full** session — but a non-PRF credential can't +unwrap the main key. We do **not** keep that session: an +authenticated-but-keyless client would be bounced straight back out by the Layout's key-missing guard the moment it reached `/flow` (the "passkey lets me in, then kicks me to /login" bug). Instead the client drops the session @@ -142,24 +149,29 @@ the pending session has `mfa_passkey_verified=true` but lacks endpoints** again to complete: - To add a password verification: `POST /auth/login/start` then - `/auth/login/finish` with the `__Host-nodea_mfa` cookie active. - The server detects the pending session (instead of creating a - new one) and bumps `mfa_password_verified=true`. -- To add a passkey verification: `POST /auth/passkeys/login/start` - then `/finish`. Bumps `mfa_passkey_verified=true`. + `/auth/login/finish` with the `mfa_pending` `nodea_session` + cookie active. The server detects the pending session (instead of + creating a new one) and bumps `mfa_password_verified=true`. +- To add a passkey verification: `POST /auth/mfa/passkey/start` + then `/auth/mfa/passkey/finish` (the stepped-MFA passkey pair, + not a single `/auth/mfa/passkey`). Bumps + `mfa_passkey_verified=true`. - To add a TOTP verification: `POST /auth/mfa/totp/verify`. Bumps `mfa_totp_verified=true`. -No new cookie is issued during these steps; we operate on the same -`mfa_pending` until `/auth/mfa/finalize`. +No new cookie is issued during these steps; the same `nodea_session` +cookie carries the `mfa_pending` session until finalisation. -### Finalisation +### Finalisation — inline, no dedicated route -`POST /auth/mfa/finalize` +**There is no `POST /auth/mfa/finalize` route.** Finalisation +happens *inside* the last factor-verify call. Each factor-verify +route (`/auth/mfa/totp/verify`, `/auth/mfa/passkey/finish`, and the +password path via `/auth/login/finish`) recomputes the missing +factors after marking its own `mfa_*_verified` column and, if none +remain: -**Server**: -1. Load the cookie's `mfa_pending` session. -2. Compute the required factors from `users.security_mode` + entry +1. Computes the required factors from `users.security_mode` + entry path: | mode | password-first | passkey-first | @@ -171,17 +183,18 @@ No new cookie is issued during these steps; we operate on the same Issue #72 — in `always_2fa` password-first, the 2nd factor is an OR set : the client can verify TOTP **or** assert any enrolled passkey (PRF or non-PRF). Either satisfies the - policy ; `/auth/mfa/finalize` does not gate on which path was - taken. Passkey-first stays TOTP-only (a second passkey - assertion on the same login would be redundant). - -3. Verify every required column in `mfa_*_verified`. If one is - missing → 400 `{ missing: [...] }`. -4. Transaction: - - DELETE the `mfa_pending` session. - - INSERT a full session, populate `reauth_password_at` / + policy ; finalisation does not gate on which path was taken. + Passkey-first stays TOTP-only (a second passkey assertion on the + same login would be redundant). + +2. If a required `mfa_*_verified` column is still missing, the + verify route returns `200 { finalized: false, missing: [...] }` + and the client drives the next factor. +3. If none are missing, the same route, in a transaction: + - DELETEs the `mfa_pending` session. + - INSERTs a full session, populating `reauth_password_at` / `reauth_passkey_at` according to what was done during the pending phase. - - Issue `__Host-nodea_session`. -5. Response `200 { user, ...some public info }`. + - Swaps the `nodea_session` cookie for the full session. + - Returns `200 { finalized: true }`. diff --git a/docs/auth/Recovery.md b/docs/auth/Recovery.md index 7458412a..8b06e96e 100644 --- a/docs/auth/Recovery.md +++ b/docs/auth/Recovery.md @@ -71,17 +71,20 @@ Hardening : ### `POST /auth/recover-kek/start` -Body: `{ email }`. Server: -1. Loads `users` by email. If not found → opaque response - `200 { ok: true, recovery_session_id: }` (no leak of - account existence; we still issue a session_id to keep timings - indistinguishable). -2. Stores `recovery_session_id` (32 random bytes, base64url) with a - 5 min TTL, bound to `users.id` if found, bound to `null` - otherwise. -3. Returns `{ recovery_session_id, wrapped_kek_recovery, - wrapped_kek_recovery_iv }` if a user was found, or - indistinguishable random blobs otherwise (timing safety). +Body: `{ email, registrationRequest }` — the new password's OPAQUE +`registrationRequest` is folded in here so the server can return the +`registrationResponse` in the same round-trip (no separate OPAQUE +register pair of routes). Server: +1. Loads `users` by email. Unknown emails get an indistinguishable + response (no leak of account existence) — a `recoverSessionId` + is still issued and the blobs are fresh random bytes. +2. Stores `recoverSessionId` (single-use, 5 min TTL), bound to + `users.id` when found. +3. Returns `{ recoverSessionId, wrappedKekRecovery, + wrappedKekRecoveryIv, userId, registrationResponse }`. For + unknown emails `userId` is a fresh random UUID (won't validate + any hash) and the wrap blobs are random — timing + body + indistinguishable from the known-email branch. ### Client side (before `/finish`) @@ -98,42 +101,51 @@ Body: `{ email }`. Server: 6. If unwrap succeeds: main key derived through the standard path (KEK → `wrapped_main_key` → main_key). 7. User types a new password. -8. Client runs OPAQUE registration (on the current email), derives - the new `export_key`, re-wraps the KEK under the new - `wk_password`. -9. Client generates a **new recovery code** (the old one will be - invalidated) → new `wrapped_kek_recovery` + new - `recovery_code_hash`. Displayed on screen after success, with an - acknowledgement checkbox. +8. Client runs OPAQUE registration (on the current email, using the + `registrationResponse` returned by `/start`), derives the new + `export_key`, re-wraps the KEK under the new `wk_password`, and + posts `registrationRecord` + `wrappedKekPassword{,Iv}` to + `/finish`. +9. The recovery code is **not** regenerated in this flow: `/finish` + nulls the old `wrapped_kek_recovery` + `recovery_code_hash` + server-side. After success the user is prompted to configure a + fresh recovery code at their leisure via + `POST /auth/security/recovery-code`. ### `POST /auth/recover-kek/finish` -Body: +Body — five camelCase fields: ```json { - "recovery_session_id": "...", - "recovery_code_hash": "...", - "opaque_register_record_new": "...", - "wrapped_kek_password_new": "...", - "wrapped_kek_password_new_iv": "...", - "wrapped_kek_recovery_new": "...", - "wrapped_kek_recovery_new_iv": "...", - "recovery_code_hash_new": "..." + "recoverSessionId": "...", + "recoveryCodeHash": "...", + "registrationRecord": "...", + "wrappedKekPassword": "...", + "wrappedKekPasswordIv": "..." } ``` +No new recovery blobs are supplied: the recovery code is **not +rotated in this flow**. The server **nulls** +`users.recovery_code_hash` + `users.wrapped_kek_recovery{,_iv}` on +success, the notification email tells the user the old code is now +invalid, and the "configure a recovery code" tip reappears (driven +by `recoveryCodeSet === false` on `/auth/me`). The user sets a new +code later via `POST /auth/security/recovery-code`. + Server: -1. Validates `recovery_session_id` (load, check TTL, consume). +1. Validates `recoverSessionId` (load, check TTL, consume). If bound to `null` → 401 ("non-existent user" path from `/start`). 2. Loads `users.recovery_code_hash`. **Constant-time comparison** - against the supplied `recovery_code_hash`. If mismatch → 401, - **no mutation**, logs an `auth.recover.hash_mismatch`. -3. Validates the new OPAQUE envelope (cryptographic consistency). + against the supplied `recoveryCodeHash`. If mismatch → 401, + **no mutation**, logs `[auth/recover-kek] hash_mismatch`. +3. Validates the new OPAQUE `registrationRecord` (cryptographic + consistency). 4. Transaction: - UPDATE `opaque_records.envelope` (by `user_id` PK). - - UPDATE `users.wrapped_kek_password{,_iv}`. - - UPDATE `users.wrapped_kek_recovery{,_iv}` ← new code. - - UPDATE `users.recovery_code_hash` ← new hash. + - UPDATE `users.wrapped_kek_password{,_iv}` ← from the body. + - NULL `users.wrapped_kek_recovery{,_iv}` (old code invalidated). + - NULL `users.recovery_code_hash`. - DELETE every session of this user. 5. Issues a full session + cookie. 6. Notification email "Your password was reset via recovery code. @@ -153,9 +165,14 @@ Distinct case from the recovery flow: the user is already authenticated (full session, KEK already in memory) and just wants to rotate the recovery code (lost paper, doubt, hygiene). -`POST /auth/security/recovery-code/regenerate` +`POST /auth/security/recovery-code` — **first-time setup and +regenerate share this one route** (no separate `.../regenerate`); +the server tells them apart by whether a hash already exists and +returns `{ ok, regenerated }` (`regenerated: true` when it replaced +an existing code). -Preconditions: `requireFreshPassword` (cf. matrix §6). +Re-auth: see Auth-Spec §6 (row: *Regenerate KEK recovery code*) — +gated by `requireFreshPassword`. Client side (before POST): 1. Generates a new BIP39 12-word recovery code. @@ -165,15 +182,16 @@ Client side (before POST): `wk_recovery_new = HKDF(..., "nodea:wrap-kek")`. 4. Wraps the current KEK (in memory): `wrapped_kek_recovery_new = AES-GCM(wk_recovery_new, kek, - AAD=buildAAD([users.id, "recovery"]))`. -5. Computes `recovery_code_hash_new = SHA-256(recovery_bytes_new)`. + AAD=buildKekAAD(users.id, "recovery"))` — + `nodea:v1\x1f\x1frecovery`. +5. Computes `recoveryCodeHash = SHA-256(recovery_bytes_new)`. -Body: +Body (`RecoveryCodeUpsertBodySchema`): ```json { - "wrapped_kek_recovery_new": "...", - "wrapped_kek_recovery_new_iv": "...", - "recovery_code_hash_new": "..." + "wrappedKekRecovery": "...", + "wrappedKekRecoveryIv": "...", + "recoveryCodeHash": "..." } ``` @@ -181,7 +199,7 @@ Server (transaction): 1. UPDATE `users.wrapped_kek_recovery{,_iv}`, `users.recovery_code_hash`. 2. Bump `users.updated_at`. -3. Response `200 { regenerated_at }`. +3. Response `200 { ok: true, regenerated }`. The old recovery code becomes invalid immediately (the `wrapped_kek_recovery` it could decrypt is no longer stored). The diff --git a/docs/auth/Register.md b/docs/auth/Register.md index 7c749307..1be56adc 100644 --- a/docs/auth/Register.md +++ b/docs/auth/Register.md @@ -110,9 +110,10 @@ wrap layers (cf. §3.2): 1. **Invited** (`inviteToken` present): - `consumeInviteAndCreateUser(token, email, …)`: - Lookup `invites` by `code_hash`, under `SELECT … FOR UPDATE`. - - Reject if used / expired / unknown → 401 `invalid_token`. + - Reject if used / expired / unknown → 401 `register_failed` + with `reason: 'invalid_token'`. - Reject if `invites.email !== body.email` (strict match) → - 400 `email_mismatch`. + 400 `register_failed` with `reason: 'email_mismatch'`. - INSERT `users { id: userId, username, wrappedMainKey, wrappedMainKeyIv, wrappedKekPassword, wrappedKekPasswordIv, @@ -178,11 +179,14 @@ Server: 1. `consumeEmailVerification('register', token)` — lookup + timing-safe compare + single-use consume. 2. UPDATE `users { emailVerifiedAt: now() }` WHERE id = verification.userId - AND emailVerifiedAt IS NULL. If no match → 401 `already_consumed`. + AND emailVerifiedAt IS NULL. If no match → 401 `activation_failed` + with `reason: 'already_consumed'`. 3. Response `200 { ok: true, email }`. -Specific errors: `invalid_token` (401), `already_consumed` (401), -`expired` (410). +Specific errors — all returned as `{ error: 'activation_failed', +reason }` (the `reason` sub-field discriminates, these are **not** +standalone top-level error codes): `reason: 'invalid_token'` (401), +`reason: 'already_consumed'` (401), `reason: 'expired'` (410). ### `GET /auth/register/mode` @@ -197,10 +201,11 @@ token is valid + unconsumed + unexpired; 404 otherwise. Lets the frontend pre-fill the email when the user arrives via `/register?invite=…`. -### Activation gate on `POST /auth/login` +### Activation gate on `POST /auth/login/finish` Once the account is created, login refuses if -`users.email_verified_at IS NULL`: +`users.email_verified_at IS NULL` (`account_not_activated` is a +**login** error, returned here — never on the register routes): ```ts if (user.emailVerifiedAt === null) { diff --git a/package.json b/package.json index 4cb3df63..5bb11a28 100644 --- a/package.json +++ b/package.json @@ -4,6 +4,10 @@ "version": "2.14.1", "license": "AGPL-3.0-or-later", "packageManager": "pnpm@10.33.0", + "engines": { + "node": ">=24", + "pnpm": ">=10" + }, "scripts": { "dev:web": "pnpm --filter @nodea/web dev", "dev:api": "pnpm --filter @nodea/api dev", @@ -13,6 +17,7 @@ "test": "pnpm -r --workspace-concurrency=1 test", "test:coverage": "pnpm -r --parallel exec vitest run --coverage", "typecheck": "pnpm -r typecheck", + "check:docs": "node scripts/check-docs.mjs", "changelog": "tsx scripts/generate-changelog.ts", "prepare": "husky" }, diff --git a/packages/api/src/db/schema/admin.ts b/packages/api/src/db/schema/admin.ts index b0bb9335..10d26b14 100644 --- a/packages/api/src/db/schema/admin.ts +++ b/packages/api/src/db/schema/admin.ts @@ -1,3 +1,13 @@ +/** + * Admin / instance tables (Drizzle DDL, re-exported by `db/schema.ts`): + * `invites` (email-bound, token stored hashed in `code_hash`), + * `app_settings` (key/value — e.g. `open_registration`), `announcements`. + * + * Where: api db layer. `invites.created_by`/`used_by`, + * `app_settings.updated_by` and `announcements.created_by` are FK ON DELETE + * SET NULL (an admin can be deleted without losing the rows). Per-table + * details are documented on each export below. + */ import { boolean, index, diff --git a/packages/api/src/db/schema/auth.ts b/packages/api/src/db/schema/auth.ts index 59e31a1c..bac856ac 100644 --- a/packages/api/src/db/schema/auth.ts +++ b/packages/api/src/db/schema/auth.ts @@ -1,3 +1,13 @@ +/** + * Auth / MFA tables (Drizzle DDL, split by domain, re-exported by + * `db/schema.ts`). Holds: `opaque_records`, `auth_factors`, `mfa_totp`, + * `mfa_totp_recovery_codes`, `mfa_bypass_requests`, `email_verifications`, + * `password_reset_tokens`. + * + * Where: api db layer. Every table FK-references `users` with ON DELETE + * CASCADE. Per-table specifics (PKs, AAD-bound blobs, the partial unique + * index on active bypass requests) are documented on each export below. + */ import { sql } from 'drizzle-orm'; import { bigint, diff --git a/packages/api/src/db/schema/users.ts b/packages/api/src/db/schema/users.ts index d1f4f81e..49d82caf 100644 --- a/packages/api/src/db/schema/users.ts +++ b/packages/api/src/db/schema/users.ts @@ -1,3 +1,13 @@ +/** + * Core identity tables (Drizzle DDL, re-exported by `db/schema.ts`): + * `users` (owners of encrypted data + the KEK wrap blobs) and `sessions` + * (server-side session rows; the cookie carries only the signed id, so a + * deleted row revokes access immediately). + * + * Where: api db layer. `sessions.user_id` is FK ON DELETE CASCADE; the + * `users` wrap blobs and the `sessions` device label are AAD-bound encrypted + * columns. Per-table details are documented on each export below. + */ import { boolean, index, diff --git a/packages/api/src/routes/admin-announcements.ts b/packages/api/src/routes/admin-announcements.ts index 7fdf22fa..9a1aec89 100644 --- a/packages/api/src/routes/admin-announcements.ts +++ b/packages/api/src/routes/admin-announcements.ts @@ -1,3 +1,10 @@ +/** + * Admin announcement CRUD: `GET`/`POST /admin/announcements`, + * `PATCH`/`DELETE /admin/announcements/{id}`. + * + * Where: api admin route layer (mounted at `/admin`, behind requireAdmin). + * Authors the banners users read via the public `/announcements` feed. + */ import { desc, eq } from 'drizzle-orm'; import { randomUUID } from 'node:crypto'; import { diff --git a/packages/api/src/routes/admin-invites.ts b/packages/api/src/routes/admin-invites.ts index b726010b..6c47a486 100644 --- a/packages/api/src/routes/admin-invites.ts +++ b/packages/api/src/routes/admin-invites.ts @@ -1,3 +1,15 @@ +/** + * Admin invite management: `POST /admin/invites`, + * `POST /admin/invites/{id}/resend`, `GET /admin/invites`, + * `DELETE /admin/invites/{id}`. + * + * Where: api admin route layer (mounted at `/admin`, behind requireAdmin). + * + * Non-obvious: invite tokens are stored hashed (SHA-256 in `code_hash`); + * the raw token exists only in the emailed link. Creation delegates to + * `auth/invites.ts`. Validation/consumption lives in `/auth/register`, + * never a standalone check endpoint. + */ import { and, desc, eq, isNull } from 'drizzle-orm'; import { CreateInviteBodySchema } from '@nodea/shared'; import { createInvite } from '../auth/invites.ts'; diff --git a/packages/api/src/routes/admin-settings.ts b/packages/api/src/routes/admin-settings.ts index 0965b9c4..35522631 100644 --- a/packages/api/src/routes/admin-settings.ts +++ b/packages/api/src/routes/admin-settings.ts @@ -1,3 +1,12 @@ +/** + * Admin instance settings: `GET`/`PATCH /admin/settings`. + * + * Where: api admin route layer (mounted at `/admin`, behind requireAdmin). + * + * Non-obvious: this is where `open_registration` lives — a DB-backed + * `app_settings` row (via `services/settings.ts`), NOT an env var. + * Toggling it here opens/closes self-service signup. + */ import { AdminSettingsPatchBodySchema } from '@nodea/shared'; import { requireUser, requireAdmin } from '../middleware/require-user.ts'; import { diff --git a/packages/api/src/routes/admin-sources.ts b/packages/api/src/routes/admin-sources.ts index 7696623d..c3f93f82 100644 --- a/packages/api/src/routes/admin-sources.ts +++ b/packages/api/src/routes/admin-sources.ts @@ -1,3 +1,10 @@ +/** + * Admin diagnostics: `GET /admin/sources` — live health probe of the + * external library-metadata providers. + * + * Where: api admin route layer (mounted at `/admin`, behind requireAdmin); + * delegates to `services/library-lookup/dispatcher.ts`. + */ import { AdminSourcesResponseSchema } from '@nodea/shared'; import type { AdminSourcesResponse } from '@nodea/shared'; import { requireUser, requireAdmin } from '../middleware/require-user.ts'; diff --git a/packages/api/src/routes/admin-users.ts b/packages/api/src/routes/admin-users.ts index 7d9ef575..8ae31b98 100644 --- a/packages/api/src/routes/admin-users.ts +++ b/packages/api/src/routes/admin-users.ts @@ -1,3 +1,12 @@ +/** + * Admin user management: `GET /admin/users`, `DELETE /admin/users/{id}`. + * + * Where: api admin route layer (mounted at `/admin`, behind requireAdmin). + * + * Non-obvious: deleting a user cascades to their auth rows + 1:1 tables + * (sessions, modules_config, …) via FK ON DELETE CASCADE; entry rows carry + * no user FK and are purged separately. The admin never sees plaintext. + */ import { asc, eq } from 'drizzle-orm'; import { db } from '../db/client.ts'; import { users } from '../db/schema.ts'; diff --git a/packages/api/src/routes/announcements.ts b/packages/api/src/routes/announcements.ts index 6396b47d..8bdcb2d3 100644 --- a/packages/api/src/routes/announcements.ts +++ b/packages/api/src/routes/announcements.ts @@ -1,3 +1,14 @@ +/** + * Public announcement feed: `GET /announcements` — active banners for the + * signed-in user. + * + * Where: api route layer, mounted at `/announcements` (requireUser, read- + * only; authoring is admin-only via `/admin/announcements`). + * + * Non-obvious: "active" is a time-window filter (publish/expiry bounds) + * computed in the query; serialization is shared via + * `announcements-serialize.ts`. + */ import { and, desc, eq, isNull, or, lte, gte } from 'drizzle-orm'; import { AnnouncementListResponseSchema } from '@nodea/shared'; import { db } from '../db/client.ts'; diff --git a/packages/api/src/routes/auth-account.ts b/packages/api/src/routes/auth-account.ts index ac13a004..85264ea1 100644 --- a/packages/api/src/routes/auth-account.ts +++ b/packages/api/src/routes/auth-account.ts @@ -1,3 +1,16 @@ +/** + * Account routes: `GET /auth/me` + `/me/crypto`, `PATCH /auth/email` + + * `/username`, `POST /auth/onboarding/complete`, `DELETE /auth/me`. + * + * Where: api auth route layer (mounted at `/auth`). + * + * Non-obvious: `/me` never leaks wrap blobs (those come from the separate + * `/me/crypto`); responses never include `guard` or another user's + * encrypted key. Sensitive mutations sit behind `requireFreshPassword`; + * `PATCH /auth/email` is rate-limited 1/24h (`rl:change-email`, + * keyed-user). `DELETE /auth/me` takes an empty body — the UI gates it + * (email retype + fresh password + confirm dialog). + */ import { and, count, eq, ne, sql } from 'drizzle-orm'; import { AuthMeCryptoResponseSchema, diff --git a/packages/api/src/routes/auth-change-password.ts b/packages/api/src/routes/auth-change-password.ts index 4866eec6..42404771 100644 --- a/packages/api/src/routes/auth-change-password.ts +++ b/packages/api/src/routes/auth-change-password.ts @@ -1,3 +1,15 @@ +/** + * Change-password routes: `POST /auth/change-password/start` + `/finish`. + * + * Where: api auth route layer (mounted at `/auth`), behind requireUser + + * `requireFreshPasswordOrPasskey`. + * + * Non-obvious: rotates the OPAQUE record and rewraps only + * `wrapped_kek_password` — NO existing ciphertext is touched (the main key + * is unchanged), so data survives. The `/start` body carries just + * `{ registrationRequest }`; re-auth is proven out-of-band via the fresh- + * reauth gate, not embedded in the body. Bucket `change-password` 5/1h. + */ import { eq } from 'drizzle-orm'; import { ChangePasswordFinishBodySchema, diff --git a/packages/api/src/routes/auth-login.ts b/packages/api/src/routes/auth-login.ts index b61f0b7d..f37aa399 100644 --- a/packages/api/src/routes/auth-login.ts +++ b/packages/api/src/routes/auth-login.ts @@ -1,3 +1,17 @@ +/** + * OPAQUE login routes: `POST /auth/login/start` + `/login/finish`, and + * `POST /auth/logout`. + * + * Where: api auth route layer (mounted at `/auth`). Wraps the OPAQUE + * handshake (`auth/opaque.ts`) and MFA policy (`auth/mfa-policy.ts`). + * + * Non-obvious: `/finish` mints either a `full` session or, when the + * security mode requires a second factor, an `mfa_pending` one + * (`{ needsMfa: true }`). Wrap blobs are NOT inlined on the full path — + * the client fetches them from `GET /auth/me/crypto`. Unknown emails get + * a constant-shape dead response (anti-enumeration). Rate-limited via + * the shared `login` bucket (10/min/IP). + */ import { eq } from 'drizzle-orm'; import { OpaqueLoginFinishBodySchema, diff --git a/packages/api/src/routes/auth-mfa-bypass.ts b/packages/api/src/routes/auth-mfa-bypass.ts index 9bab8bc4..ba867e00 100644 --- a/packages/api/src/routes/auth-mfa-bypass.ts +++ b/packages/api/src/routes/auth-mfa-bypass.ts @@ -1,3 +1,17 @@ +/** + * MFA-bypass (lost-factor recovery) routes: `POST /auth/mfa/bypass/request` + * and `GET /auth/mfa/bypass/confirm`. + * + * Where: api auth route layer (mounted at `/auth`), for a user locked out + * of their second factor. + * + * Non-obvious: `request` always returns 200 whether or not a bypass row is + * created (anti-enumeration). Confirming the emailed magic-link starts a + * 7-day delay during which any successful login cancels the pending + * bypass; after the delay the next login consumes it (lost factor purged, + * mode downgraded). Buckets: `mfa-bypass-request` 3/1h, `mfa-bypass-link` + * 20/1h. + */ import { and, eq, isNull } from 'drizzle-orm'; import { randomUUID } from 'node:crypto'; import { diff --git a/packages/api/src/routes/auth-mfa.ts b/packages/api/src/routes/auth-mfa.ts index f7cf1da3..bf010c19 100644 --- a/packages/api/src/routes/auth-mfa.ts +++ b/packages/api/src/routes/auth-mfa.ts @@ -1,3 +1,18 @@ +/** + * Stepped-MFA verification routes: `POST /auth/mfa/totp/verify`, + * `POST /auth/mfa/passkey/start` + `/finish`. + * + * Where: api auth route layer (mounted at `/auth`), reached only with an + * `mfa_pending` session (requireMfaPending) between primary auth and the + * second factor. + * + * Non-obvious: the last successful factor finalizes the session INLINE + * (there is no `/auth/mfa/finalize` route) — it returns + * `{ finalized: true }` (or `{ finalized: false, missing }`) and swaps + * the cookie to `full`. TOTP `verify` also accepts a backup code in the + * same `code` field (format-disambiguated). UV failure collapses to 401 + * `invalid_credentials`. Keyed-session rate limit (10/5min). + */ import { and, eq, isNull } from 'drizzle-orm'; import { MfaPasskeyFinishBodySchema, diff --git a/packages/api/src/routes/auth-passkey-enroll.ts b/packages/api/src/routes/auth-passkey-enroll.ts index 2e56ce02..f6c822df 100644 --- a/packages/api/src/routes/auth-passkey-enroll.ts +++ b/packages/api/src/routes/auth-passkey-enroll.ts @@ -1,3 +1,14 @@ +/** + * Passkey enrollment routes: `POST /auth/passkeys/enroll/start` + `/finish`. + * + * Where: api auth route layer (combined into `auth-passkey.ts`, mounted at + * `/auth`), behind requireUser. + * + * Non-obvious: enroll requires a PRF-capable, user-verifying authenticator + * — `uv !== true` is rejected with `user_verification_required`/400 (only + * enroll surfaces this distinct code). The new KEK wrap is AAD-bound to the + * credential id. Bucket `passkey-enroll` (10/15min). + */ import { eq } from 'drizzle-orm'; import { randomUUID } from 'node:crypto'; import { diff --git a/packages/api/src/routes/auth-passkey-login.ts b/packages/api/src/routes/auth-passkey-login.ts index 33599c6c..51244d9b 100644 --- a/packages/api/src/routes/auth-passkey-login.ts +++ b/packages/api/src/routes/auth-passkey-login.ts @@ -1,3 +1,15 @@ +/** + * Passkey-first login routes: `POST /auth/passkeys/login/start` + `/finish`. + * + * Where: api auth route layer (combined into `auth-passkey.ts`, mounted at + * `/auth`), pre-auth. + * + * Non-obvious: the server issues a session on a successful assertion; the + * NON-PRF block is client-side (the client tears the session down when the + * authenticator lacks PRF — there is no server-side prf gate). UV failure + * collapses to 401 `invalid_credentials` (anti-enumeration). Uses the + * `passkey-login` bucket (20/15min). + */ import { eq } from 'drizzle-orm'; import { generateAuthenticationOptions, diff --git a/packages/api/src/routes/auth-passkey-manage.ts b/packages/api/src/routes/auth-passkey-manage.ts index 64eb288e..241e4fe3 100644 --- a/packages/api/src/routes/auth-passkey-manage.ts +++ b/packages/api/src/routes/auth-passkey-manage.ts @@ -1,3 +1,14 @@ +/** + * Passkey management routes: `GET /auth/passkeys/list`, + * `PATCH /auth/passkeys/{id}/label` (rename), `POST /auth/passkeys/{id}/remove`. + * + * Where: api auth route layer (combined into `auth-passkey.ts`, mounted at + * `/auth`), behind requireUser. + * + * Non-obvious: removing the last PRF passkey is constrained by the active + * security mode (can't drop below the mode's factor requirement). Bucket + * `passkey-manage` (30/15min). + */ import { and, eq } from 'drizzle-orm'; import { PasskeyDeleteBodySchema, diff --git a/packages/api/src/routes/auth-reauth.ts b/packages/api/src/routes/auth-reauth.ts index bf6d18f9..faee48ad 100644 --- a/packages/api/src/routes/auth-reauth.ts +++ b/packages/api/src/routes/auth-reauth.ts @@ -1,3 +1,15 @@ +/** + * Step-up re-auth routes: `POST /auth/reauth/password/start` + `/finish`, + * `POST /auth/reauth/passkey/start` + `/finish`. + * + * Where: api auth route layer (mounted at `/auth`), behind requireUser. + * + * Non-obvious: re-auth is a real OPAQUE / WebAuthn round-trip (hence the + * `/start`+`/finish` pair), not a single call. On success it stamps + * `sessions.reauth_password_at` / `reauth_passkey_at`, which + * `requireFreshPassword(OrPasskey)` reads to gate sensitive mutations + * within a 5-min window. Shared `reauth` rate-limit (10/15min). + */ import { eq } from 'drizzle-orm'; import { generateAuthenticationOptions, diff --git a/packages/api/src/routes/auth-recovery.ts b/packages/api/src/routes/auth-recovery.ts index 3fbe5af5..cfd7884e 100644 --- a/packages/api/src/routes/auth-recovery.ts +++ b/packages/api/src/routes/auth-recovery.ts @@ -1,3 +1,17 @@ +/** + * KEK recovery routes: `POST /auth/recover-kek/start` · `/verify` · + * `/finish`, and `POST /auth/security/recovery-code` (setup + regenerate). + * + * Where: api auth route layer (mounted at `/auth`). + * + * Non-obvious: recovery re-registers the OPAQUE record under a new + * password and rewraps the KEK from the recovery code — data is preserved + * (unlike a destructive reset). `/finish` nulls the old recovery wrap + * blobs server-side (they aren't re-supplied). Hash mismatch logs + * `[auth/recover-kek] hash_mismatch` and returns 401 `invalid_credentials`. + * Buckets: `recover-kek` 5/1h, `recover-kek-verify` 3/1h, + * `recovery-code-setup` 5/1h. + */ import { eq, isNotNull } from 'drizzle-orm'; import { createHmac, randomBytes, timingSafeEqual } from 'node:crypto'; import { diff --git a/packages/api/src/routes/auth-register-v2.ts b/packages/api/src/routes/auth-register-v2.ts index bbe0d778..abfe2ccd 100644 --- a/packages/api/src/routes/auth-register-v2.ts +++ b/packages/api/src/routes/auth-register-v2.ts @@ -1,3 +1,16 @@ +/** + * OPAQUE registration routes: `GET /auth/register/invite-info`, + * `POST /auth/register/start` · `/finish` · `/activate`. + * + * Where: api auth route layer, mounted at `/auth/register`. + * + * Invariants: invite consumption is atomic — the `/finish` transaction + * does SELECT … FOR UPDATE on the invite, creates the user + opaque + * record, and deletes the invite together, so a code can never mint two + * accounts. Email is bound to the invite (form email must match). Errors + * surface as `{ error: 'register_failed'|'activation_failed', reason }`. + * Each step has its own rate-limit bucket (see tech.md rate-limit catalog). + */ import { and, eq, isNull } from 'drizzle-orm'; import { randomUUID } from 'node:crypto'; import { diff --git a/packages/api/src/routes/auth-reset.ts b/packages/api/src/routes/auth-reset.ts index 9149f7f8..de93f619 100644 --- a/packages/api/src/routes/auth-reset.ts +++ b/packages/api/src/routes/auth-reset.ts @@ -1,3 +1,15 @@ +/** + * Password-reset routes: `POST /auth/request-reset`, `POST /auth/reset/start` + * + `/reset/finish`. + * + * Where: api auth route layer (mounted at `/auth`), pre-auth (token-gated). + * + * Non-obvious: a forgotten-password reset DESTROYS the old main key, so + * `/reset/finish` is a full OPAQUE re-register that ships a fresh + * `wrappedMainKey` — all prior encrypted data becomes unreadable (the + * destructive door, distinct from recover-kek). `request-reset` always + * 200s (anti-enumeration). Buckets: `request-reset` 5/1h, `reset` 10/1min. + */ import { and, eq, gt, isNull } from 'drizzle-orm'; import { RequestResetBodySchema, diff --git a/packages/api/src/routes/auth-security-mode.ts b/packages/api/src/routes/auth-security-mode.ts index 8e4e8f18..8f794247 100644 --- a/packages/api/src/routes/auth-security-mode.ts +++ b/packages/api/src/routes/auth-security-mode.ts @@ -1,3 +1,14 @@ +/** + * `POST /auth/security-mode/change` — switch the per-user security mode + * (`password_or_passkey` / `always_2fa` / `maximum`). + * + * Where: api auth route layer (mounted at `/auth`), behind requireUser + + * fresh re-auth. + * + * Non-obvious: upgrades enforce the activation invariant (Auth-Spec §6.1) + * — e.g. `maximum` requires TOTP enabled AND a PRF-capable passkey before + * it can be selected. Rate-limited `security-mode-change` (10/15min). + */ import { and, eq } from 'drizzle-orm'; import { SecurityModeChangeBodySchema, diff --git a/packages/api/src/routes/auth-sessions.ts b/packages/api/src/routes/auth-sessions.ts index ba5dc598..2a6abc32 100644 --- a/packages/api/src/routes/auth-sessions.ts +++ b/packages/api/src/routes/auth-sessions.ts @@ -1,3 +1,14 @@ +/** + * Active-session management: `GET /auth/sessions`, `DELETE /auth/sessions/:id`, + * `POST /auth/logout-all`, `PATCH /auth/sessions/current/device-label`. + * + * Where: api auth route layer (mounted at `/auth`), behind requireUser. + * + * Non-obvious: the device label shown in the UI is an encrypted blob the + * client PATCHes (AAD-bound to the user) — the server never reads the raw + * user-agent (`ip_hash`/`user_agent` are deprecated). `logout-all` revokes + * every session row, killing them immediately. + */ import { and, desc, eq, gt } from 'drizzle-orm'; import { z } from 'zod'; import { diff --git a/packages/api/src/routes/auth-shared.ts b/packages/api/src/routes/auth-shared.ts index 2baed207..761c061d 100644 --- a/packages/api/src/routes/auth-shared.ts +++ b/packages/api/src/routes/auth-shared.ts @@ -1,3 +1,11 @@ +/** + * Shared auth-route helpers: the pre-auth rate limiters (`login`, + * `request-reset`, `reset`) and a Postgres unique-violation matcher. + * + * Where: api auth route layer — factored here so the login / reset / + * register route files share one limiter definition and one duplicate-key + * detector (matched by SQLSTATE 23505 + constraint name, not the message). + */ import { rateLimit } from '../middleware/rate-limit.ts'; /** diff --git a/packages/api/src/routes/auth-totp.ts b/packages/api/src/routes/auth-totp.ts index e8144c1a..55ee1e6b 100644 --- a/packages/api/src/routes/auth-totp.ts +++ b/packages/api/src/routes/auth-totp.ts @@ -1,3 +1,15 @@ +/** + * TOTP management routes: `POST /auth/totp/enroll/start` + `/enroll/verify`, + * `/totp/disable`, `/totp/backup-codes/regenerate`. + * + * Where: api auth route layer (mounted at `/auth`), behind requireUser + * (+ fresh re-auth on sensitive ops). + * + * Non-obvious: enroll surfaces a distinct `user_verification_required` + * error (login/MFA do not); the secret + backup codes are shown once. + * Wraps `otplib` via `auth/totp.ts`. Buckets: `totp-enroll` 10/15min, + * `totp-manage` 30/15min. + */ import { and, eq } from 'drizzle-orm'; import { randomUUID } from 'node:crypto'; import { diff --git a/packages/api/src/routes/library-lookup.ts b/packages/api/src/routes/library-lookup.ts index 2ba20f83..f20eca06 100644 --- a/packages/api/src/routes/library-lookup.ts +++ b/packages/api/src/routes/library-lookup.ts @@ -1,3 +1,16 @@ +/** + * External book-metadata proxy: `POST /library/lookup/by-isbn`, + * `POST /library/lookup/by-query/stream`, `GET /library/lookup/cover-fetch`. + * + * Where: api route layer, mounted at `/library/lookup`; dispatches to the + * providers in `services/library-lookup/` (ADR-0009). + * + * Non-obvious: the one route family that calls third-party APIs (the public + * egress surface). Results are cached in-memory + * (`services/library-lookup/cache.ts`) and the cover-fetch proxy caps the + * raw response at 5 MB. No user crypto material involved. Buckets: + * `library-lookup-isbn`/`-query` 30/min, `-cover` 60/min. + */ import { stream } from 'hono/streaming'; import { LibraryLookupByIsbnBodySchema, diff --git a/packages/api/src/routes/modules-config.ts b/packages/api/src/routes/modules-config.ts index 493022e3..fc4fbe3f 100644 --- a/packages/api/src/routes/modules-config.ts +++ b/packages/api/src/routes/modules-config.ts @@ -1,3 +1,15 @@ +/** + * `/modules-config` — read/write the per-user encrypted module config + * (which modules are active + their settings). + * + * Where: api route layer, mounted at `/modules-config`. + * + * GUARD-EXEMPT by design: the table PK is `user_id` (1:1), so + * `requireUser` + `user_id` scoping fully authorizes access — there is no + * independent row `id` to authenticate, hence no `requireGuard` + * (CLAUDE.md mandates documenting this exemption in the route). Writes + * are rate-limited (`modules-config-put`, 30/min). + */ import { eq } from 'drizzle-orm'; import { ModulesConfigBodySchema } from '@nodea/shared/schemas/entries'; import { db } from '../db/client.ts'; diff --git a/packages/api/src/routes/passkey-helpers.ts b/packages/api/src/routes/passkey-helpers.ts index 0bbe982a..9a0d2f32 100644 --- a/packages/api/src/routes/passkey-helpers.ts +++ b/packages/api/src/routes/passkey-helpers.ts @@ -1,3 +1,12 @@ +/** + * Shared passkey route helpers: the `prf`-widened WebAuthn extension type + * and the enroll/login/manage rate limiters. + * + * Where: api auth route layer — imported by the three `auth-passkey-*` + * route files so the limiter buckets (`passkey-enroll` 10/15min, + * `passkey-login` 20/15min, `passkey-manage` 30/15min) have a single + * definition site. + */ import type { AuthenticatorTransportFuture } from '@simplewebauthn/server'; import { rateLimit } from '../middleware/rate-limit.ts'; diff --git a/packages/api/src/routes/records.ts b/packages/api/src/routes/records.ts index da67c417..cbd42d1a 100644 --- a/packages/api/src/routes/records.ts +++ b/packages/api/src/routes/records.ts @@ -1,3 +1,17 @@ +/** + * Unified `/records` CRUD endpoint for every encrypted module table. + * + * Where: api route layer, mounted at `/` (app.ts) and built from the + * single `COLLECTIONS` array — one factory, no per-module route files, + * so a new collection can't forget its guard. The target table travels + * in the `X-Collection` header (issue #67), never the URL. + * + * Invariants: every mutation runs requireUser + requireCollection + + * requireGuard; the HMAC `guard` is verified per write and never + * serialized back (`toView` omits it); creation sends `guard:"init"` + * then promotes to the real `g_` in a second PATCH. Rows carry no + * user FK — access is scoped by `module_user_id` + `guard` only. + */ import { randomUUID } from 'node:crypto'; import { and, eq, inArray, sql } from 'drizzle-orm'; import { diff --git a/packages/api/src/routes/user-preferences.ts b/packages/api/src/routes/user-preferences.ts index ae7a8797..a987758a 100644 --- a/packages/api/src/routes/user-preferences.ts +++ b/packages/api/src/routes/user-preferences.ts @@ -1,3 +1,15 @@ +/** + * `/user-preferences` — read/write the per-user encrypted preferences + * blob (theme, language, cross-device personalisation). + * + * Where: api route layer, mounted at `/user-preferences`. + * + * GUARD-EXEMPT, same rationale as modules-config: PK on `user_id` (1:1), + * the user IS the row, so `requireUser` + scoping suffices — no + * `requireGuard`. Kept a separate table/route from modules-config so an + * auditing admin can't read one while touching the other. Writes + * rate-limited (`user-preferences-put`, 60/min). + */ import { eq } from 'drizzle-orm'; import { UserPreferencesBodySchema } from '@nodea/shared/schemas/preferences'; import { db } from '../db/client.ts'; diff --git a/packages/api/src/test/auth-register-v2.test.ts b/packages/api/src/test/auth-register-v2.test.ts index c0823de7..4b051502 100644 --- a/packages/api/src/test/auth-register-v2.test.ts +++ b/packages/api/src/test/auth-register-v2.test.ts @@ -405,8 +405,8 @@ describe('POST /auth/register/finish — invited path', () => { }); /** - * Closes Finding 2 of `docs/security-audit.md` — invite atomicity - * under concurrent consumption. + * Invite atomicity under concurrent consumption (audit finding — + * the same invite code must never create two accounts). * * The implementation in `auth/invites.ts` wraps the consumption in * a `db.transaction(...)` with `SELECT … FOR UPDATE` so that two diff --git a/packages/shared/src/error-codes.ts b/packages/shared/src/error-codes.ts index c5078f29..3d76f44a 100644 --- a/packages/shared/src/error-codes.ts +++ b/packages/shared/src/error-codes.ts @@ -15,8 +15,6 @@ * The list is generated by grepping `{ error: 'xxx' }` in * `packages/api/src/routes/`. Keep it sorted alphabetically and * add a comment block when intent is non-obvious. - * - * Reference: `docs/roadmap/health.md` Tier B.4. */ export const KNOWN_API_ERROR_CODES = [ diff --git a/packages/shared/src/module-ids.ts b/packages/shared/src/module-ids.ts index 60736930..51b64e47 100644 --- a/packages/shared/src/module-ids.ts +++ b/packages/shared/src/module-ids.ts @@ -13,9 +13,7 @@ * `settings` (alias to `account`) is intentionally absent — it was * killed alongside the URL-routing rework. `home` is the cold-start * default ; `account` and `admin` are reachable but hidden from the - * public module list (`display: false` in `modules_list.tsx`). - * - * Reference: `docs/roadmap/health.md` Tier B.5. + * public module list (`display: false` in `modules-registry.tsx`). */ export const MODULE_IDS = [ diff --git a/packages/shared/src/schemas/admin-sources.ts b/packages/shared/src/schemas/admin-sources.ts index c6181b67..3d5405de 100644 --- a/packages/shared/src/schemas/admin-sources.ts +++ b/packages/shared/src/schemas/admin-sources.ts @@ -1,3 +1,9 @@ +/** + * Zod DTO for the admin library-provider health-probe response. + * + * Where: packages/shared — shared by the api `GET /admin/sources` route and + * the web admin diagnostics panel. + */ import { z } from 'zod'; /** diff --git a/packages/shared/src/schemas/announcements.ts b/packages/shared/src/schemas/announcements.ts index 83cd6ba0..3f7de641 100644 --- a/packages/shared/src/schemas/announcements.ts +++ b/packages/shared/src/schemas/announcements.ts @@ -1,3 +1,9 @@ +/** + * Zod DTOs for admin announcements (create/update bodies + list response). + * + * Where: packages/shared — shared by the api `/admin/announcements` (write) + * and `/announcements` (public read) routes and the web admin + banner UI. + */ import { z } from 'zod'; /** diff --git a/packages/shared/src/schemas/auth-mfa-bypass.ts b/packages/shared/src/schemas/auth-mfa-bypass.ts index fbefb80b..cf795cd9 100644 --- a/packages/shared/src/schemas/auth-mfa-bypass.ts +++ b/packages/shared/src/schemas/auth-mfa-bypass.ts @@ -1,3 +1,10 @@ +/** + * Zod DTOs for the lost-factor MFA-bypass flow (request + confirm). + * + * Where: packages/shared — shared by the api `/auth/mfa/bypass/*` routes + * and the web bypass flow. Responses are intentionally uniform whether or + * not a bypass exists (anti-enumeration). + */ import { z } from 'zod'; /** diff --git a/packages/shared/src/schemas/auth-mfa.ts b/packages/shared/src/schemas/auth-mfa.ts index 2eefb7b1..2218a1cf 100644 --- a/packages/shared/src/schemas/auth-mfa.ts +++ b/packages/shared/src/schemas/auth-mfa.ts @@ -1,3 +1,10 @@ +/** + * Zod DTOs for stepped-MFA verification (TOTP + passkey second factor). + * + * Where: packages/shared — shared by the api `/auth/mfa/*` routes and the + * web MFA flow. The TOTP `code` field accepts either a 6-digit code or a + * backup code (format-disambiguated server-side). + */ import { z } from 'zod'; /** diff --git a/packages/shared/src/schemas/auth-opaque.ts b/packages/shared/src/schemas/auth-opaque.ts index 793b0bae..455b567d 100644 --- a/packages/shared/src/schemas/auth-opaque.ts +++ b/packages/shared/src/schemas/auth-opaque.ts @@ -1,3 +1,11 @@ +/** + * Zod DTOs for the OPAQUE handshake (register + login start/finish) and + * the wrapped-key blobs returned after auth. + * + * Where: packages/shared — single source of truth for both the api auth + * routes and the web session client. OPAQUE wire blobs are base64url with + * a loose size ceiling (DoS guard, not correctness). + */ import { z } from 'zod'; import { UsernameField } from './auth.ts'; diff --git a/packages/shared/src/schemas/auth-passkey.ts b/packages/shared/src/schemas/auth-passkey.ts index 80ebedbd..1e39101a 100644 --- a/packages/shared/src/schemas/auth-passkey.ts +++ b/packages/shared/src/schemas/auth-passkey.ts @@ -1,3 +1,11 @@ +/** + * Zod DTOs for WebAuthn / passkey flows: enroll, login, list, rename, + * remove, plus the PRF-derived KEK-wrap payloads. + * + * Where: packages/shared — shared by the api `auth-passkey-*` routes and + * the web passkey client. Credential ids are base64url; the wrap blob is + * AAD-bound to the credential id server-side. + */ import { z } from 'zod'; /** diff --git a/packages/shared/src/schemas/auth-reauth.ts b/packages/shared/src/schemas/auth-reauth.ts index 8996e156..7bf7c3e6 100644 --- a/packages/shared/src/schemas/auth-reauth.ts +++ b/packages/shared/src/schemas/auth-reauth.ts @@ -1,3 +1,10 @@ +/** + * Zod DTOs for step-up re-auth (password + passkey, start/finish). + * + * Where: packages/shared — shared by the api `/auth/reauth/*` routes and + * the web re-auth helper. The proof is a fresh OPAQUE / WebAuthn round- + * trip, not an embedded password. + */ import { z } from 'zod'; /** diff --git a/packages/shared/src/schemas/auth-recovery.ts b/packages/shared/src/schemas/auth-recovery.ts index dca48bc9..814f4fa6 100644 --- a/packages/shared/src/schemas/auth-recovery.ts +++ b/packages/shared/src/schemas/auth-recovery.ts @@ -1,3 +1,11 @@ +/** + * Zod DTOs for KEK recovery: recover-kek start/verify/finish and + * recovery-code setup/regenerate. + * + * Where: packages/shared — shared by the api recovery routes and the web + * recovery flow. `finish` carries the rewrapped password KEK; the old + * recovery blobs are nulled server-side, not re-supplied. + */ import { z } from 'zod'; /** diff --git a/packages/shared/src/schemas/auth-register-v2.ts b/packages/shared/src/schemas/auth-register-v2.ts index cea7a25b..bb39f221 100644 --- a/packages/shared/src/schemas/auth-register-v2.ts +++ b/packages/shared/src/schemas/auth-register-v2.ts @@ -1,3 +1,9 @@ +/** + * Zod DTOs for OPAQUE registration (invite-info, start, finish, activate). + * + * Where: packages/shared — shared by the api `/auth/register/*` routes and + * the web register flow. Email is invite-bound; activation gates first login. + */ import { z } from 'zod'; /** diff --git a/packages/shared/src/schemas/auth-totp.ts b/packages/shared/src/schemas/auth-totp.ts index f2298b61..c0021e76 100644 --- a/packages/shared/src/schemas/auth-totp.ts +++ b/packages/shared/src/schemas/auth-totp.ts @@ -1,3 +1,10 @@ +/** + * Zod DTOs for TOTP enrollment + management (enroll start/verify, disable, + * backup-code regenerate). + * + * Where: packages/shared — shared by the api `/auth/totp/*` routes and the + * web TOTP settings flow. + */ import { z } from 'zod'; /** diff --git a/packages/shared/src/schemas/auth.ts b/packages/shared/src/schemas/auth.ts index e97b1eba..6de13e2f 100644 --- a/packages/shared/src/schemas/auth.ts +++ b/packages/shared/src/schemas/auth.ts @@ -1,3 +1,12 @@ +/** + * Core auth Zod DTOs: the OPAQUE re-auth proof, change-password/email, + * self-delete, and the shared field primitives (`UsernameField`, base64ish + * blobs) reused across the other auth-* schema files. + * + * Where: packages/shared — the keystone auth schema, single source of truth + * for both the api auth routes and the web session client. camelCase wire + * (ADR-0012); Update DTOs derive from Create via `.partial()`. + */ import { z } from 'zod'; const Base64ish = z.string().min(1); diff --git a/packages/shared/src/schemas/entries.ts b/packages/shared/src/schemas/entries.ts index f9b450f3..cb1416db 100644 --- a/packages/shared/src/schemas/entries.ts +++ b/packages/shared/src/schemas/entries.ts @@ -1,3 +1,13 @@ +/** + * Zod DTOs for the encrypted-record API: create/update/bulk/wipe bodies, + * the `EntryView` response (which OMITS `guard`), `COLLECTION_NAMES`, and + * the `INIT_GUARD` sentinel. + * + * Where: packages/shared — single source of truth for the `/records` route + * and the web collection client. `COLLECTION_NAMES` is the canonical list + * of guarded collections; the body-size caps below bound the encrypted + * envelope on the wire. + */ import { z } from 'zod'; /** diff --git a/packages/shared/src/schemas/envelope.ts b/packages/shared/src/schemas/envelope.ts index 47bc261b..3383e2f8 100644 --- a/packages/shared/src/schemas/envelope.ts +++ b/packages/shared/src/schemas/envelope.ts @@ -1,3 +1,12 @@ +/** + * Zod schema for the encrypted-record envelope shared by every module + * (front + back single source of truth). + * + * Where: packages/shared — imported by the api records route and the web + * collection client. Mirrors the `*_entries` columns the server sees: + * `cipherIv` + `payload` (the AES-GCM blob). camelCase on the wire + * (ADR-0012). + */ import { z } from 'zod'; /** diff --git a/packages/shared/src/schemas/library-lookup.ts b/packages/shared/src/schemas/library-lookup.ts index 255c01bd..1e7257e9 100644 --- a/packages/shared/src/schemas/library-lookup.ts +++ b/packages/shared/src/schemas/library-lookup.ts @@ -1,3 +1,11 @@ +/** + * Zod DTOs for external book-metadata lookup (by-isbn, by-query) and the + * normalised result shape (`NormalisedBook`). + * + * Where: packages/shared — shared by the api `/library/lookup/*` proxy and + * the web Library import UI. The `provider` enum lists the six adapters + * (Open Library, Google Books, BNF, BNE, Wikidata, Amazon). + */ import { z } from 'zod'; /** diff --git a/packages/shared/src/schemas/preferences.ts b/packages/shared/src/schemas/preferences.ts index f487ce53..0bc4d28b 100644 --- a/packages/shared/src/schemas/preferences.ts +++ b/packages/shared/src/schemas/preferences.ts @@ -1,3 +1,11 @@ +/** + * Zod schema for the per-user preferences payload (theme, language, other + * cross-device personalisation) carried inside the encrypted blob. + * + * Where: packages/shared — shared by the api `/user-preferences` route and + * the web preferences store. Cleartext shape; the server only ever sees the + * ciphertext. + */ import { z } from 'zod'; /** diff --git a/packages/web/src/app/modules-registry.tsx b/packages/web/src/app/modules-registry.tsx index fe55b3a1..fb5d339f 100644 --- a/packages/web/src/app/modules-registry.tsx +++ b/packages/web/src/app/modules-registry.tsx @@ -148,11 +148,13 @@ export const MODULES: readonly ModuleDef[] = [ display: true, }, { - /** Hormone replacement therapy tracking. Two encrypted + /** Hormone replacement therapy tracking. Four encrypted * collections : `hrt_admin_logs_entries` (the dose/injection - * log) and `hrt_lab_results_entries` (lab markers + chart). + * log), `hrt_lab_results_entries` (lab markers + chart), + * `hrt_suppliers_entries` (the product catalog) and + * `hrt_schedules_entries` (recurring dose schedules). * `collection` here names the primary one for nav metadata — - * the module owns both. Two sub-views, à la Library. */ + * the module owns all four. Two sub-views, à la Library. */ id: 'hrt', label: 'modules.hrt.label', collection: 'hrt_admin_logs_entries', diff --git a/packages/web/src/app/pages/Docs.tsx b/packages/web/src/app/pages/Docs.tsx index a18019bc..73d61ae1 100644 --- a/packages/web/src/app/pages/Docs.tsx +++ b/packages/web/src/app/pages/Docs.tsx @@ -41,9 +41,8 @@ import DocsSelfHost, { * `CONTRIBUTING.md` du repo qui couvre le workflow upstream * (PR, conventions de commit) — audience différente. * - **Auto-héberger** (`/docs/self-host`) — install Docker, env - * vars, reverse proxy, mises à jour, backups. Source de vérité - * progressive : transfère depuis `docs/Operations.md` + le root - * README. + * vars, reverse proxy, mises à jour, backups. Reprend et + * complète le README racine du repo. * * Le `/flow` privacy invariant ne s'applique pas ici : `/docs` est * public, l'audience varie d'un onglet à l'autre, et les URLs par diff --git a/packages/web/src/app/pages/docs/DocsSelfHost.tsx b/packages/web/src/app/pages/docs/DocsSelfHost.tsx index a1cb15e4..c053a622 100644 --- a/packages/web/src/app/pages/docs/DocsSelfHost.tsx +++ b/packages/web/src/app/pages/docs/DocsSelfHost.tsx @@ -8,8 +8,7 @@ import { MarkdownTier, parseToc } from './primitives'; * (sur VPS, NAS, machine perso). Couvre l'install Docker, les * variables d'environnement critiques, le reverse proxy, les * mises à jour, le backup et le diagnostic en panne. Le contenu - * est progressivement transféré depuis `docs/Operations.md` et - * le README racine du repo vers cette page. + * reprend et complète le README racine du repo. */ // eslint-disable-next-line react-refresh/only-export-components diff --git a/packages/web/src/app/pages/docs/content/fork.md b/packages/web/src/app/pages/docs/content/fork.md index 60d854bc..29cb6631 100644 --- a/packages/web/src/app/pages/docs/content/fork.md +++ b/packages/web/src/app/pages/docs/content/fork.md @@ -32,11 +32,12 @@ pnpm --filter @nodea/web dev # Web Vite sur :8089 ## Comprendre la structure -Trois packages dans un monorepo pnpm : +Quatre packages dans un monorepo pnpm : - **`packages/shared/`** — schémas Zod, types branded crypto, partagés entre back et front. Le keystone : un schéma défini ici sert de validation API ET de resolver React Hook Form côté web. - **`packages/api/`** — Hono + Drizzle ORM + PostgreSQL 16. Les routes vivent dans `src/routes/`, les schémas DB dans `src/db/schema/`, les migrations dans `drizzle/`. - **`packages/web/`** — React 19 + Vite + Tailwind + Zustand. Les pages dans `src/app/pages/`, le store dans `src/core/store/`, la crypto dans `src/core/crypto/`. +- **`packages/e2e/`** — suite de tests end-to-end Playwright (parcours auth, modules) lancée contre la stack complète. Toute la documentation détaillée de l'architecture vit dans le repo aujourd'hui (`docs/Architecture.md`, `docs/Database.md`, `docs/Auth-Spec.md`) — elle migrera ici au fur et à mesure. @@ -47,7 +48,7 @@ Trois suites cohabitent. ```bash pnpm --filter @nodea/api test # ~278 tests d'intégration, ~3 min pnpm --filter @nodea/web test # ~319 tests unitaires React, ~5 s -pnpm --filter @nodea/e2e test # 13 tests Playwright end-to-end, ~3-5 min +pnpm --filter @nodea/e2e e2e # 13 tests Playwright end-to-end, ~3-5 min ``` Pour les tests e2e, prérequis machine : Postgres + Mailpit en route, plus le binaire Chromium installé une fois (`pnpm --filter @nodea/e2e install:browsers`). @@ -80,15 +81,15 @@ Si tu veux distinguer ta fork de l'instance officielle (autre nom, autres couleu ### Tokens couleurs -Définis dans `packages/web/src/ui/theme/global.css`. Les noms sémantiques sont volontairement non-anglophones pour ne pas être confondus avec des classes Tailwind par défaut. +Définis dans `packages/web/src/ui/theme/dirk.css` (les `--color-k-*` sur `:root` pour le mode clair et `.dark` pour le mode sombre). `global.css` ne fait que des `@import` — il ne contient aucune couleur. Les noms sémantiques sont volontairement non-anglophones pour ne pas être confondus avec des classes Tailwind par défaut. -| Nom | Hex | Rôle | -|---|---|---| -| Sauge | `#5a7a5e` | Accent principal (boutons primaires, liens, focus) | -| Sauge clarifié | `#9bbf9f` | Variante dark mode du sauge | -| Encre | `#161614` | Texte principal sur fond clair | -| Papier | `#fcfcfa` | Fond clair | -| Nuit chaude | `#1d1c18` | Fond dark mode | +| Nom | Token | Hex | Rôle | +|---|---|---|---| +| Sauge | `--color-k-accent` | `#5a7a5e` | Accent principal (boutons primaires, liens, focus) | +| Sauge clarifié | `--color-k-accent` (`.dark`) | `#7ea582` | Variante dark mode du sauge | +| Encre | `--color-k-ink` | `#161614` | Texte principal sur fond clair | +| Papier | `--color-k-bg` | `#f9f8f3` | Fond clair | +| Nuit chaude | `--color-k-bg` (`.dark`) | `#272620` | Fond dark mode | Wordmark : Instrument Serif, `font-weight: 400`, `letter-spacing: -0.015em`. diff --git a/packages/web/src/app/pages/docs/content/self-host.md b/packages/web/src/app/pages/docs/content/self-host.md index 2b4855bb..f63cf257 100644 --- a/packages/web/src/app/pages/docs/content/self-host.md +++ b/packages/web/src/app/pages/docs/content/self-host.md @@ -37,14 +37,16 @@ cp .env.example .env COOKIE_SECRET=<32 chars random> OPAQUE_SERVER_SETUP= DOMAIN=nodea.exemple.fr -WEB_BASE_URL=https://nodea.exemple.fr +ADDRESS=nodea.exemple.fr WEBAUTHN_RP_NAME=Nodea SMTP_HOST=... SMTP_PORT=... SMTP_USER=... -SMTP_PASSWORD=... +SMTP_PASS=... ``` +`ADDRESS` accepte soit un hôte nu (`nodea.exemple.fr` — l'API préfixe `https://` en interne), soit une URL complète (`https://nodea.exemple.fr`). C'est **requis** : `docker compose up` échoue immédiatement s'il est absent. + Génère un `COOKIE_SECRET` fort : ```bash @@ -78,15 +80,18 @@ docker compose exec api sh -c \ | `COOKIE_SECRET` | 32 chars random | Oui — change-le et toutes les sessions actives sont invalidées | | `OPAQUE_SERVER_SETUP` | Généré une fois, à conserver | Oui — le perdre = comptes existants inutilisables | | `DOMAIN` | Ton domaine sans `https://` ni port (sert aussi de WebAuthn rpId) | Oui — change-le et toutes les passkeys enrôlées sont perdues | -| `WEB_BASE_URL` | URL complète avec `https://` (sert aussi d'origin WebAuthn) | Oui — doit matcher exactement ce que voit le navigateur | -| `SMTP_*` | Provider SMTP | Oui — sans ça pas d'activation, pas de récupération | -| `OPEN_REGISTRATION` | `true` ou `false` | Optionnel — défaut `false` (admin doit envoyer une invitation) | +| `ADDRESS` | Hôte nu (`nodea.exemple.fr`) ou URL complète avec `https://` (sert aussi d'origin WebAuthn) | Oui — requis, l'API refuse de démarrer sans ; doit matcher exactement ce que voit le navigateur | +| `COOKIE_SECURE` | `true` ou `false` | Oui — défaut `true` (fail-secure) ; passe à `false` uniquement en dev HTTP local | +| `DATA_DIR` | Racine du bind-mount Postgres | Oui en prod — pointe vers un dossier sauvegardé (défaut dev `./data`) | +| `SMTP_*` | Provider SMTP (`SMTP_HOST`, `SMTP_PORT`, `SMTP_USER`, `SMTP_PASS`, …) | Oui — sans ça pas d'activation, pas de récupération | + +L'inscription ouverte (`open_registration`) n'est **pas** une variable d'environnement : c'est un réglage stocké en base (`app_settings`) qu'on active depuis l'interface d'administration (Réglages admin). Par défaut elle est désactivée (l'admin doit envoyer une invitation). La liste exhaustive avec types Zod et valeurs par défaut est dans `packages/api/src/config.ts`. ## Reverse proxy (HTTPS) -Nodea écoute sur :3000 (API) et :8089 (web statique) à l'intérieur de Docker. Tu mets un reverse proxy devant qui : +Nodea écoute sur :3000 (API) et :8080 (web statique, nginx) à l'intérieur de Docker. (Le :8089 est le port du serveur de dev Vite — une autre chose, voir `vite.config.js`.) Tu mets un reverse proxy devant qui : 1. Termine TLS (Let's Encrypt via Caddy ou Traefik). 2. Sert le web statique sur `/`. @@ -119,4 +124,4 @@ docker compose logs --tail=50 api docker compose logs --tail=50 postgres ``` -Un runbook ops complet (que faire quand X panne) sera transféré ici depuis `docs/Operations.md` du repo prochainement. +Un runbook ops complet (que faire quand X panne) sera ajouté ici prochainement. diff --git a/packages/web/src/app/pages/docs/content/tech.md b/packages/web/src/app/pages/docs/content/tech.md index 7dc61d8e..90f4b416 100644 --- a/packages/web/src/app/pages/docs/content/tech.md +++ b/packages/web/src/app/pages/docs/content/tech.md @@ -76,18 +76,25 @@ Trois étages, deux blobs structurels par utilisateur·ice. Aucune clé n'appara ### AAD (Additional Authenticated Data) -Chaque blob AES-GCM est lié à son contexte par une AAD. Un swap de blob entre utilisateurs ou entre facteurs fait échouer l'auth-tag au déchiffrement. +Chaque blob AES-GCM qui **enveloppe une clé** (KEK, main-key, passkey, label d'appareil) est lié à son contexte par une AAD. Un swap de blob entre utilisateurs ou entre facteurs fait échouer l'auth-tag au déchiffrement. (Les payloads des lignes de module ne portent pas d'AAD — seuls les blobs d'enveloppe en ont ; voir `core/crypto/aes.ts`.) -Construction canonique via `buildAAD(parts: Uint8Array[])` (dans `packages/shared/src/crypto-types.ts`) — chaque part préfixée par sa longueur en u16 big-endian, puis concaténation. Format non ambigu même avec parts de longueur variable (notamment `credential_id` WebAuthn, 16 à 1023 bytes). +Construction canonique dans `packages/web/src/core/crypto/factor-wrap.ts`, sous forme de **chaîne UTF-8** préfixée par version et jointe par le séparateur `\x1f` (Unit Separator, rare en texte et impossible dans un UUID) : -| Blob | AAD = `buildAAD([...])` | -|---|---| -| `wrapped_main_key` | `[users.id]` (UUID, 16 bytes raw) | -| `wrapped_kek_password` | `[users.id, utf8("password")]` | -| `wrapped_kek_passkey_` | `[users.id, utf8("passkey"), credential_id]` | -| `wrapped_kek_recovery` | `[users.id, utf8("recovery")]` | +```text +nodea:v1\x1f\x1f +``` -Aucune autre construction n'est autorisée dans le code applicatif — toute exception est un bug à fail-loud. +Le préfixe `nodea:v1` permet à un futur bump de schéma (v2) de rester volontairement incompatible avec les ciphertexts v1. + +| Blob | Builder | AAD | +|---|---|---| +| `wrapped_main_key` | `buildMainKeyAAD(userId)` | `nodea:v1\x1f\x1fmain` | +| `wrapped_kek_password` | `buildKekAAD(userId, 'password')` | `nodea:v1\x1f\x1fpassword` | +| `wrapped_kek_passkey_` | `buildPasskeyAAD(userId, credIdB64Url)` | `nodea:v1\x1f\x1fpasskey\x1f` | +| `wrapped_kek_recovery` | `buildKekAAD(userId, 'recovery')` | `nodea:v1\x1f\x1frecovery` | +| label d'appareil (session) | `buildSessionDeviceLabelAAD(userId)` | `nodea:v1\x1f\x1fsession-device-label` | + +`credIdB64Url` est le base64url canonique des bytes bruts du `credential_id` (même encodage que `auth_factors.credential_id` côté serveur). Aucune autre construction n'est autorisée dans le code applicatif — toute exception est un bug à fail-loud. ## Authentification @@ -106,7 +113,7 @@ Cinq routes peuvent être abusées pour énumérer les comptes ; toutes répond - `/auth/register/finish` — sur invite-bound, l'invite est consommée ou refusée selon que l'email matche le token, jamais selon que le compte existe déjà ailleurs. - `/auth/login/start` — un email inconnu reçoit une réponse OPAQUE syntaxiquement valide mais cryptographiquement morte (`finishLogin` côté client retourne `undefined`). - `/auth/recover-kek/start` — emails inconnus (ou emails connus sans recovery code) reçoivent des `wrappedKekRecovery` aléatoires de la bonne taille (48 bytes ciphertext + 12 bytes IV) ; le client échoue silencieusement à les unwrap. -- `/auth/mfa-bypass/request` — pendant un `mfa_pending`, la requête de bypass renvoie toujours 200, qu'un `mfa_bypass_request` soit créé en DB ou non. +- `/auth/mfa/bypass/request` — pendant un `mfa_pending`, la requête de bypass renvoie toujours 200, qu'un `mfa_bypass_request` soit créé en DB ou non. - `/auth/request-reset` — toujours 200, qu'un email matche ou non. ## Cycle de vie d'un compte @@ -179,7 +186,7 @@ Signature counter `signCount` vérifié strict côté serveur — un counter qui ### Bypass MFA par email -Auth-Spec §7.8. Quand l'utilisateur·ice perd son TOTP (téléphone perdu, app effacée…) et n'a pas de backup code sous la main, il/elle peut demander un bypass via `/auth/mfa-bypass/request`. Le serveur envoie un email avec un lien `/auth/bypass/confirm?t=` ; cliquer démarre un délai de **7 jours** pendant lesquels la requête peut être annulée par n'importe quelle connexion réussie. Après 7 jours, la prochaine connexion consomme le bypass : le facteur perdu est nettoyé (TOTP désactivé + backup codes purgés, ou toutes les passkeys supprimées) et le mode rétrogradé si nécessaire. +Auth-Spec §7.8. Quand l'utilisateur·ice perd son TOTP (téléphone perdu, app effacée…) et n'a pas de backup code sous la main, il/elle peut demander un bypass via `/auth/mfa/bypass/request`. Le serveur envoie un email avec un lien `/auth/mfa/bypass/confirm?t=` ; cliquer démarre un délai de **7 jours** pendant lesquels la requête peut être annulée par n'importe quelle connexion réussie. Après 7 jours, la prochaine connexion consomme le bypass : le facteur perdu est nettoyé (TOTP désactivé + backup codes purgés, ou toutes les passkeys supprimées) et le mode rétrogradé si nécessaire. Eligibility gate §6.2 : en mode `maximum`, un bypass d'un facteur exige que l'autre facteur soit prouvé dans la session `mfa_pending` avant d'émettre la requête. Sans ça, on perdrait deux facteurs d'un coup → reset destructif. @@ -222,12 +229,13 @@ Toutes les requêtes DB passent par Drizzle (`eq(users.email, value)`, etc.). ** ### Guards HMAC obligatoires sur les mutations -Toute table d'entrée chiffrée (`mood_entries`, `goals_entries`, etc.) requiert un guard HMAC pour UPDATE et DELETE. Le middleware `requireGuard(table)` valide le tuple `(user, sid, guard)` dans une seule passe centralisée. La liste des collections est dans `packages/api/src/collections/registry.ts` — le route factory itère cette liste pour monter les routes ; impossible d'enregistrer une collection sans validation. +Toute table d'entrée chiffrée (`mood_entries`, `goals_entries`, etc.) requiert un guard HMAC pour UPDATE et DELETE. Le middleware `requireGuard` valide le tuple `(user, sid, guard)` dans une seule passe centralisée. La liste des collections est le tableau `COLLECTIONS` dans `packages/api/src/collections.ts` — `createRecordsRoutes` itère cette liste pour monter les routes ; impossible d'enregistrer une collection sans validation. -**Formule du guard.** Déterministe, calculé côté client : +**Formule du guard.** Déterministe, calculée côté client en **deux passes HMAC-SHA-256** (`core/crypto/guard-derivation.ts`) — une sous-clé scopée au module, puis le tag sur l'`id` de la ligne : ```text -guard = "g_" + hex( HMAC(hmacKey, `${module_user_id}:${record_id}`) ) +scopedKey = HMAC(hmacKey, "guard:" + module_user_id) +guard = "g_" + hex( HMAC(scopedKey, record_id) ) ``` **Création en deux temps.** À la création, le client n'a pas encore l'`id` de la ligne (généré côté serveur). Il envoie donc `guard: "init"` au `POST`, reçoit l'`id` retourné, recalcule le vrai `guard`, puis fait immédiatement un `PATCH` avec les en-têtes `X-Sid: `, `X-Guard: init` et le vrai guard dans le body. Le serveur promeut `"init"` → vrai guard atomiquement. @@ -238,53 +246,74 @@ Tables 1:1 (`modules_config`, `user_preferences`) — pas de guard : le user *e ### Rate-limit catalog -22 limiters actifs au total. Format : `max / fenêtre`. Tous keyed-IP (premier hop `x-forwarded-for`, fallback `x-real-ip` puis `unknown`) et indépendants — un même IP peut consommer chaque bucket séparément. Implémenté en mémoire process (`packages/api/src/middleware/rate-limit.ts`). Single-instance ; scaling out = swap vers Redis. +35 limiters au total. Format : `max / fenêtre`. Tous keyed-IP (premier hop `x-forwarded-for`, fallback `x-real-ip` puis `unknown`), sauf le stepped-MFA (keyed-session) et `change-email` (keyed-user) ; chaque bucket est indépendant. Implémenté en mémoire process (`packages/api/src/middleware/rate-limit.ts`). Single-instance ; scaling out = swap vers Redis. Catalogue exhaustif — tout nouveau `rateLimit({…})` s'ajoute ici dans le même commit. -#### Pré-auth (`/auth/*` accessibles sans cookie de session) +#### Authentification & récupération (sans cookie, ou login en cours) -| Route | Limite | `keyPrefix` | Justification | -|---|---|---|---| -| `POST /auth/register/start` | 10 / 1h | `register-start` | OPAQUE start, accepte 10 essais/heure pour permettre la correction d'erreurs sans ouvrir un boulevard à l'enrôlement automatisé | -| `POST /auth/register/finish` | 5 / 1h | `register-finish` | Étape coûteuse (création user + opaque_record + invite consumption en transaction) | -| `POST /auth/register/activate` | 20 / 1h | `register-activate` | Click sur le magic-link — tolérant à un user qui clique plusieurs fois | -| `GET /auth/register/invite-info` | 30 / 1h | `register-invite-info` | Pré-rendu de l'écran de register (peeks au token sans le consommer) | -| `POST /auth/login` (legacy hashed) | 10 / 1min | `login` | Court-fenêtré pour limiter le brute-force, tolérant à un user qui se trompe | -| `POST /auth/login/start` + `/finish` | — | — | Pas de limiter dédié : OPAQUE est déjà coûteux côté serveur et `client.finishLogin` retourne `undefined` avant tout aller-retour réseau pour un mot de passe faux | -| `POST /auth/request-reset` | 5 / 1h | `request-reset` | Anti-spam mailer, 5 demandes par IP par heure suffisent à un user honnête | -| `POST /auth/reset` | 10 / 1min | `reset` | Mild cap pour ralentir un brute-force sur un token volé | -| `POST /auth/recover-kek/start` | 5 / 1h | `recover-kek` | Anti-énumération : 5 emails testés par heure max | -| `POST /auth/recover-kek/finish` | 5 / 1h | `recover-kek` | Partage le bucket avec `/start` | -| `POST /auth/mfa-bypass/confirm` | 20 / 1h | `mfa-bypass-link` | Click sur le magic-link de confirmation | - -#### Authentifié (cookie de session requis) - -| Route | Limite | `keyPrefix` | Justification | -|---|---|---|---| -| `POST /auth/reauth/password/{start,finish}` | 10 / 15min | `reauth` | Re-prove password gate — assez large pour les ré-auths légitimes, assez serré pour ne pas devenir un canal de devinette du mot de passe | -| `POST /auth/security-mode/change` | 10 / 15min | `security-mode-change` | Mutation sensible mais déjà gardée par re-auth | -| `POST /auth/mfa/totp` | 10 / 5min | `mfa-totp-verify` | Stepped-MFA après login : 10 tentatives/5min coupent le brute-force d'un code à 6 chiffres | -| `POST /auth/mfa/passkey/{options,verify}` | 10 / 5min | `mfa-passkey` | Stepped-MFA passkey : challenge à usage unique côté serveur | -| `POST /auth/mfa-bypass/request` | 3 / 1h | `mfa-bypass-request` | Anti-spam : trois mails maximum par heure pour le bypass MFA | -| `POST /auth/totp/enroll/{start,verify}` | 10 / 15min | `totp-enroll` | Setup d'un nouveau secret — utilisé une fois en pratique | -| `POST /auth/totp/{disable,…}` | 30 / 15min | `totp-manage` | Lecture/écriture régulière depuis Settings, plafond confortable | -| `POST /auth/security/recovery-code` | 5 / 1h | `recovery-code-setup` | Setup ou regenerate du code de récupération — opération rare | -| `POST /auth/passkeys/enroll/{options,finish}` | 10 / 15min | `passkey-enroll` | Setup d'un nouveau passkey, idem TOTP | -| `POST /auth/passkeys/{login-options,login-finish}` | 20 / 15min | `passkey-login` | Login passkey-first : tolérant aux annulations utilisateur sur le prompt OS | -| `* /auth/passkeys/:id/...` (rename, remove) | 30 / 15min | `passkey-manage` | Lecture/écriture Settings | - -#### Non-auth (lookup externes) - -| Route | Limite | `keyPrefix` | Justification | -|---|---|---|---| -| `GET /library/lookup/isbn/:isbn` | 30 / 1min | `library-lookup-isbn` | Proxy ISBN — protège l'API tierce | -| `GET /library/lookup/query` | 30 / 1min | `library-lookup-query` | Recherche fuzzy | -| `GET /library/lookup/cover/:hash` | 60 / 1min | `library-lookup-cover` | Couvertures d'images, ratio plus haut car appelé en batch sur une page de résultats | +| Route | Limite | `keyPrefix` | +|---|---|---| +| `POST /auth/register/start` | 10 / 1h | `register-start` | +| `POST /auth/register/finish` | 5 / 1h | `register-finish` | +| `POST /auth/register/activate` | 20 / 1h | `register-activate` | +| `GET /auth/register/invite-info` | 30 / 1h | `register-invite-info` | +| `POST /auth/login/start` + `/finish` | 10 / 1min | `login` | +| `POST /auth/passkeys/login/start` + `/finish` | 20 / 15min | `passkey-login` | +| `POST /auth/request-reset` | 5 / 1h | `request-reset` | +| `POST /auth/reset/start` + `/finish` | 10 / 1min | `reset` | +| `POST /auth/recover-kek/start` + `/finish` | 5 / 1h | `recover-kek` | +| `POST /auth/recover-kek/verify` | 3 / 1h | `recover-kek-verify` | + +#### MFA en cours de login (cookie `mfa_pending`) + +| Route | Limite | `keyPrefix` | +|---|---|---| +| `POST /auth/mfa/totp/verify` | 10 / 5min | `mfa-totp-verify` (keyed-session) | +| `POST /auth/mfa/passkey/start` + `/finish` | 10 / 5min | `mfa-passkey` (keyed-session) | +| `POST /auth/mfa/bypass/request` | 3 / 1h | `mfa-bypass-request` | +| `GET /auth/mfa/bypass/confirm` | 20 / 1h | `mfa-bypass-link` | + +#### Gestion du compte (cookie de session `full`) + +| Route | Limite | `keyPrefix` | +|---|---|---| +| `POST /auth/reauth/{password,passkey}/{start,finish}` | 10 / 15min | `reauth` | +| `POST /auth/change-password/start` + `/finish` | 5 / 1h | `change-password` | +| `PATCH /auth/email` | 1 / 24h | `rl:change-email` (keyed-user) | +| `POST /auth/security-mode/change` | 10 / 15min | `security-mode-change` | +| `POST /auth/totp/enroll/{start,verify}` | 10 / 15min | `totp-enroll` | +| `POST /auth/totp/{disable,backup-codes/regenerate}` | 30 / 15min | `totp-manage` | +| `POST /auth/security/recovery-code` | 5 / 1h | `recovery-code-setup` | +| `POST /auth/passkeys/enroll/{start,finish}` | 10 / 15min | `passkey-enroll` | +| `PATCH /auth/passkeys/{id}/label`, `POST …/remove` | 30 / 15min | `passkey-manage` | +| `PUT /modules-config` | 30 / 1min | `modules-config-put` | +| `PUT /user-preferences` | 60 / 1min | `user-preferences-put` | + +#### Mutations de données chiffrées (`/records*`, cookie + guard) + +| Route | Limite | `keyPrefix` | +|---|---|---| +| `POST /records` | 600 / 1min | `records-create` | +| `PATCH /records/{id}` | 600 / 1min | `records-update` | +| `DELETE /records/{id}` | 600 / 1min | `records-delete` | +| `GET /records` | 300 / 1min | `records-list` | +| `POST /records/bulk` | 60 / 1min | `records-bulk-create` | +| `POST /records/promote-guards` | 60 / 1min | `records-bulk-promote` | +| `POST /records/wipe` | 10 / 1min | `records-wipe` | + +#### Lookups externes (`/library/lookup/*`) + +| Route | Limite | `keyPrefix` | +|---|---|---| +| `POST /library/lookup/by-isbn` | 30 / 1min | `library-lookup-isbn` | +| `POST /library/lookup/by-query/stream` | 30 / 1min | `library-lookup-query` | +| `GET /library/lookup/cover-fetch` | 60 / 1min | `library-lookup-cover` | -**Politique implicite — trois familles de durée :** +**Politique implicite — familles de durée :** -- **5 minutes** pour les codes courts (TOTP, passkey en stepped-MFA) — borne le brute-force d'un secret 6 chiffres. -- **15 minutes** pour la gestion sensible (re-auth, security-mode, enroll d'un facteur). -- **1 heure** pour les actions à coût mailer ou serveur élevé (register, reset, recovery, bypass). +- **1 minute** pour les endpoints à fort débit légitime (login, reset, mutations `/records`, lookups, `modules-config`/`user-preferences`). +- **5 minutes** pour les codes courts (TOTP, passkey en stepped-MFA) — borne le brute-force d'un secret à 6 chiffres. +- **15 minutes** pour la gestion sensible (re-auth, security-mode, enroll d'un facteur, gestion des passkeys). +- **1 heure** (24 h pour le change-email) pour les actions à coût mailer/serveur élevé (register, request-reset, recovery, bypass, change-password). Si tu ajoutes une nouvelle route `/auth/*`, choisis la fenêtre selon ces familles plutôt que d'inventer une nouvelle valeur ; les exceptions doivent être justifiées en commentaire au-dessus du `rateLimit({…})`. @@ -299,7 +328,7 @@ Liste prescriptive utilisée à la revue de PR. La version exhaustive (avec rati **À faire :** - Générer un IV frais par chiffrement AES-GCM (`crypto.getRandomValues`). -- Construire toute AAD via `buildAAD(parts: Uint8Array[])` (dans `packages/shared/src/crypto-types.ts`) — jamais à la main. +- Construire toute AAD via les builders de `core/crypto/factor-wrap.ts` (`buildMainKeyAAD`, `buildKekAAD`, `buildPasskeyAAD`, `buildSessionDeviceLabelAAD`) — jamais à la main. - Dériver les sous-clés AES et HMAC via HKDF avec labels distincts (`"nodea:aes"`, `"nodea:hmac"`). - Utiliser les branded types (`AesMainKey`, `HmacMainKey`, `Base64`, `CipherIV`…) — mélanger les primitives doit échouer à la compilation. - Pour un nouveau module : réutiliser `createCollectionClient`, ne pas réimplémenter le POST/PATCH dance. @@ -564,7 +593,7 @@ Les lignes suivantes accumulent dans le temps et sont gardées pour audit : - `password_reset_tokens` après `consumed_at` - `email_verifications` autres que `register` -Pour une rétention plus serrée, étendre `packages/api/src/cron/index.ts` avec des delete-where passé une fenêtre d'audit choisie (ex. 90 jours). Choix laissé à l'opérateur — la valeur d'audit de ces lignes n'est pas nulle, et les volumes restent minuscules en V1. +Pour une rétention plus serrée, étendre `packages/api/src/cron.ts` avec des delete-where passé une fenêtre d'audit choisie (ex. 90 jours). Choix laissé à l'opérateur — la valeur d'audit de ces lignes n'est pas nulle, et les volumes restent minuscules en V1. ## Audit & divulgation diff --git a/packages/web/src/app/pages/docs/diagrams/MfaBypassDiagram.tsx b/packages/web/src/app/pages/docs/diagrams/MfaBypassDiagram.tsx index 30c7ffcc..ba40e336 100644 --- a/packages/web/src/app/pages/docs/diagrams/MfaBypassDiagram.tsx +++ b/packages/web/src/app/pages/docs/diagrams/MfaBypassDiagram.tsx @@ -148,7 +148,7 @@ export default function MfaBypassDiagram() { width={150} height={hMain} label="Demande" - sub="POST /mfa-bypass/request" + sub="POST /auth/mfa/bypass/request" /> {/* Arrow 1→2 */} diff --git a/packages/web/src/app/pages/docs/diagrams/OpaqueFlowDiagram.tsx b/packages/web/src/app/pages/docs/diagrams/OpaqueFlowDiagram.tsx index 1523540a..993b392e 100644 --- a/packages/web/src/app/pages/docs/diagrams/OpaqueFlowDiagram.tsx +++ b/packages/web/src/app/pages/docs/diagrams/OpaqueFlowDiagram.tsx @@ -217,7 +217,7 @@ export default function OpaqueFlowDiagram() { y={350} direction="left" label="200 OK + Set-Cookie nodea_session=…" - body="{ wrappedMainKey, wrappedKekPassword, … }" + body="{ needsMfa: false, id }" /> {/* Step 7 — client unwraps locally */} @@ -225,8 +225,8 @@ export default function OpaqueFlowDiagram() { side="client" yTop={390} height={70} - label="HKDF(exportKey, "nodea:wrap-kek")" - sub="→ AES-GCM-decrypt → KEK → main_key → AES + HMAC sub-keys" + label="GET /auth/me/crypto → HKDF(exportKey, "nodea:wrap-kek")" + sub="→ wrap blobs → AES-GCM-decrypt → KEK → main_key → AES + HMAC sub-keys" /> {/* Footer note */} diff --git a/packages/web/src/app/pages/docs/diagrams/SteppedMfaDiagram.tsx b/packages/web/src/app/pages/docs/diagrams/SteppedMfaDiagram.tsx index 8db632ee..fb86a091 100644 --- a/packages/web/src/app/pages/docs/diagrams/SteppedMfaDiagram.tsx +++ b/packages/web/src/app/pages/docs/diagrams/SteppedMfaDiagram.tsx @@ -217,7 +217,7 @@ export default function SteppedMfaDiagram() {