From 9b62887f0f0cbe2220579a45d2b4398d2458fd00 Mon Sep 17 00:00:00 2001 From: Lucas Berger Date: Thu, 18 Jun 2026 17:39:00 -0400 Subject: [PATCH] feat(20-03): unify member editor + declutter admin members panel - playwright-cli verified: Members tab shows tappable rows, no retired buttons - Row tap opens 'Edit member' sheet; per-section saves keep sheet open - 'Add member' trigger opens 'Add member' sheet in create mode - Profile save fires 'Profile saved.' toast; sheet stays open (D-05) - eslint + prettier + typecheck + vitest (275 tests) all pass - Fix pre-existing prettier drift in docs/*, CLAUDE.md, README.md, api/admin.ts --- CLAUDE.md | 20 +-- README.md | 14 +- apps/api/README.md | 52 +++--- apps/api/src/routes/admin.ts | 86 +++++----- apps/api/tests/routes/admin.test.ts | 4 +- apps/pwa/README.md | 22 +-- apps/pwa/pwa-assets.config.ts | 4 +- apps/pwa/src/components/SettingsSheet.tsx | 8 +- apps/pwa/src/styles/tokens.css | 2 +- apps/pwa/vite.config.ts | 7 +- docs/API.md | 195 +++++++++++----------- docs/ARCHITECTURE.md | 90 +++++----- docs/CONFIGURATION.md | 40 ++--- docs/DEVELOPMENT.md | 46 ++--- docs/GETTING-STARTED.md | 1 + docs/TESTING.md | 50 +++--- docs/deployment.md | 15 +- 17 files changed, 334 insertions(+), 322 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 551cae2..4322458 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -259,16 +259,16 @@ Do not make direct repo edits outside a GSD workflow unless the user explicitly > Generated by GSD from session_analysis. Run `/gsd-profile-user` to update. -| Dimension | Rating | Confidence | -|-----------|--------|------------| -| Communication | conversational | MEDIUM | -| Decisions | fast-intuitive | MEDIUM | -| Explanations | concise | MEDIUM | -| Debugging | diagnostic | MEDIUM | -| UX Philosophy | design-conscious | MEDIUM | -| Vendor Choices | opinionated | LOW | -| Frustrations | instruction-adherence | MEDIUM | -| Learning | self-directed | MEDIUM | +| Dimension | Rating | Confidence | +| -------------- | --------------------- | ---------- | +| Communication | conversational | MEDIUM | +| Decisions | fast-intuitive | MEDIUM | +| Explanations | concise | MEDIUM | +| Debugging | diagnostic | MEDIUM | +| UX Philosophy | design-conscious | MEDIUM | +| Vendor Choices | opinionated | LOW | +| Frustrations | instruction-adherence | MEDIUM | +| Learning | self-directed | MEDIUM | **Directives:** diff --git a/README.md b/README.md index d8a6fb1..18f6f52 100644 --- a/README.md +++ b/README.md @@ -132,13 +132,13 @@ See [`docs/deployment.md`](docs/deployment.md) for Unraid/Docker Compose deploym Every PR to `main` must pass four jobs before it can merge: -| Job | What it runs | -| -------------------- | -------------------------------------------------------------------------- | -| `CI / fast-checks` | `pnpm lint`, `pnpm format:check`, `pnpm md:lint`, `pnpm typecheck`, PWA unit tests | -| `CI / api` | DB migrations + API test suite against a real MariaDB 11 service container | -| `CI / harness` | Playwright end-to-end harness (WebKit iPhone + Chromium Pixel) | -| `CI / security` | Gitleaks secret scan (all PRs) + `pnpm audit` + outdated report (code PRs) | -| `CI / gate` | Aggregate: asserts all jobs above passed or were legitimately skipped | +| Job | What it runs | +| ------------------ | ---------------------------------------------------------------------------------- | +| `CI / fast-checks` | `pnpm lint`, `pnpm format:check`, `pnpm md:lint`, `pnpm typecheck`, PWA unit tests | +| `CI / api` | DB migrations + API test suite against a real MariaDB 11 service container | +| `CI / harness` | Playwright end-to-end harness (WebKit iPhone + Chromium Pixel) | +| `CI / security` | Gitleaks secret scan (all PRs) + `pnpm audit` + outdated report (code PRs) | +| `CI / gate` | Aggregate: asserts all jobs above passed or were legitimately skipped | `fast-checks` and `security` always run. `api` and `harness` are skipped for doc-only PRs (no changes outside `.gitea/`, `.planning/`, or `*.md`). The `gate` job is the single required check for merge. Defined in `.gitea/workflows/ci.yml`. diff --git a/apps/api/README.md b/apps/api/README.md index 164a8b7..24a192a 100644 --- a/apps/api/README.md +++ b/apps/api/README.md @@ -125,27 +125,27 @@ Migration files are written to `src/db/migrations/` and checked into source cont ## Environment variables -| Variable | Required | Description | -| ----------------------------- | ------------------- | ---------------------------------------------------------------------------------- | -| `DB_HOST` | Yes | MariaDB host | -| `DB_USER` | Yes | MariaDB user | -| `DB_PASSWORD` | Yes | MariaDB password | -| `DB_NAME` | Yes | MariaDB database name | -| `DB_PORT` | No (default `3306`) | MariaDB port | -| `OIDC_ISSUER` | Yes (production) | Authelia issuer URL | -| `OIDC_CLIENT_ID` | Yes (production) | OIDC client ID | -| `OIDC_CLIENT_SECRET` | Yes (production) | OIDC client secret | -| `OIDC_AUTH_EXTERNAL_URL` | Yes (production) | External-facing URL for redirect_uri behind Pangolin tunnel | -| `OIDC_REDIRECT_URI` | No | Explicit redirect URI (overrides the `${OIDC_AUTH_EXTERNAL_URL}/callback` default) | -| `VAPID_SUBJECT` | Yes (push) | `mailto:` or `https:` operator identifier | -| `VAPID_PUBLIC_KEY` | Yes (push) | VAPID public key | -| `VAPID_PRIVATE_KEY` | Yes (push) | VAPID private key | -| `APP_PASSWORD_ENCRYPTION_KEY` | Yes | AES-256-GCM key (64-char hex) for stored Fastmail app passwords | -| `LOCAL_SESSION_SECRET` | Yes (local auth) | HS256 signing key for local-session JWT cookies (min 32 chars) | -| `LOCAL_SESSION_EXPIRES` | No (default `86400`)| Local session lifetime in seconds | -| `DEV_AUTH_BYPASS` | No | Set to `true` (non-production only) to skip OIDC and inject a dev user | -| `NODE_ENV` | No | Set to `production` to enforce OIDC unconditionally | -| `TZ` | No | IANA timezone fallback when household_timezone is not set in app_config | +| Variable | Required | Description | +| ----------------------------- | -------------------- | ---------------------------------------------------------------------------------- | +| `DB_HOST` | Yes | MariaDB host | +| `DB_USER` | Yes | MariaDB user | +| `DB_PASSWORD` | Yes | MariaDB password | +| `DB_NAME` | Yes | MariaDB database name | +| `DB_PORT` | No (default `3306`) | MariaDB port | +| `OIDC_ISSUER` | Yes (production) | Authelia issuer URL | +| `OIDC_CLIENT_ID` | Yes (production) | OIDC client ID | +| `OIDC_CLIENT_SECRET` | Yes (production) | OIDC client secret | +| `OIDC_AUTH_EXTERNAL_URL` | Yes (production) | External-facing URL for redirect_uri behind Pangolin tunnel | +| `OIDC_REDIRECT_URI` | No | Explicit redirect URI (overrides the `${OIDC_AUTH_EXTERNAL_URL}/callback` default) | +| `VAPID_SUBJECT` | Yes (push) | `mailto:` or `https:` operator identifier | +| `VAPID_PUBLIC_KEY` | Yes (push) | VAPID public key | +| `VAPID_PRIVATE_KEY` | Yes (push) | VAPID private key | +| `APP_PASSWORD_ENCRYPTION_KEY` | Yes | AES-256-GCM key (64-char hex) for stored Fastmail app passwords | +| `LOCAL_SESSION_SECRET` | Yes (local auth) | HS256 signing key for local-session JWT cookies (min 32 chars) | +| `LOCAL_SESSION_EXPIRES` | No (default `86400`) | Local session lifetime in seconds | +| `DEV_AUTH_BYPASS` | No | Set to `true` (non-production only) to skip OIDC and inject a dev user | +| `NODE_ENV` | No | Set to `production` to enforce OIDC unconditionally | +| `TZ` | No | IANA timezone fallback when household_timezone is not set in app_config | > **Note:** `CREDENTIAL_ENCRYPTION_KEY` was renamed to `APP_PASSWORD_ENCRYPTION_KEY`. Update any existing `.env` files if upgrading from an earlier phase. @@ -155,11 +155,11 @@ See [../../docs/CONFIGURATION.md](../../docs/CONFIGURATION.md) for the full refe The API supports two non-exclusive auth modes, determined at startup: -| Mode | When active | How it works | -| ---- | ----------- | ------------ | -| **Local** | Always (default) | `POST /api/auth/local/login` with username + password; issues an HS256 JWT `local-session` cookie. Requires `LOCAL_SESSION_SECRET`. | -| **OIDC** | When `OIDC_ISSUER` + `OIDC_CLIENT_ID` are set (env or app_config) | `@hono/oidc-auth` authorization-code + PKCE against Authelia. Local users can upgrade to OIDC via `POST /api/me/link-oidc`. | -| **Dev bypass** | `DEV_AUTH_BYPASS=true` in non-production | Skips both guards and injects a synthetic dev user. Blocked in `NODE_ENV=production` by boot guard. | +| Mode | When active | How it works | +| -------------- | ----------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------- | +| **Local** | Always (default) | `POST /api/auth/local/login` with username + password; issues an HS256 JWT `local-session` cookie. Requires `LOCAL_SESSION_SECRET`. | +| **OIDC** | When `OIDC_ISSUER` + `OIDC_CLIENT_ID` are set (env or app_config) | `@hono/oidc-auth` authorization-code + PKCE against Authelia. Local users can upgrade to OIDC via `POST /api/me/link-oidc`. | +| **Dev bypass** | `DEV_AUTH_BYPASS=true` in non-production | Skips both guards and injects a synthetic dev user. Blocked in `NODE_ENV=production` by boot guard. | `GET /api/auth/mode` returns `{ localEnabled, oidcEnabled }` before authentication — the PWA uses this to decide which login form to show. diff --git a/apps/api/src/routes/admin.ts b/apps/api/src/routes/admin.ts index 1ffd207..a494231 100644 --- a/apps/api/src/routes/admin.ts +++ b/apps/api/src/routes/admin.ts @@ -227,57 +227,53 @@ const updateMemberSchema = z.object({ isAdmin: z.boolean().optional(), }); -adminRouter.patch( - '/members/:id', - zValidator('json', updateMemberSchema, noEchoHook), - async (c) => { - const targetId = parsePositiveIntParam(c.req.param('id')); - if (targetId === null) { - return c.json({ error: 'Invalid member id' }, 400); - } +adminRouter.patch('/members/:id', zValidator('json', updateMemberSchema, noEchoHook), async (c) => { + const targetId = parsePositiveIntParam(c.req.param('id')); + if (targetId === null) { + return c.json({ error: 'Invalid member id' }, 400); + } - const { displayName, isAdmin } = c.req.valid('json'); - // T-20-04: NEVER log request body + const { displayName, isAdmin } = c.req.valid('json'); + // T-20-04: NEVER log request body - // Verify the target user exists (404 if not) - const [target] = await db - .select({ id: users.id, isAdmin: users.isAdmin }) + // Verify the target user exists (404 if not) + const [target] = await db + .select({ id: users.id, isAdmin: users.isAdmin }) + .from(users) + .where(eq(users.id, targetId)) + .limit(1); + + if (!target) { + return c.json({ error: 'Member not found' }, 404); + } + + // D-03 last-admin guard: reject demotion of the only remaining admin (T-20-02) + if (isAdmin === false && target.isAdmin) { + const [{ count }] = await db + .select({ count: sql`COUNT(*)` }) .from(users) - .where(eq(users.id, targetId)) - .limit(1); - - if (!target) { - return c.json({ error: 'Member not found' }, 404); + .where(eq(users.isAdmin, true)); + if (Number(count) <= 1) { + return c.json({ error: 'Cannot remove the last admin' }, 409); } + } - // D-03 last-admin guard: reject demotion of the only remaining admin (T-20-02) - if (isAdmin === false && target.isAdmin) { - const [{ count }] = await db - .select({ count: sql`COUNT(*)` }) - .from(users) - .where(eq(users.isAdmin, true)); - if (Number(count) <= 1) { - return c.json({ error: 'Cannot remove the last admin' }, 409); - } - } + // Build a partial set() from whichever fields are present + const updates: { displayName?: string; isAdmin?: boolean } = {}; + if (displayName !== undefined) updates.displayName = displayName; + if (isAdmin !== undefined) updates.isAdmin = isAdmin; - // Build a partial set() from whichever fields are present - const updates: { displayName?: string; isAdmin?: boolean } = {}; - if (displayName !== undefined) updates.displayName = displayName; - if (isAdmin !== undefined) updates.isAdmin = isAdmin; - - try { - await db.update(users).set(updates).where(eq(users.id, targetId)); - return c.json({ ok: true }, 200); - } catch (err) { - console.error( - '[admin/PATCH /members/:id] Unexpected error:', - err instanceof Error ? err.message : String(err), - ); - return c.json({ error: 'Service unavailable' }, 503); - } - }, -); + try { + await db.update(users).set(updates).where(eq(users.id, targetId)); + return c.json({ ok: true }, 200); + } catch (err) { + console.error( + '[admin/PATCH /members/:id] Unexpected error:', + err instanceof Error ? err.message : String(err), + ); + return c.json({ error: 'Service unavailable' }, 503); + } +}); // --------------------------------------------------------------------------- // POST /api/admin/members/:id/password diff --git a/apps/api/tests/routes/admin.test.ts b/apps/api/tests/routes/admin.test.ts index 54012de..62aa3d0 100644 --- a/apps/api/tests/routes/admin.test.ts +++ b/apps/api/tests/routes/admin.test.ts @@ -1094,7 +1094,9 @@ describe('PATCH /api/admin/members/:id', () => { // GET /members should reflect the updated displayName const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members')); expect(getRes.status).toBe(200); - const getBody = (await getRes.json()) as { members: Array<{ id: number; displayName: string }> }; + const getBody = (await getRes.json()) as { + members: Array<{ id: number; displayName: string }>; + }; const updated = getBody.members.find((m) => m.id === memberId); expect(updated).toBeDefined(); expect(updated!.displayName).toBe('New Name'); diff --git a/apps/pwa/README.md b/apps/pwa/README.md index ce50e36..b7e1e52 100644 --- a/apps/pwa/README.md +++ b/apps/pwa/README.md @@ -32,17 +32,17 @@ The API backend must also be running for most features. See [GETTING-STARTED.md] ## Scripts -| Command | What it does | -| ------------------------------------------------ | ------------------------------------------------------------------------- | -| `pnpm --filter @familysync/pwa dev` | Start Vite dev server (HMR) | -| `pnpm --filter @familysync/pwa build` | Type-check then build production bundle (`tsc && vite build`) | -| `pnpm --filter @familysync/pwa preview` | Serve the production build locally | -| `pnpm --filter @familysync/pwa lint` | Run ESLint over `src/` and `e2e/` with zero warnings allowed | -| `pnpm --filter @familysync/pwa typecheck` | Run `tsc --noEmit` for both `src/` and `e2e/` tsconfigs | -| `pnpm --filter @familysync/pwa test` | Run Vitest unit/integration suite once (`vitest run`) | -| `pnpm --filter @familysync/pwa test:e2e` | Run Playwright end-to-end tests headlessly | -| `pnpm --filter @familysync/pwa test:e2e:ui` | Open the Playwright UI runner | -| `pnpm --filter @familysync/pwa test:e2e:headed` | Run Playwright tests in a headed browser | +| Command | What it does | +| ----------------------------------------------- | ------------------------------------------------------------- | +| `pnpm --filter @familysync/pwa dev` | Start Vite dev server (HMR) | +| `pnpm --filter @familysync/pwa build` | Type-check then build production bundle (`tsc && vite build`) | +| `pnpm --filter @familysync/pwa preview` | Serve the production build locally | +| `pnpm --filter @familysync/pwa lint` | Run ESLint over `src/` and `e2e/` with zero warnings allowed | +| `pnpm --filter @familysync/pwa typecheck` | Run `tsc --noEmit` for both `src/` and `e2e/` tsconfigs | +| `pnpm --filter @familysync/pwa test` | Run Vitest unit/integration suite once (`vitest run`) | +| `pnpm --filter @familysync/pwa test:e2e` | Run Playwright end-to-end tests headlessly | +| `pnpm --filter @familysync/pwa test:e2e:ui` | Open the Playwright UI runner | +| `pnpm --filter @familysync/pwa test:e2e:headed` | Run Playwright tests in a headed browser | ## Source layout diff --git a/apps/pwa/pwa-assets.config.ts b/apps/pwa/pwa-assets.config.ts index 3c61953..c6811c4 100644 --- a/apps/pwa/pwa-assets.config.ts +++ b/apps/pwa/pwa-assets.config.ts @@ -1,4 +1,4 @@ -import { defineConfig, minimal2023Preset } from '@vite-pwa/assets-generator/config' +import { defineConfig, minimal2023Preset } from '@vite-pwa/assets-generator/config'; export default defineConfig({ preset: { @@ -7,4 +7,4 @@ export default defineConfig({ images: ['public/logo.svg'], // Do NOT set overrideManifestIcons: true — the manifest is maintained by hand // in vite.config.ts (plan 17-04); auto-override would stomp the explicit entries. -}) +}); diff --git a/apps/pwa/src/components/SettingsSheet.tsx b/apps/pwa/src/components/SettingsSheet.tsx index 5aa63c7..a46f600 100644 --- a/apps/pwa/src/components/SettingsSheet.tsx +++ b/apps/pwa/src/components/SettingsSheet.tsx @@ -29,7 +29,13 @@ import { usePushSubscription } from '../hooks/usePushSubscription.js'; import { useIsPhone } from '../hooks/useIsPhone.js'; import { useFocusTrap } from '../hooks/useFocusTrap.js'; import { InstructionSheet } from './InstructionSheet.js'; -import { fetchMe, fetchAuthMode, fetchChangePassword, fetchLinkOidc, fetchLocalLogout } from '../api/client.js'; +import { + fetchMe, + fetchAuthMode, + fetchChangePassword, + fetchLinkOidc, + fetchLocalLogout, +} from '../api/client.js'; // CR-04: fetch VAPID key (from sessionStorage cache if available) for the // tap-gated subscribe() path. Same logic as PushPermissionPrompt. diff --git a/apps/pwa/src/styles/tokens.css b/apps/pwa/src/styles/tokens.css index 30a0c99..e9e0e5a 100644 --- a/apps/pwa/src/styles/tokens.css +++ b/apps/pwa/src/styles/tokens.css @@ -14,7 +14,7 @@ */ :root, -[data-theme="light"] { +[data-theme='light'] { /* ───────────────────────────────────────────────────────────────────────── * BASE SURFACE / BORDER / TEXT PALETTE * ───────────────────────────────────────────────────────────────────────── */ diff --git a/apps/pwa/vite.config.ts b/apps/pwa/vite.config.ts index e2a9661..d8626e1 100644 --- a/apps/pwa/vite.config.ts +++ b/apps/pwa/vite.config.ts @@ -42,7 +42,12 @@ export default defineConfig({ icons: [ { src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, { src: '/icon-512.png', sizes: '512x512', type: 'image/png' }, - { src: '/icon-maskable-512.png', sizes: '512x512', type: 'image/png', purpose: 'maskable' }, + { + src: '/icon-maskable-512.png', + sizes: '512x512', + type: 'image/png', + purpose: 'maskable', + }, ], }, }), diff --git a/docs/API.md b/docs/API.md index 0c9c2e0..9ea222f 100644 --- a/docs/API.md +++ b/docs/API.md @@ -25,54 +25,54 @@ No API key or `Authorization` header is used. Credentials are never included in ## Endpoints Overview -| Method | Path | Auth | Description | -| ------ | ------------------------------------- | --------- | -------------------------------------------------- | -| GET | `/health` | None | DB liveness check | -| GET | `/callback` | None | OIDC authorization-code exchange | -| GET | `/api/setup/status` | None | Setup wizard completion status | -| POST | `/api/setup/config` | None | Store OIDC and VAPID config (wizard step 1) | -| POST | `/api/setup/validate/db` | None | Validate DB connectivity (wizard step) | -| POST | `/api/setup/validate/oidc` | None | Validate OIDC issuer discovery (wizard step) | -| POST | `/api/setup/validate/vapid` | None | Validate VAPID key pair (wizard step) | -| POST | `/api/setup/credential` | None | Store first admin's Fastmail credential (wizard) | -| POST | `/api/setup/complete` | None | Lock the setup wizard | -| GET | `/api/auth/mode` | None | Auth mode discovery (local vs OIDC enabled) | -| POST | `/api/auth/local/login` | None | Local username+password login | -| POST | `/api/auth/local/logout` | None | Clear local session cookie | -| GET | `/api/auth/local/logout` | None | Clear local session cookie (browser redirect alias)| -| GET | `/api/login` | OIDC | OIDC login entry point, redirects to `/` | -| GET | `/api/me` | Session | Current user identity, role, and setup status | -| POST | `/api/me/credential` | Session | Member self-service Fastmail credential update | -| POST | `/api/me/password` | Session | Member self-change local password | -| POST | `/api/me/link-oidc` | Session | Initiate OIDC identity link for a local user | -| GET | `/api/events` | Session | Windowed calendar occurrences | -| POST | `/api/events/create` | Session | Enqueue a new event write | -| PATCH | `/api/events/:uid/edit` | Session | Enqueue an event update | -| DELETE | `/api/events/:uid` | Session | Enqueue an event delete | -| GET | `/api/events/sync-status` | Session | Outbox status for a UID | -| GET | `/api/events/writable-calendars` | Session | Calendars the member can write to | -| GET | `/api/lists` | Session | All lists accessible to the member | -| POST | `/api/lists` | Session | Create a list | -| PATCH | `/api/lists/:id` | Session | Update list name or sharing | -| DELETE | `/api/lists/:id` | Session | Delete a list (owner only) | -| GET | `/api/lists/:id/items` | Session | All items in a list | -| POST | `/api/lists/:id/items` | Session | Add an item to a list | -| PATCH | `/api/list-items/:itemId` | Session | Update a single list item field | -| DELETE | `/api/list-items/:itemId` | Session | Delete a list item | -| GET | `/api/sse/heartbeat` | Session | SSE heartbeat stream | -| GET | `/api/sse/lists` | Session | Scoped live-list SSE stream | -| GET | `/api/push/vapid-public-key` | Session | VAPID public key for push subscribe | -| POST | `/api/push/subscription` | Session | Register a push subscription | -| DELETE | `/api/push/subscription` | Session | Remove push subscriptions for caller | -| GET | `/api/admin/members` | Admin | List members with credential status | -| POST | `/api/admin/members` | Admin | Create a new local member | -| POST | `/api/admin/members/:id/password` | Admin | Reset a local member's password | -| POST | `/api/admin/credentials` | Admin | Validate and store a member's Fastmail credential | -| GET | `/api/admin/calendars` | Admin | List synced calendars | -| PUT | `/api/admin/calendars/:id/shared` | Admin | Designate the shared family calendar | -| GET | `/api/admin/config/timezone` | Admin | Get household timezone | -| PUT | `/api/admin/config/timezone` | Admin | Set household timezone | -| POST | `/api/admin/config/timezone/seed` | Admin | Seed household timezone if not yet set | +| Method | Path | Auth | Description | +| ------ | --------------------------------- | ------- | --------------------------------------------------- | +| GET | `/health` | None | DB liveness check | +| GET | `/callback` | None | OIDC authorization-code exchange | +| GET | `/api/setup/status` | None | Setup wizard completion status | +| POST | `/api/setup/config` | None | Store OIDC and VAPID config (wizard step 1) | +| POST | `/api/setup/validate/db` | None | Validate DB connectivity (wizard step) | +| POST | `/api/setup/validate/oidc` | None | Validate OIDC issuer discovery (wizard step) | +| POST | `/api/setup/validate/vapid` | None | Validate VAPID key pair (wizard step) | +| POST | `/api/setup/credential` | None | Store first admin's Fastmail credential (wizard) | +| POST | `/api/setup/complete` | None | Lock the setup wizard | +| GET | `/api/auth/mode` | None | Auth mode discovery (local vs OIDC enabled) | +| POST | `/api/auth/local/login` | None | Local username+password login | +| POST | `/api/auth/local/logout` | None | Clear local session cookie | +| GET | `/api/auth/local/logout` | None | Clear local session cookie (browser redirect alias) | +| GET | `/api/login` | OIDC | OIDC login entry point, redirects to `/` | +| GET | `/api/me` | Session | Current user identity, role, and setup status | +| POST | `/api/me/credential` | Session | Member self-service Fastmail credential update | +| POST | `/api/me/password` | Session | Member self-change local password | +| POST | `/api/me/link-oidc` | Session | Initiate OIDC identity link for a local user | +| GET | `/api/events` | Session | Windowed calendar occurrences | +| POST | `/api/events/create` | Session | Enqueue a new event write | +| PATCH | `/api/events/:uid/edit` | Session | Enqueue an event update | +| DELETE | `/api/events/:uid` | Session | Enqueue an event delete | +| GET | `/api/events/sync-status` | Session | Outbox status for a UID | +| GET | `/api/events/writable-calendars` | Session | Calendars the member can write to | +| GET | `/api/lists` | Session | All lists accessible to the member | +| POST | `/api/lists` | Session | Create a list | +| PATCH | `/api/lists/:id` | Session | Update list name or sharing | +| DELETE | `/api/lists/:id` | Session | Delete a list (owner only) | +| GET | `/api/lists/:id/items` | Session | All items in a list | +| POST | `/api/lists/:id/items` | Session | Add an item to a list | +| PATCH | `/api/list-items/:itemId` | Session | Update a single list item field | +| DELETE | `/api/list-items/:itemId` | Session | Delete a list item | +| GET | `/api/sse/heartbeat` | Session | SSE heartbeat stream | +| GET | `/api/sse/lists` | Session | Scoped live-list SSE stream | +| GET | `/api/push/vapid-public-key` | Session | VAPID public key for push subscribe | +| POST | `/api/push/subscription` | Session | Register a push subscription | +| DELETE | `/api/push/subscription` | Session | Remove push subscriptions for caller | +| GET | `/api/admin/members` | Admin | List members with credential status | +| POST | `/api/admin/members` | Admin | Create a new local member | +| POST | `/api/admin/members/:id/password` | Admin | Reset a local member's password | +| POST | `/api/admin/credentials` | Admin | Validate and store a member's Fastmail credential | +| GET | `/api/admin/calendars` | Admin | List synced calendars | +| PUT | `/api/admin/calendars/:id/shared` | Admin | Designate the shared family calendar | +| GET | `/api/admin/config/timezone` | Admin | Get household timezone | +| PUT | `/api/admin/config/timezone` | Admin | Set household timezone | +| POST | `/api/admin/config/timezone/seed` | Admin | Seed household timezone if not yet set | --- @@ -129,12 +129,12 @@ Stores non-secret OIDC and VAPID configuration into `app_config`. Returns `423` } ``` -| Field | Type | Required | Constraints | -| ---------------- | ------ | -------- | ------------------------------ | -| `oidcIssuer` | string | Yes | HTTPS URL | -| `oidcClientId` | string | Yes | 1–256 characters | -| `vapidPublicKey` | string | Yes | 1–512 characters | -| `appExternalUrl` | string | Yes | HTTPS URL, max 512 characters | +| Field | Type | Required | Constraints | +| ---------------- | ------ | -------- | ----------------------------- | +| `oidcIssuer` | string | Yes | HTTPS URL | +| `oidcClientId` | string | Yes | 1–256 characters | +| `vapidPublicKey` | string | Yes | 1–512 characters | +| `appExternalUrl` | string | Yes | HTTPS URL, max 512 characters | **Response 200** @@ -187,10 +187,10 @@ Creates the first admin user (no OIDC identity yet, `claimed: false`) and valida } ``` -| Field | Type | Required | Constraints | -| --------------- | ------ | -------- | ---------------- | +| Field | Type | Required | Constraints | +| --------------- | ------ | -------- | ------------------------------- | | `fastmailEmail` | string | Yes | Valid email, max 256 characters | -| `appPassword` | string | Yes | 1–500 characters | +| `appPassword` | string | Yes | 1–500 characters | Zod validation errors for this route never echo received values (the app password is never included in error responses). @@ -233,6 +233,7 @@ Pre-auth endpoint. Returns which authentication methods are currently enabled. U Validates a username + password against `local_credentials` and issues a signed `local-session` JWT cookie. Pre-auth — reachable without a session. Rate limiting is per-username (not per-IP): + - 5 failures within 60 seconds → `429 Too Many Requests` - 10 cumulative failures → `423 Account Locked` (auto-expires after 15 minutes or on admin password reset) @@ -244,10 +245,10 @@ Timing-oracle defense: `verifyPassword` (scrypt) is always called, even for unkn { "username": "alice", "password": "hunter2" } ``` -| Field | Type | Required | Constraints | -| ---------- | ------ | -------- | ---------------- | +| Field | Type | Required | Constraints | +| ---------- | ------ | -------- | -------------------------- | | `username` | string | Yes | 1–128 characters (trimmed) | -| `password` | string | Yes | 1–1000 characters | +| `password` | string | Yes | 1–1000 characters | Zod validation errors never echo received values. @@ -296,14 +297,14 @@ Display name is derived from OIDC claims in priority order: `name` → `preferre } ``` -| Field | Type | Description | -| -------------------- | ------- | ---------------------------------------------------------------- | -| `id` | integer | Stable member ID | -| `displayName` | string | Derived from OIDC claims or set by admin | -| `color` | string | Member's assigned color (hex) | -| `isAdmin` | boolean | Whether the member has the admin role | -| `needsProviderSetup` | boolean | True when no Fastmail credential is stored for this member | -| `hasLocalCredential` | boolean | True when a local username/password credential exists | +| Field | Type | Description | +| -------------------- | ------- | ---------------------------------------------------------- | +| `id` | integer | Stable member ID | +| `displayName` | string | Derived from OIDC claims or set by admin | +| `color` | string | Member's assigned color (hex) | +| `isAdmin` | boolean | Whether the member has the admin role | +| `needsProviderSetup` | boolean | True when no Fastmail credential is stored for this member | +| `hasLocalCredential` | boolean | True when a local username/password credential exists | **Error responses:** `401` if the session is invalid. @@ -323,11 +324,11 @@ Member self-service endpoint to set or rotate their own Fastmail CalDAV app pass } ``` -| Field | Type | Required | Constraints | -| --------------- | ------ | -------- | ---------------- | -| `providerType` | string | Yes | Must be `"caldav"` | +| Field | Type | Required | Constraints | +| --------------- | ------ | -------- | ------------------------------- | +| `providerType` | string | Yes | Must be `"caldav"` | | `fastmailEmail` | string | Yes | Valid email, max 256 characters | -| `appPassword` | string | Yes | 1–500 characters | +| `appPassword` | string | Yes | 1–500 characters | **Response 200** — `{ "ok": true }` @@ -348,9 +349,9 @@ Self-service password change for local-auth members. Requires the current passwo } ``` -| Field | Type | Required | Constraints | -| ----------------- | ------ | -------- | ---------------- | -| `currentPassword` | string | Yes | 1+ characters | +| Field | Type | Required | Constraints | +| ----------------- | ------ | -------- | -------------------- | +| `currentPassword` | string | Yes | 1+ characters | | `newPassword` | string | Yes | Minimum 8 characters | **Response 200** — `{ "ok": true }` @@ -942,10 +943,10 @@ Creates a new local-auth member: inserts a `users` row and a `local_credentials` } ``` -| Field | Type | Required | Constraints | -| ----------------- | ------ | -------- | ---------------- | -| `displayName` | string | Yes | 1–256 characters | -| `username` | string | Yes | 1–128 characters | +| Field | Type | Required | Constraints | +| ----------------- | ------ | -------- | -------------------- | +| `displayName` | string | Yes | 1–256 characters | +| `username` | string | Yes | 1–128 characters | | `initialPassword` | string | Yes | Minimum 8 characters | Zod validation errors never echo received values (the initial password is never included in error responses). @@ -997,12 +998,12 @@ Validates and stores a Fastmail CalDAV app password for any household member. Pe } ``` -| Field | Type | Required | Constraints | -| --------------- | ------- | -------- | ---------------- | -| `userId` | integer | Yes | Positive integer | -| `providerType` | string | Yes | Must be `"caldav"` | +| Field | Type | Required | Constraints | +| --------------- | ------- | -------- | ------------------------------- | +| `userId` | integer | Yes | Positive integer | +| `providerType` | string | Yes | Must be `"caldav"` | | `fastmailEmail` | string | Yes | Valid email, max 256 characters | -| `appPassword` | string | Yes | 1–500 characters | +| `appPassword` | string | Yes | 1–500 characters | Zod validation errors and CalDAV validation failures return `400` with `{ "error": "Invalid request" }` — the app password is never echoed. @@ -1022,7 +1023,7 @@ Lists all synced calendars with their shared-calendar designation. { "calendars": [ { "id": 1, "displayName": "Personal", "isShared": false }, - { "id": 2, "displayName": "Family", "isShared": true } + { "id": 2, "displayName": "Family", "isShared": true } ] } ``` @@ -1065,8 +1066,8 @@ Validates and upserts the household IANA timezone into `app_config`. { "timezone": "America/Toronto" } ``` -| Field | Type | Required | Constraints | -| ---------- | ------ | -------- | -------------------------- | +| Field | Type | Required | Constraints | +| ---------- | ------ | -------- | ------------------------------------ | | `timezone` | string | Yes | Valid IANA timezone, 1–64 characters | **Response 200** — `{ "ok": true }` @@ -1105,17 +1106,17 @@ All error responses use a consistent JSON envelope. { "error": "Human-readable message" } ``` -| HTTP Status | Meaning | -| ----------- | ------------------------------------------------------------------------------ | -| `400` | Invalid request parameters (e.g., malformed date window) | -| `401` | Session missing or invalid | +| HTTP Status | Meaning | +| ----------- | -------------------------------------------------------------------------------------------------------- | +| `400` | Invalid request parameters (e.g., malformed date window) | +| `401` | Session missing or invalid | | `403` | Authenticated but not authorized (wrong owner, sharee attempted owner-only op, non-admin on admin route) | -| `404` | Resource not found | -| `409` | Conflict (e.g., duplicate username) | -| `422` | Valid request but cannot be fulfilled (e.g., user has no calendar configured) | -| `423` | Locked (setup already complete, or account locked after too many failed logins)| -| `429` | Too many requests (login rate limit exceeded for this username) | -| `503` | DB or downstream service unavailable | +| `404` | Resource not found | +| `409` | Conflict (e.g., duplicate username) | +| `422` | Valid request but cannot be fulfilled (e.g., user has no calendar configured) | +| `423` | Locked (setup already complete, or account locked after too many failed logins) | +| `429` | Too many requests (login rate limit exceeded for this username) | +| `503` | DB or downstream service unavailable | Zod validation failures return `400` with a structured body from `@hono/zod-validator` rather than the `{ "error": "..." }` envelope. Exception: credential and password routes use a `noEchoHook` that always returns `{ "error": "Invalid request" }` to prevent echoing submitted secrets in error details. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index e06b04e..804fcb9 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -88,40 +88,40 @@ familysync/ ### Directory Rationale -| Directory | Purpose | -| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `apps/api/src/routes/` | One file per resource — `events.ts`, `lists.ts`, `me.ts`, `push.ts`, `sse.ts`, `health.ts`, `admin.ts`, `setup.ts`, `authMode.ts`, `localAuth.ts` | -| `apps/api/src/broker/` | All CalDAV I/O: `client.ts` (tsdav factory), `sync.ts` (REPORT→DB), `poller.ts` (5-min ctag check), `outboxWorker.ts` (async write-back), `expand.ts` (RRULE expansion), `write.ts` (PUT/DELETE), `vevent.ts` (ICS builder), `crypto.ts` (AES-256-GCM for app passwords) | +| Directory | Purpose | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `apps/api/src/routes/` | One file per resource — `events.ts`, `lists.ts`, `me.ts`, `push.ts`, `sse.ts`, `health.ts`, `admin.ts`, `setup.ts`, `authMode.ts`, `localAuth.ts` | +| `apps/api/src/broker/` | All CalDAV I/O: `client.ts` (tsdav factory), `sync.ts` (REPORT→DB), `poller.ts` (5-min ctag check), `outboxWorker.ts` (async write-back), `expand.ts` (RRULE expansion), `write.ts` (PUT/DELETE), `vevent.ts` (ICS builder), `crypto.ts` (AES-256-GCM for app passwords) | | `apps/api/src/auth/` | `middleware.ts` (re-exports `@hono/oidc-auth`), `devBypass.ts` (DEV_AUTH_BYPASS inject), `localAuthMiddleware.ts` (local-session cookie → user), `localCredentials.ts` (scrypt hash/verify), `localSession.ts` (JWT cookie issue/verify/clear), `oidcConfig.ts` (env+DB fallback for OIDC config), `linkNonceStore.ts` (single-use OIDC-link nonces), `linkOidc.ts` (bind OIDC identity to local user), `persistSessionCookie.ts` (session lifetime extension), `user.ts` (upsert on first OIDC login) | -| `apps/api/src/db/` | `schema.ts` (Drizzle `mysqlTable` definitions), `client.ts` (mysql2 pool), `migrations/` (drizzle-kit output) | -| `apps/api/src/lib/` | Stateless helpers: `listEmitter.ts` (EventEmitter fan-out), `listChangeDispatcher.ts`, `eventChangeDispatcher.ts`, `pushDispatcher.ts` (VAPID send), `pushCoalescer.ts`, `listAccess.ts`, `rank.ts` (fractional indexing), `bootGuards.ts` (startup safety assertions), `requireAdmin.ts` (admin-role guard), `setupGuard.ts` (isSetupLocked check) | -| `apps/pwa/src/api/` | Thin typed fetch wrappers — `client.ts` (events, me, sync-status, auth-mode, local login/logout), `listsClient.ts` (lists and items) | -| `apps/pwa/src/store/` | `calendarStore.ts` and `listsStore.ts` — Zustand UI-only state (no server data) | -| `apps/pwa/src/hooks/` | `useListSSE.ts` (bounded-backoff EventSource), `usePushSubscription.ts` (VAPID subscribe) | +| `apps/api/src/db/` | `schema.ts` (Drizzle `mysqlTable` definitions), `client.ts` (mysql2 pool), `migrations/` (drizzle-kit output) | +| `apps/api/src/lib/` | Stateless helpers: `listEmitter.ts` (EventEmitter fan-out), `listChangeDispatcher.ts`, `eventChangeDispatcher.ts`, `pushDispatcher.ts` (VAPID send), `pushCoalescer.ts`, `listAccess.ts`, `rank.ts` (fractional indexing), `bootGuards.ts` (startup safety assertions), `requireAdmin.ts` (admin-role guard), `setupGuard.ts` (isSetupLocked check) | +| `apps/pwa/src/api/` | Thin typed fetch wrappers — `client.ts` (events, me, sync-status, auth-mode, local login/logout), `listsClient.ts` (lists and items) | +| `apps/pwa/src/store/` | `calendarStore.ts` and `listsStore.ts` — Zustand UI-only state (no server data) | +| `apps/pwa/src/hooks/` | `useListSSE.ts` (bounded-backoff EventSource), `usePushSubscription.ts` (VAPID subscribe) | --- ## Key Abstractions -| Abstraction | File | Description | -| ------------------------------------------ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `app` (Hono) | `apps/api/src/index.ts` | Root Hono app; mounts all routes and serves the PWA static build | -| Drizzle schema | `apps/api/src/db/schema.ts` | Single source of truth for all table definitions (`users`, `memberCredentials`, `localCredentials`, `calendars`, `calendarEvents`, `calendarOutbox`, `lists`, `listShares`, `listItems`, `pushSubscriptions`, `appConfig`) | -| `syncCalendar` | `apps/api/src/broker/sync.ts` | REPORT → ical.js parse → `onDuplicateKeyUpdate` upsert into MariaDB | -| `expandOccurrences` | `apps/api/src/broker/expand.ts` | Server-side RRULE expansion using `ical.js` + `rrule`; never runs in the browser | -| `CalendarOccurrence` | `apps/api/src/broker/expand.ts` | Wire type for a single concrete event occurrence; mirrored in the PWA's `api/client.ts` | -| `calendarOutbox` table | `apps/api/src/db/schema.ts` | Transactional outbox pattern — CalDAV writes are enqueued here and drained asynchronously | -| `runOutboxDrain` | `apps/api/src/broker/outboxWorker.ts` | Drains pending outbox rows every 15s; handles retry backoff, 412 conflict, dead-lettering, and edit-as-move ordering | -| `publishListEvent` / `subscribeListEvents` | `apps/api/src/lib/listEmitter.ts` | In-process EventEmitter fan-out keyed per list; SSE route subscribes on open and unsubscribes on disconnect | -| `dispatchPush` | `apps/api/src/lib/pushDispatcher.ts` | Centralised VAPID-signed push sender; handles 410/404 subscription pruning | -| `issueLocalSessionCookie` / `verifyLocalSessionCookie` | `apps/api/src/auth/localSession.ts` | Issues and verifies the `local-session` JWT cookie used by local username/password auth | -| `localAuthMiddleware` | `apps/api/src/auth/localAuthMiddleware.ts` | Reads `local-session` cookie → populates `c.get('user')`; no-op passthrough when cookie absent (OIDC guard fires for unauthenticated requests) | -| `linkOidcToUser` / `OidcLinkConflictError` | `apps/api/src/auth/linkOidc.ts` | Binds an OIDC iss+sub to an existing local user; throws `OidcLinkConflictError` on identity collision | -| `localCredentials` table | `apps/api/src/db/schema.ts` | Per-member local login credentials (scrypt PHC hash); a row exists iff the member can log in with username/password | -| `appConfig` table | `apps/api/src/db/schema.ts` | Key/value store for setup wizard output (OIDC config, VAPID public key, setup_complete flag) | -| `isSetupLocked` | `apps/api/src/lib/setupGuard.ts` | Returns true when the first-run wizard is complete; setup mutation routes call this as their first guard | -| `SessionExpiredError` | `apps/pwa/src/api/client.ts` | Typed error thrown by all fetch wrappers on 401/opaqueredirect; global `QueryCache` handler arms the session-expiry interstitial | -| Zustand stores | `apps/pwa/src/store/` | UI-only ephemeral state (open panels, selected date, active tab); server state always in TanStack Query | +| Abstraction | File | Description | +| ------------------------------------------------------ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `app` (Hono) | `apps/api/src/index.ts` | Root Hono app; mounts all routes and serves the PWA static build | +| Drizzle schema | `apps/api/src/db/schema.ts` | Single source of truth for all table definitions (`users`, `memberCredentials`, `localCredentials`, `calendars`, `calendarEvents`, `calendarOutbox`, `lists`, `listShares`, `listItems`, `pushSubscriptions`, `appConfig`) | +| `syncCalendar` | `apps/api/src/broker/sync.ts` | REPORT → ical.js parse → `onDuplicateKeyUpdate` upsert into MariaDB | +| `expandOccurrences` | `apps/api/src/broker/expand.ts` | Server-side RRULE expansion using `ical.js` + `rrule`; never runs in the browser | +| `CalendarOccurrence` | `apps/api/src/broker/expand.ts` | Wire type for a single concrete event occurrence; mirrored in the PWA's `api/client.ts` | +| `calendarOutbox` table | `apps/api/src/db/schema.ts` | Transactional outbox pattern — CalDAV writes are enqueued here and drained asynchronously | +| `runOutboxDrain` | `apps/api/src/broker/outboxWorker.ts` | Drains pending outbox rows every 15s; handles retry backoff, 412 conflict, dead-lettering, and edit-as-move ordering | +| `publishListEvent` / `subscribeListEvents` | `apps/api/src/lib/listEmitter.ts` | In-process EventEmitter fan-out keyed per list; SSE route subscribes on open and unsubscribes on disconnect | +| `dispatchPush` | `apps/api/src/lib/pushDispatcher.ts` | Centralised VAPID-signed push sender; handles 410/404 subscription pruning | +| `issueLocalSessionCookie` / `verifyLocalSessionCookie` | `apps/api/src/auth/localSession.ts` | Issues and verifies the `local-session` JWT cookie used by local username/password auth | +| `localAuthMiddleware` | `apps/api/src/auth/localAuthMiddleware.ts` | Reads `local-session` cookie → populates `c.get('user')`; no-op passthrough when cookie absent (OIDC guard fires for unauthenticated requests) | +| `linkOidcToUser` / `OidcLinkConflictError` | `apps/api/src/auth/linkOidc.ts` | Binds an OIDC iss+sub to an existing local user; throws `OidcLinkConflictError` on identity collision | +| `localCredentials` table | `apps/api/src/db/schema.ts` | Per-member local login credentials (scrypt PHC hash); a row exists iff the member can log in with username/password | +| `appConfig` table | `apps/api/src/db/schema.ts` | Key/value store for setup wizard output (OIDC config, VAPID public key, setup_complete flag) | +| `isSetupLocked` | `apps/api/src/lib/setupGuard.ts` | Returns true when the first-run wizard is complete; setup mutation routes call this as their first guard | +| `SessionExpiredError` | `apps/pwa/src/api/client.ts` | Typed error thrown by all fetch wrappers on 401/opaqueredirect; global `QueryCache` handler arms the session-expiry interstitial | +| Zustand stores | `apps/pwa/src/store/` | UI-only ephemeral state (open panels, selected date, active tab); server state always in TanStack Query | --- @@ -234,21 +234,21 @@ routes/setup.ts ──→ db (app_config) ## Infrastructure -| Component | Technology | -| -------------------- | ------------------------------------------------------------------------------------------------------------------- | -| Runtime | Node.js 22 LTS | -| HTTP framework | Hono 4.x (`@hono/node-server`) | -| Database | MariaDB 11 (Docker volume) | -| ORM | Drizzle ORM 0.45.x (`mysql2` dialect) | -| Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE; optional when local auth is enabled | +| Component | Technology | +| -------------------- | --------------------------------------------------------------------------------------------------------------------------- | +| Runtime | Node.js 22 LTS | +| HTTP framework | Hono 4.x (`@hono/node-server`) | +| Database | MariaDB 11 (Docker volume) | +| ORM | Drizzle ORM 0.45.x (`mysql2` dialect) | +| Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE; optional when local auth is enabled | | Session middleware | `@hono/oidc-auth` (OIDC session — storage-less signed JWT cookies) + custom `localSession.ts` (local-auth HS256 JWT cookie) | -| Calendar source | Fastmail CalDAV (`caldav.fastmail.com`) — read via `tsdav`, write via transactional outbox | -| Calendar parsing | `ical.js` (VCALENDAR/VEVENT parse) + `rrule` (RRULE expansion) | -| App password storage | AES-256-GCM encrypted in `member_credentials.encrypted_password` | -| Local auth storage | scrypt PHC hash in `local_credentials.password_hash`; session signed with `LOCAL_SESSION_SECRET` env var | -| Push notifications | `web-push` (VAPID) → APNs (iOS) / FCM (Android) | -| Live list sync | In-process Node.js `EventEmitter` → SSE (`text/event-stream`) | -| Redis | Present in stack (image: `redis:7-alpine`); not used in current runtime (reserved for future multi-process pub/sub) | -| PWA | React 19 + Vite 8 + `vite-plugin-pwa` (Workbox `injectManifest` mode) | -| Networking | Pangolin/Newt tunnel — no open ports; split-DNS internal domain | -| Deployment | Docker Compose on Unraid; single `api` container serves both the API and the PWA static build | +| Calendar source | Fastmail CalDAV (`caldav.fastmail.com`) — read via `tsdav`, write via transactional outbox | +| Calendar parsing | `ical.js` (VCALENDAR/VEVENT parse) + `rrule` (RRULE expansion) | +| App password storage | AES-256-GCM encrypted in `member_credentials.encrypted_password` | +| Local auth storage | scrypt PHC hash in `local_credentials.password_hash`; session signed with `LOCAL_SESSION_SECRET` env var | +| Push notifications | `web-push` (VAPID) → APNs (iOS) / FCM (Android) | +| Live list sync | In-process Node.js `EventEmitter` → SSE (`text/event-stream`) | +| Redis | Present in stack (image: `redis:7-alpine`); not used in current runtime (reserved for future multi-process pub/sub) | +| PWA | React 19 + Vite 8 + `vite-plugin-pwa` (Workbox `injectManifest` mode) | +| Networking | Pangolin/Newt tunnel — no open ports; split-DNS internal domain | +| Deployment | Docker Compose on Unraid; single `api` container serves both the API and the PWA static build | diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index ef1bab1..82d0b24 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -10,14 +10,14 @@ All runtime configuration is supplied via environment variables. There are no JS ### Database -| Variable | Required | Default | Description | -| ------------------ | ------------ | ------------ | ------------------------------------------------------------------------------------------------------------------------ | -| `DB_HOST` | Yes | `localhost` | MariaDB hostname. Use `mariadb` inside Docker Compose; use `localhost` (or `127.0.0.1`) for host-side dev runs. | -| `DB_PORT` | No | `3306` | MariaDB port. | -| `DB_USER` | No | `familysync` | Database user. | -| `DB_PASSWORD` | **Required** | _(none)_ | Database password. Also used by the `mariadb` service as `MARIADB_PASSWORD`. | -| `DB_NAME` | No | `familysync` | Database name. | -| `DB_ROOT_PASSWORD` | **Required** | _(none)_ | MariaDB root password. Used only by the `mariadb` Docker service (`MARIADB_ROOT_PASSWORD`). Not read by the API process. | +| Variable | Required | Default | Description | +| ------------------ | ------------ | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `DB_HOST` | Yes | `localhost` | MariaDB hostname. Use `mariadb` inside Docker Compose; use `localhost` (or `127.0.0.1`) for host-side dev runs. | +| `DB_PORT` | No | `3306` | MariaDB port. | +| `DB_USER` | No | `familysync` | Database user. | +| `DB_PASSWORD` | **Required** | _(none)_ | Database password. Also used by the `mariadb` service as `MARIADB_PASSWORD`. | +| `DB_NAME` | No | `familysync` | Database name. | +| `DB_ROOT_PASSWORD` | **Required** | _(none)_ | MariaDB root password. Used only by the `mariadb` Docker service (`MARIADB_ROOT_PASSWORD`). Not read by the API process. | | `DB_ROOT_USER` | No | `root` | MariaDB root username. Read only by `apps/api/test/global-setup.ts` during local test provisioning. Never used by the API or Docker Compose in production. | Five of these variables — `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_PORT` — are read by `drizzle.config.ts` when running migrations (`db:generate` / `db:migrate`) and by the API process to build its connection pool. `DB_ROOT_PASSWORD` is **not** read by either; it is consumed only by the `mariadb` Docker service. `DB_ROOT_USER` is only used by the local Vitest global setup to create and grant the `familysync_test` database. @@ -55,10 +55,10 @@ Five of these variables — `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_ These variables govern the stateless local-auth path introduced in Phase 19. Local auth issues a separate `local-session` JWT cookie (distinct from `oidc-auth`) signed with `LOCAL_SESSION_SECRET`. -| Variable | Required | Default | Description | -| ----------------------- | ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Variable | Required | Default | Description | +| ----------------------- | ------------------------- | -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | `LOCAL_SESSION_SECRET` | **Required** (non-bypass) | _(none)_ | 32+ character secret used to sign and verify `local-session` JWT cookies (HS256). Generate with `openssl rand -base64 32`. The API refuses to start with a fatal error if this is absent or shorter than 32 characters, unless `DEV_AUTH_BYPASS=true`. | -| `LOCAL_SESSION_EXPIRES` | No | `86400` | `local-session` cookie `Max-Age` in seconds (default 1 day). Mirrors `OIDC_AUTH_EXPIRES` but applies to the local-auth cookie. Malformed (non-numeric) values silently fall back to the default. Source: `apps/api/src/auth/localSession.ts`. | +| `LOCAL_SESSION_EXPIRES` | No | `86400` | `local-session` cookie `Max-Age` in seconds (default 1 day). Mirrors `OIDC_AUTH_EXPIRES` but applies to the local-auth cookie. Malformed (non-numeric) values silently fall back to the default. Source: `apps/api/src/auth/localSession.ts`. | **Security note:** `LOCAL_SESSION_SECRET` must be a distinct value from `OIDC_AUTH_SECRET`. Both are JWT signing keys, but they govern different cookies and must not be shared. @@ -90,10 +90,10 @@ npx web-push generate-vapid-keys --json ### Runtime Mode -| Variable | Required | Default | Description | -| ----------------- | -------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| `NODE_ENV` | No | _(not set)_ | Set to `production` in the production Docker Compose. When `production`, the dev-auth bypass is unconditionally disabled regardless of `DEV_AUTH_BYPASS`. | -| `DEV_AUTH_BYPASS` | No | _(not set)_ | Set to `true` to bypass OIDC authentication for local development without a live Authelia instance. **Only active when `NODE_ENV !== 'production'`.** The production `docker-compose.yml` must never include this variable. | +| Variable | Required | Default | Description | +| ----------------- | -------- | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | +| `NODE_ENV` | No | _(not set)_ | Set to `production` in the production Docker Compose. When `production`, the dev-auth bypass is unconditionally disabled regardless of `DEV_AUTH_BYPASS`. | +| `DEV_AUTH_BYPASS` | No | _(not set)_ | Set to `true` to bypass OIDC authentication for local development without a live Authelia instance. **Only active when `NODE_ENV !== 'production'`.** The production `docker-compose.yml` must never include this variable. | | `TZ` | No | _(not set)_ | IANA timezone identifier (e.g. `America/Toronto`) used as the server-side fallback for the household timezone when no value is stored in `app_config`. The full fallback chain is: stored DB value → `TZ` env → `Intl.DateTimeFormat().resolvedOptions().timeZone`. Empty or whitespace values are ignored. Source: `apps/api/src/lib/householdTimezone.ts`. | --- @@ -102,11 +102,11 @@ npx web-push generate-vapid-keys --json These variables are never needed in production and should not appear in the production `.env`. -| Variable | Scope | Default | Description | -| ----------------------- | ---------------------- | ------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | -| `FASTMAIL_EMAIL` | Dev spike script only | _(none)_ | Fastmail account email. Read only by `apps/api/src/broker/spike.ts`, a standalone dev script for enumerating CalDAV collections. Not imported by the API or Docker image. | -| `FASTMAIL_APP_PASSWORD` | Dev spike script only | _(none)_ | Fastmail app password. Read only by `apps/api/src/broker/spike.ts`. **Never logged.** Not used by the API in any environment. | -| `PLAYWRIGHT_BASE_URL` | E2E tests only | `http://localhost:5173` | Base URL for Playwright e2e tests. Overridden to `http://127.0.0.1:5173` in CI to avoid IPv6 resolution failures. Source: `apps/pwa/playwright.config.ts`. | +| Variable | Scope | Default | Description | +| ----------------------- | --------------------- | ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `FASTMAIL_EMAIL` | Dev spike script only | _(none)_ | Fastmail account email. Read only by `apps/api/src/broker/spike.ts`, a standalone dev script for enumerating CalDAV collections. Not imported by the API or Docker image. | +| `FASTMAIL_APP_PASSWORD` | Dev spike script only | _(none)_ | Fastmail app password. Read only by `apps/api/src/broker/spike.ts`. **Never logged.** Not used by the API in any environment. | +| `PLAYWRIGHT_BASE_URL` | E2E tests only | `http://localhost:5173` | Base URL for Playwright e2e tests. Overridden to `http://127.0.0.1:5173` in CI to avoid IPv6 resolution failures. Source: `apps/pwa/playwright.config.ts`. | --- diff --git a/docs/DEVELOPMENT.md b/docs/DEVELOPMENT.md index 5e3813f..be8be82 100644 --- a/docs/DEVELOPMENT.md +++ b/docs/DEVELOPMENT.md @@ -100,19 +100,19 @@ Vite serves the PWA with HMR on the configured dev port. The PWA's API calls tar ### Root workspace scripts -| Command | Description | -| ------------------------ | ------------------------------------------------------------- | -| `pnpm dev:api` | Start API dev watcher (`node --watch dist/index.js`) | -| `pnpm dev:pwa` | Start Vite dev server for the PWA | -| `pnpm build` | Build both `apps/api` (tsc) and `apps/pwa` (tsc + vite build) | -| `pnpm test` | Run API test suite (`vitest run` in `apps/api`) | -| `pnpm test:e2e` | Run Playwright e2e harness (`apps/pwa`) | -| `pnpm lint` | ESLint across all workspaces (`pnpm -r --if-present lint`) | -| `pnpm format` | Reformat all files with Prettier (`prettier --write .`) | -| `pnpm format:check` | Check formatting without writing (`prettier --check .`) | -| `pnpm typecheck` | `tsc --noEmit` in all workspaces | -| `pnpm md:lint` | Markdown lint (`markdownlint-cli2`) across the repo | -| `pnpm generate-secrets` | Generate VAPID and session secret values via `scripts/generate-secrets.mjs` | +| Command | Description | +| ----------------------- | --------------------------------------------------------------------------- | +| `pnpm dev:api` | Start API dev watcher (`node --watch dist/index.js`) | +| `pnpm dev:pwa` | Start Vite dev server for the PWA | +| `pnpm build` | Build both `apps/api` (tsc) and `apps/pwa` (tsc + vite build) | +| `pnpm test` | Run API test suite (`vitest run` in `apps/api`) | +| `pnpm test:e2e` | Run Playwright e2e harness (`apps/pwa`) | +| `pnpm lint` | ESLint across all workspaces (`pnpm -r --if-present lint`) | +| `pnpm format` | Reformat all files with Prettier (`prettier --write .`) | +| `pnpm format:check` | Check formatting without writing (`prettier --check .`) | +| `pnpm typecheck` | `tsc --noEmit` in all workspaces | +| `pnpm md:lint` | Markdown lint (`markdownlint-cli2`) across the repo | +| `pnpm generate-secrets` | Generate VAPID and session secret values via `scripts/generate-secrets.mjs` | ### `apps/api` scripts @@ -157,9 +157,9 @@ pnpm md:lint # Markdown lint (also runs in CI fast-checks) Config: `eslint.config.js` (root, flat ESLint 9 format). The config covers: -- **All `apps/**/*.{ts,tsx}`** — `js.configs.recommended` + `tseslint.configs.recommendedTypeChecked` with `projectService: true` (type-aware rules, auto-discovers all `tsconfig.json` files) -- **`apps/pwa/**/*.{ts,tsx}` additionally** — `eslint-plugin-react` + `eslint-plugin-react-hooks` (React 19 flat config; React Compiler rules disabled — this codebase does not use the Compiler) -- **All `apps/**/*.{ts,tsx}`** — `eslint-plugin-security` (14 of 15 rules at error; `detect-object-injection` disabled due to high false-positive rate on schema-derived numeric keys) +- **All `apps/**/\*.{ts,tsx}`** — `js.configs.recommended`+`tseslint.configs.recommendedTypeChecked`with`projectService: true`(type-aware rules, auto-discovers all`tsconfig.json` files) +- **`apps/pwa/**/\*.{ts,tsx}`additionally** —`eslint-plugin-react`+`eslint-plugin-react-hooks` (React 19 flat config; React Compiler rules disabled — this codebase does not use the Compiler) +- **All `apps/**/\*.{ts,tsx}`** — `eslint-plugin-security`(14 of 15 rules at error;`detect-object-injection` disabled due to high false-positive rate on schema-derived numeric keys) - **Tool configs + test dirs** (`drizzle.config.ts`, `vitest.config.ts`, `apps/api/tests/**`, `apps/pwa/e2e/**`) — type-aware rules disabled via `disableTypeChecked` (these files are outside the main tsconfig projects) - **Prettier integration** — `eslint-config-prettier` last in the config disables all formatting rules that conflict with Prettier @@ -195,13 +195,13 @@ Run `pnpm typecheck` before opening a PR to catch errors that vitest and Vite bu Every PR to `main` runs through `.gitea/workflows/ci.yml`. A `changes` path-filter job determines whether code files changed; the `api` and `harness` jobs are skipped entirely for doc-only PRs (changes only to `.planning/**`, `.gitea/**`, or `*.md` files). -| Job | Runs on | Checks | -| ------------- | ------------------- | ----------------------------------------------------------------------------------------------------------- | -| `fast-checks` | Every PR | `pnpm lint` → `pnpm format:check` → `pnpm md:lint` → `pnpm typecheck` → `pnpm --filter @familysync/pwa test` | -| `api` | Code-change PRs only | DB migrations + `pnpm --filter @familysync/api test` (vitest against a MariaDB 11 service container) | -| `harness` | Code-change PRs only | DB migrations + seed dev user + API build + Playwright e2e (WebKit + Chromium) with `DEV_AUTH_BYPASS=true` | -| `security` | Every PR | Gitleaks secret scan (PR diff); `pnpm audit` (High+Critical blocking) + outdated report on code-change PRs | -| `gate` | Always | Final aggregator — requires `fast-checks` and `security` to succeed; `api` and `harness` may be skipped | +| Job | Runs on | Checks | +| ------------- | -------------------- | ------------------------------------------------------------------------------------------------------------ | +| `fast-checks` | Every PR | `pnpm lint` → `pnpm format:check` → `pnpm md:lint` → `pnpm typecheck` → `pnpm --filter @familysync/pwa test` | +| `api` | Code-change PRs only | DB migrations + `pnpm --filter @familysync/api test` (vitest against a MariaDB 11 service container) | +| `harness` | Code-change PRs only | DB migrations + seed dev user + API build + Playwright e2e (WebKit + Chromium) with `DEV_AUTH_BYPASS=true` | +| `security` | Every PR | Gitleaks secret scan (PR diff); `pnpm audit` (High+Critical blocking) + outdated report on code-change PRs | +| `gate` | Always | Final aggregator — requires `fast-checks` and `security` to succeed; `api` and `harness` may be skipped | All five jobs must pass (or be legitimately skipped) before a PR can merge. See [docs/TESTING.md](TESTING.md) for test suite details. diff --git a/docs/GETTING-STARTED.md b/docs/GETTING-STARTED.md index 76f098c..d444c5d 100644 --- a/docs/GETTING-STARTED.md +++ b/docs/GETTING-STARTED.md @@ -134,6 +134,7 @@ Or set `DB_HOST=localhost` directly in your `.env` for host-side dev. **`[FATAL] LOCAL_SESSION_SECRET is not set or is shorter than 32 characters`** The API refuses to start in non-bypass mode without a valid `LOCAL_SESSION_SECRET`. Either: + - Set `DEV_AUTH_BYPASS=true` in `.env` for local dev (bypass mode exempts the requirement), or - Run `pnpm generate-secrets` and add the generated `LOCAL_SESSION_SECRET` value to `.env`. diff --git a/docs/TESTING.md b/docs/TESTING.md index c8d852e..df4f737 100644 --- a/docs/TESTING.md +++ b/docs/TESTING.md @@ -6,10 +6,10 @@ Both apps use **Vitest** (`^4.1.8`). -| App | Environment | Global setup | Per-file setup | -| ---------- | ----------- | ----------------------------------- | ---------------------------- | -| `apps/api` | `node` | `apps/api/test/global-setup.ts` | `apps/api/test/setup.ts` | -| `apps/pwa` | `jsdom` | — | `apps/pwa/src/test-setup.ts` | +| App | Environment | Global setup | Per-file setup | +| ---------- | ----------- | ------------------------------- | ---------------------------- | +| `apps/api` | `node` | `apps/api/test/global-setup.ts` | `apps/api/test/setup.ts` | +| `apps/pwa` | `jsdom` | — | `apps/pwa/src/test-setup.ts` | **apps/api global setup** (`test/global-setup.ts`) runs once before any test file. Locally it provisions an isolated `familysync_test` database (root connection → `CREATE DATABASE IF NOT EXISTS familysync_test` → GRANT → `drizzle migrate`) and then truncates every table to give each run a clean slate. Under CI (`process.env.CI` truthy) it returns immediately — the CI `api` job provisions its own `familysync` service container via `db:migrate`. @@ -53,11 +53,11 @@ pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts The PWA has a Playwright harness configured in `apps/pwa/playwright.config.ts` with three device profiles: -| Profile | Viewport | Engine | User-Agent | -| --------- | --------- | -------- | ------------------------- | -| `iphone` | 390×844 | WebKit | Mobile Safari (iPhone 14) | -| `pixel` | 412×915 | Chromium | Chrome Android (Pixel 7) | -| `desktop` | 1280×720 | Chromium | Desktop Chrome | +| Profile | Viewport | Engine | User-Agent | +| --------- | -------- | -------- | ------------------------- | +| `iphone` | 390×844 | WebKit | Mobile Safari (iPhone 14) | +| `pixel` | 412×915 | Chromium | Chrome Android (Pixel 7) | +| `desktop` | 1280×720 | Chromium | Desktop Chrome | All profiles block the service worker (`serviceWorkers: 'block'`) so the Workbox SW does not intercept requests during tests. Auth is handled via `DEV_AUTH_BYPASS=true` on the API — never via stored browser state. @@ -232,17 +232,17 @@ The throwaway credentials (`DB_USER=familysync`, `DB_PASSWORD=testpass`) are sco Runs the Playwright mobile and desktop e2e harness (iphone + pixel + desktop) against a runner-hosted dev stack. Skipped for doc-only PRs. -| Step | Detail | -| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | -| MariaDB service | Same `mariadb:11` setup as the `api` job | -| Schema migrations | `pnpm --filter @familysync/api db:migrate` | -| Dev user seed | Inserts `users` row id=1 (`INSERT IGNORE`) for `DEV_AUTH_BYPASS` | -| Local credentials seed | Inserts `local_credentials` row for dev user (username: `devuser`, password: `devpass`) via inline scrypt hash — Phase 19 requirement | -| API build | `pnpm --filter @familysync/api build` (dist/ is gitignored) | -| Playwright install | `npx playwright install --with-deps webkit chromium` (no cache) | -| API start + tests | API started as a background process in the same step as `playwright test` to survive the step boundary; `DEV_AUTH_BYPASS=true`, `NODE_ENV=development` | -| Base URL | `http://127.0.0.1:5173` (not `localhost` — runner resolves `localhost` to `::1` but Vite binds IPv4-only) | -| Artifacts on fail | Traces, screenshots, videos, and HTML report uploaded via `ChristopherHX/gitea-upload-artifact@v4` (standard `upload-artifact` aborts on Gitea) | +| Step | Detail | +| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ | +| MariaDB service | Same `mariadb:11` setup as the `api` job | +| Schema migrations | `pnpm --filter @familysync/api db:migrate` | +| Dev user seed | Inserts `users` row id=1 (`INSERT IGNORE`) for `DEV_AUTH_BYPASS` | +| Local credentials seed | Inserts `local_credentials` row for dev user (username: `devuser`, password: `devpass`) via inline scrypt hash — Phase 19 requirement | +| API build | `pnpm --filter @familysync/api build` (dist/ is gitignored) | +| Playwright install | `npx playwright install --with-deps webkit chromium` (no cache) | +| API start + tests | API started as a background process in the same step as `playwright test` to survive the step boundary; `DEV_AUTH_BYPASS=true`, `NODE_ENV=development` | +| Base URL | `http://127.0.0.1:5173` (not `localhost` — runner resolves `localhost` to `::1` but Vite binds IPv4-only) | +| Artifacts on fail | Traces, screenshots, videos, and HTML report uploaded via `ChristopherHX/gitea-upload-artifact@v4` (standard `upload-artifact` aborts on Gitea) | The API process is started and the Playwright suite invoked within a single CI step. Starting the API in an earlier step causes it to be reaped at the step boundary before Playwright runs. @@ -250,11 +250,11 @@ The API process is started and the Playwright suite invoked within a single CI s Runs secret scanning and dependency audits. Always runs regardless of the `changes` filter (secrets can appear in doc-only commits). Dependency audit and outdated checks run only when code changes are detected. -| Step | Tool/Command | Detail | -| ------------------- | ------------------------------- | -------------------------------------------------------------- | -| Secret scan | `gitleaks` (v8.30.1) | Scans the PR diff range; blocks on any finding | -| Dependency audit | `node scripts/check-audit.mjs` | Blocks on High or Critical severity vulnerabilities | -| Outdated report | `node scripts/check-outdated.mjs` | Advisory only — always exits 0, logged but never gates | +| Step | Tool/Command | Detail | +| ---------------- | --------------------------------- | ------------------------------------------------------ | +| Secret scan | `gitleaks` (v8.30.1) | Scans the PR diff range; blocks on any finding | +| Dependency audit | `node scripts/check-audit.mjs` | Blocks on High or Critical severity vulnerabilities | +| Outdated report | `node scripts/check-outdated.mjs` | Advisory only — always exits 0, logged but never gates | ### `gate` diff --git a/docs/deployment.md b/docs/deployment.md index 0ac909a..dbdfa7d 100644 --- a/docs/deployment.md +++ b/docs/deployment.md @@ -32,13 +32,13 @@ FamilySync uses a self-hosted Gitea Actions runner. Two workflows govern the rel Triggered on every pull request targeting `main`. The workflow runs a `changes` filter job first, then launches the following jobs in parallel: -| Job | Runs when | What it checks | -| ------------- | -------------------- | --------------------------------------------------------------------------------------------------------- | -| `fast-checks` | Always | Lint (`pnpm lint`), format check (`pnpm format:check`), markdown lint (`pnpm md:lint`), typecheck, PWA unit tests | -| `api` | Code-changing PRs only | DB migrations + API integration tests against a live MariaDB service container | -| `harness` | Code-changing PRs only | Full Playwright E2E suite (iPhone + Pixel + desktop profiles) against the compiled API | -| `security` | Always | Secret scan (gitleaks, PR diff); dependency audit and outdated report on code-changing PRs | -| `gate` | Always | Aggregates results — fails if any non-skipped required job did not succeed | +| Job | Runs when | What it checks | +| ------------- | ---------------------- | ----------------------------------------------------------------------------------------------------------------- | +| `fast-checks` | Always | Lint (`pnpm lint`), format check (`pnpm format:check`), markdown lint (`pnpm md:lint`), typecheck, PWA unit tests | +| `api` | Code-changing PRs only | DB migrations + API integration tests against a live MariaDB service container | +| `harness` | Code-changing PRs only | Full Playwright E2E suite (iPhone + Pixel + desktop profiles) against the compiled API | +| `security` | Always | Secret scan (gitleaks, PR diff); dependency audit and outdated report on code-changing PRs | +| `gate` | Always | Aggregates results — fails if any non-skipped required job did not succeed | The `api` and `harness` jobs are **skipped on doc-only PRs** (changes confined to `.gitea/**`, `.planning/**`, or `*.md` files). A doc-only PR must pass `fast-checks` and `security`; the heavy jobs are not required. @@ -62,6 +62,7 @@ The current milestone prefix (`v1.1`) is set in the `MILESTONE` env var at the t The immutable `:-` tag is pushed first. `:latest` is only moved after the immutable tag has landed, so a failed second push can never leave `:latest` advanced without a corresponding rollback tag. Before pushing, the workflow runs two image hygiene assertions: + 1. **Static assertions** — verifies `.dockerignore` contains all required exclusion patterns and that the build targets `--target production`. 2. **Boot-smoke** — starts the image with `NODE_ENV=production` and `DEV_AUTH_BYPASS=true` and asserts that it refuses to start (confirming the D-08 guard fires in the shipped image).