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
This commit is contained in:
Lucas Berger
2026-06-18 17:39:00 -04:00
parent 9e6b004541
commit 9b62887f0f
17 changed files with 334 additions and 322 deletions
+10 -10
View File
@@ -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. > Generated by GSD from session_analysis. Run `/gsd-profile-user` to update.
| Dimension | Rating | Confidence | | Dimension | Rating | Confidence |
|-----------|--------|------------| | -------------- | --------------------- | ---------- |
| Communication | conversational | MEDIUM | | Communication | conversational | MEDIUM |
| Decisions | fast-intuitive | MEDIUM | | Decisions | fast-intuitive | MEDIUM |
| Explanations | concise | MEDIUM | | Explanations | concise | MEDIUM |
| Debugging | diagnostic | MEDIUM | | Debugging | diagnostic | MEDIUM |
| UX Philosophy | design-conscious | MEDIUM | | UX Philosophy | design-conscious | MEDIUM |
| Vendor Choices | opinionated | LOW | | Vendor Choices | opinionated | LOW |
| Frustrations | instruction-adherence | MEDIUM | | Frustrations | instruction-adherence | MEDIUM |
| Learning | self-directed | MEDIUM | | Learning | self-directed | MEDIUM |
**Directives:** **Directives:**
+7 -7
View File
@@ -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: Every PR to `main` must pass four jobs before it can merge:
| Job | What it runs | | Job | What it runs |
| -------------------- | -------------------------------------------------------------------------- | | ------------------ | ---------------------------------------------------------------------------------- |
| `CI / fast-checks` | `pnpm lint`, `pnpm format:check`, `pnpm md:lint`, `pnpm typecheck`, PWA unit tests | | `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 / 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 / harness` | Playwright end-to-end harness (WebKit iPhone + Chromium Pixel) |
| `CI / security` | Gitleaks secret scan (all PRs) + `pnpm audit` + outdated report (code PRs) | | `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 | | `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`. `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`.
+26 -26
View File
@@ -125,27 +125,27 @@ Migration files are written to `src/db/migrations/` and checked into source cont
## Environment variables ## Environment variables
| Variable | Required | Description | | Variable | Required | Description |
| ----------------------------- | ------------------- | ---------------------------------------------------------------------------------- | | ----------------------------- | -------------------- | ---------------------------------------------------------------------------------- |
| `DB_HOST` | Yes | MariaDB host | | `DB_HOST` | Yes | MariaDB host |
| `DB_USER` | Yes | MariaDB user | | `DB_USER` | Yes | MariaDB user |
| `DB_PASSWORD` | Yes | MariaDB password | | `DB_PASSWORD` | Yes | MariaDB password |
| `DB_NAME` | Yes | MariaDB database name | | `DB_NAME` | Yes | MariaDB database name |
| `DB_PORT` | No (default `3306`) | MariaDB port | | `DB_PORT` | No (default `3306`) | MariaDB port |
| `OIDC_ISSUER` | Yes (production) | Authelia issuer URL | | `OIDC_ISSUER` | Yes (production) | Authelia issuer URL |
| `OIDC_CLIENT_ID` | Yes (production) | OIDC client ID | | `OIDC_CLIENT_ID` | Yes (production) | OIDC client ID |
| `OIDC_CLIENT_SECRET` | Yes (production) | OIDC client secret | | `OIDC_CLIENT_SECRET` | Yes (production) | OIDC client secret |
| `OIDC_AUTH_EXTERNAL_URL` | Yes (production) | External-facing URL for redirect_uri behind Pangolin tunnel | | `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) | | `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_SUBJECT` | Yes (push) | `mailto:` or `https:` operator identifier |
| `VAPID_PUBLIC_KEY` | Yes (push) | VAPID public key | | `VAPID_PUBLIC_KEY` | Yes (push) | VAPID public key |
| `VAPID_PRIVATE_KEY` | Yes (push) | VAPID private 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 | | `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_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 | | `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 | | `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 | | `NODE_ENV` | No | Set to `production` to enforce OIDC unconditionally |
| `TZ` | No | IANA timezone fallback when household_timezone is not set in app_config | | `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. > **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: The API supports two non-exclusive auth modes, determined at startup:
| Mode | When active | How it works | | 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`. | | **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`. | | **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. | | **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. `GET /api/auth/mode` returns `{ localEnabled, oidcEnabled }` before authentication — the PWA uses this to decide which login form to show.
+41 -45
View File
@@ -227,57 +227,53 @@ const updateMemberSchema = z.object({
isAdmin: z.boolean().optional(), isAdmin: z.boolean().optional(),
}); });
adminRouter.patch( adminRouter.patch('/members/:id', zValidator('json', updateMemberSchema, noEchoHook), async (c) => {
'/members/:id', const targetId = parsePositiveIntParam(c.req.param('id'));
zValidator('json', updateMemberSchema, noEchoHook), if (targetId === null) {
async (c) => { return c.json({ error: 'Invalid member id' }, 400);
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'); const { displayName, isAdmin } = c.req.valid('json');
// T-20-04: NEVER log request body // T-20-04: NEVER log request body
// Verify the target user exists (404 if not) // Verify the target user exists (404 if not)
const [target] = await db const [target] = await db
.select({ id: users.id, isAdmin: users.isAdmin }) .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<number>`COUNT(*)` })
.from(users) .from(users)
.where(eq(users.id, targetId)) .where(eq(users.isAdmin, true));
.limit(1); if (Number(count) <= 1) {
return c.json({ error: 'Cannot remove the last admin' }, 409);
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) // Build a partial set() from whichever fields are present
if (isAdmin === false && target.isAdmin) { const updates: { displayName?: string; isAdmin?: boolean } = {};
const [{ count }] = await db if (displayName !== undefined) updates.displayName = displayName;
.select({ count: sql<number>`COUNT(*)` }) if (isAdmin !== undefined) updates.isAdmin = isAdmin;
.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 try {
const updates: { displayName?: string; isAdmin?: boolean } = {}; await db.update(users).set(updates).where(eq(users.id, targetId));
if (displayName !== undefined) updates.displayName = displayName; return c.json({ ok: true }, 200);
if (isAdmin !== undefined) updates.isAdmin = isAdmin; } catch (err) {
console.error(
try { '[admin/PATCH /members/:id] Unexpected error:',
await db.update(users).set(updates).where(eq(users.id, targetId)); err instanceof Error ? err.message : String(err),
return c.json({ ok: true }, 200); );
} catch (err) { return c.json({ error: 'Service unavailable' }, 503);
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 // POST /api/admin/members/:id/password
+3 -1
View File
@@ -1094,7 +1094,9 @@ describe('PATCH /api/admin/members/:id', () => {
// GET /members should reflect the updated displayName // GET /members should reflect the updated displayName
const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members')); const getRes = await app.fetch(jsonRequest('GET', '/api/admin/members'));
expect(getRes.status).toBe(200); 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); const updated = getBody.members.find((m) => m.id === memberId);
expect(updated).toBeDefined(); expect(updated).toBeDefined();
expect(updated!.displayName).toBe('New Name'); expect(updated!.displayName).toBe('New Name');
+11 -11
View File
@@ -32,17 +32,17 @@ The API backend must also be running for most features. See [GETTING-STARTED.md]
## Scripts ## Scripts
| Command | What it does | | Command | What it does |
| ------------------------------------------------ | ------------------------------------------------------------------------- | | ----------------------------------------------- | ------------------------------------------------------------- |
| `pnpm --filter @familysync/pwa dev` | Start Vite dev server (HMR) | | `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 build` | Type-check then build production bundle (`tsc && vite build`) |
| `pnpm --filter @familysync/pwa preview` | Serve the production build locally | | `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 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 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` | 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` | 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:ui` | Open the Playwright UI runner |
| `pnpm --filter @familysync/pwa test:e2e:headed` | Run Playwright tests in a headed browser | | `pnpm --filter @familysync/pwa test:e2e:headed` | Run Playwright tests in a headed browser |
## Source layout ## Source layout
+2 -2
View File
@@ -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({ export default defineConfig({
preset: { preset: {
@@ -7,4 +7,4 @@ export default defineConfig({
images: ['public/logo.svg'], images: ['public/logo.svg'],
// Do NOT set overrideManifestIcons: true — the manifest is maintained by hand // 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. // in vite.config.ts (plan 17-04); auto-override would stomp the explicit entries.
}) });
+7 -1
View File
@@ -29,7 +29,13 @@ import { usePushSubscription } from '../hooks/usePushSubscription.js';
import { useIsPhone } from '../hooks/useIsPhone.js'; import { useIsPhone } from '../hooks/useIsPhone.js';
import { useFocusTrap } from '../hooks/useFocusTrap.js'; import { useFocusTrap } from '../hooks/useFocusTrap.js';
import { InstructionSheet } from './InstructionSheet.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 // CR-04: fetch VAPID key (from sessionStorage cache if available) for the
// tap-gated subscribe() path. Same logic as PushPermissionPrompt. // tap-gated subscribe() path. Same logic as PushPermissionPrompt.
+1 -1
View File
@@ -14,7 +14,7 @@
*/ */
:root, :root,
[data-theme="light"] { [data-theme='light'] {
/* /*
* BASE SURFACE / BORDER / TEXT PALETTE * BASE SURFACE / BORDER / TEXT PALETTE
* */ * */
+6 -1
View File
@@ -42,7 +42,12 @@ export default defineConfig({
icons: [ icons: [
{ src: '/icon-192.png', sizes: '192x192', type: 'image/png' }, { src: '/icon-192.png', sizes: '192x192', type: 'image/png' },
{ src: '/icon-512.png', sizes: '512x512', 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',
},
], ],
}, },
}), }),
+98 -97
View File
@@ -25,54 +25,54 @@ No API key or `Authorization` header is used. Credentials are never included in
## Endpoints Overview ## Endpoints Overview
| Method | Path | Auth | Description | | Method | Path | Auth | Description |
| ------ | ------------------------------------- | --------- | -------------------------------------------------- | | ------ | --------------------------------- | ------- | --------------------------------------------------- |
| GET | `/health` | None | DB liveness check | | GET | `/health` | None | DB liveness check |
| GET | `/callback` | None | OIDC authorization-code exchange | | GET | `/callback` | None | OIDC authorization-code exchange |
| GET | `/api/setup/status` | None | Setup wizard completion status | | 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/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/db` | None | Validate DB connectivity (wizard step) |
| POST | `/api/setup/validate/oidc` | None | Validate OIDC issuer discovery (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/validate/vapid` | None | Validate VAPID key pair (wizard step) |
| POST | `/api/setup/credential` | None | Store first admin's Fastmail credential (wizard) | | POST | `/api/setup/credential` | None | Store first admin's Fastmail credential (wizard) |
| POST | `/api/setup/complete` | None | Lock the setup wizard | | POST | `/api/setup/complete` | None | Lock the setup wizard |
| GET | `/api/auth/mode` | None | Auth mode discovery (local vs OIDC enabled) | | 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/login` | None | Local username+password login |
| POST | `/api/auth/local/logout` | None | Clear local session cookie | | 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/auth/local/logout` | None | Clear local session cookie (browser redirect alias) |
| GET | `/api/login` | OIDC | OIDC login entry point, redirects to `/` | | GET | `/api/login` | OIDC | OIDC login entry point, redirects to `/` |
| GET | `/api/me` | Session | Current user identity, role, and setup status | | 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/credential` | Session | Member self-service Fastmail credential update |
| POST | `/api/me/password` | Session | Member self-change local password | | POST | `/api/me/password` | Session | Member self-change local password |
| POST | `/api/me/link-oidc` | Session | Initiate OIDC identity link for a local user | | POST | `/api/me/link-oidc` | Session | Initiate OIDC identity link for a local user |
| GET | `/api/events` | Session | Windowed calendar occurrences | | GET | `/api/events` | Session | Windowed calendar occurrences |
| POST | `/api/events/create` | Session | Enqueue a new event write | | POST | `/api/events/create` | Session | Enqueue a new event write |
| PATCH | `/api/events/:uid/edit` | Session | Enqueue an event update | | PATCH | `/api/events/:uid/edit` | Session | Enqueue an event update |
| DELETE | `/api/events/:uid` | Session | Enqueue an event delete | | DELETE | `/api/events/:uid` | Session | Enqueue an event delete |
| GET | `/api/events/sync-status` | Session | Outbox status for a UID | | 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/events/writable-calendars` | Session | Calendars the member can write to |
| GET | `/api/lists` | Session | All lists accessible to the member | | GET | `/api/lists` | Session | All lists accessible to the member |
| POST | `/api/lists` | Session | Create a list | | POST | `/api/lists` | Session | Create a list |
| PATCH | `/api/lists/:id` | Session | Update list name or sharing | | PATCH | `/api/lists/:id` | Session | Update list name or sharing |
| DELETE | `/api/lists/:id` | Session | Delete a list (owner only) | | DELETE | `/api/lists/:id` | Session | Delete a list (owner only) |
| GET | `/api/lists/:id/items` | Session | All items in a list | | GET | `/api/lists/:id/items` | Session | All items in a list |
| POST | `/api/lists/:id/items` | Session | Add an item to 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 | | PATCH | `/api/list-items/:itemId` | Session | Update a single list item field |
| DELETE | `/api/list-items/:itemId` | Session | Delete a list item | | DELETE | `/api/list-items/:itemId` | Session | Delete a list item |
| GET | `/api/sse/heartbeat` | Session | SSE heartbeat stream | | GET | `/api/sse/heartbeat` | Session | SSE heartbeat stream |
| GET | `/api/sse/lists` | Session | Scoped live-list SSE stream | | GET | `/api/sse/lists` | Session | Scoped live-list SSE stream |
| GET | `/api/push/vapid-public-key` | Session | VAPID public key for push subscribe | | GET | `/api/push/vapid-public-key` | Session | VAPID public key for push subscribe |
| POST | `/api/push/subscription` | Session | Register a push subscription | | POST | `/api/push/subscription` | Session | Register a push subscription |
| DELETE | `/api/push/subscription` | Session | Remove push subscriptions for caller | | DELETE | `/api/push/subscription` | Session | Remove push subscriptions for caller |
| GET | `/api/admin/members` | Admin | List members with credential status | | GET | `/api/admin/members` | Admin | List members with credential status |
| POST | `/api/admin/members` | Admin | Create a new local member | | 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/members/:id/password` | Admin | Reset a local member's password |
| POST | `/api/admin/credentials` | Admin | Validate and store a member's Fastmail credential | | POST | `/api/admin/credentials` | Admin | Validate and store a member's Fastmail credential |
| GET | `/api/admin/calendars` | Admin | List synced calendars | | GET | `/api/admin/calendars` | Admin | List synced calendars |
| PUT | `/api/admin/calendars/:id/shared` | Admin | Designate the shared family calendar | | PUT | `/api/admin/calendars/:id/shared` | Admin | Designate the shared family calendar |
| GET | `/api/admin/config/timezone` | Admin | Get household timezone | | GET | `/api/admin/config/timezone` | Admin | Get household timezone |
| PUT | `/api/admin/config/timezone` | Admin | Set 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 | | 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 | | Field | Type | Required | Constraints |
| ---------------- | ------ | -------- | ------------------------------ | | ---------------- | ------ | -------- | ----------------------------- |
| `oidcIssuer` | string | Yes | HTTPS URL | | `oidcIssuer` | string | Yes | HTTPS URL |
| `oidcClientId` | string | Yes | 1256 characters | | `oidcClientId` | string | Yes | 1256 characters |
| `vapidPublicKey` | string | Yes | 1512 characters | | `vapidPublicKey` | string | Yes | 1512 characters |
| `appExternalUrl` | string | Yes | HTTPS URL, max 512 characters | | `appExternalUrl` | string | Yes | HTTPS URL, max 512 characters |
**Response 200** **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 | | `fastmailEmail` | string | Yes | Valid email, max 256 characters |
| `appPassword` | string | Yes | 1500 characters | | `appPassword` | string | Yes | 1500 characters |
Zod validation errors for this route never echo received values (the app password is never included in error responses). 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. 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): Rate limiting is per-username (not per-IP):
- 5 failures within 60 seconds → `429 Too Many Requests` - 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) - 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" } { "username": "alice", "password": "hunter2" }
``` ```
| Field | Type | Required | Constraints | | Field | Type | Required | Constraints |
| ---------- | ------ | -------- | ---------------- | | ---------- | ------ | -------- | -------------------------- |
| `username` | string | Yes | 1128 characters (trimmed) | | `username` | string | Yes | 1128 characters (trimmed) |
| `password` | string | Yes | 11000 characters | | `password` | string | Yes | 11000 characters |
Zod validation errors never echo received values. 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 | | Field | Type | Description |
| -------------------- | ------- | ---------------------------------------------------------------- | | -------------------- | ------- | ---------------------------------------------------------- |
| `id` | integer | Stable member ID | | `id` | integer | Stable member ID |
| `displayName` | string | Derived from OIDC claims or set by admin | | `displayName` | string | Derived from OIDC claims or set by admin |
| `color` | string | Member's assigned color (hex) | | `color` | string | Member's assigned color (hex) |
| `isAdmin` | boolean | Whether the member has the admin role | | `isAdmin` | boolean | Whether the member has the admin role |
| `needsProviderSetup` | boolean | True when no Fastmail credential is stored for this member | | `needsProviderSetup` | boolean | True when no Fastmail credential is stored for this member |
| `hasLocalCredential` | boolean | True when a local username/password credential exists | | `hasLocalCredential` | boolean | True when a local username/password credential exists |
**Error responses:** `401` if the session is invalid. **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 | | Field | Type | Required | Constraints |
| --------------- | ------ | -------- | ---------------- | | --------------- | ------ | -------- | ------------------------------- |
| `providerType` | string | Yes | Must be `"caldav"` | | `providerType` | string | Yes | Must be `"caldav"` |
| `fastmailEmail` | string | Yes | Valid email, max 256 characters | | `fastmailEmail` | string | Yes | Valid email, max 256 characters |
| `appPassword` | string | Yes | 1500 characters | | `appPassword` | string | Yes | 1500 characters |
**Response 200** — `{ "ok": true }` **Response 200** — `{ "ok": true }`
@@ -348,9 +349,9 @@ Self-service password change for local-auth members. Requires the current passwo
} }
``` ```
| Field | Type | Required | Constraints | | Field | Type | Required | Constraints |
| ----------------- | ------ | -------- | ---------------- | | ----------------- | ------ | -------- | -------------------- |
| `currentPassword` | string | Yes | 1+ characters | | `currentPassword` | string | Yes | 1+ characters |
| `newPassword` | string | Yes | Minimum 8 characters | | `newPassword` | string | Yes | Minimum 8 characters |
**Response 200** — `{ "ok": true }` **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 | | Field | Type | Required | Constraints |
| ----------------- | ------ | -------- | ---------------- | | ----------------- | ------ | -------- | -------------------- |
| `displayName` | string | Yes | 1256 characters | | `displayName` | string | Yes | 1256 characters |
| `username` | string | Yes | 1128 characters | | `username` | string | Yes | 1128 characters |
| `initialPassword` | string | Yes | Minimum 8 characters | | `initialPassword` | string | Yes | Minimum 8 characters |
Zod validation errors never echo received values (the initial password is never included in error responses). 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 | | Field | Type | Required | Constraints |
| --------------- | ------- | -------- | ---------------- | | --------------- | ------- | -------- | ------------------------------- |
| `userId` | integer | Yes | Positive integer | | `userId` | integer | Yes | Positive integer |
| `providerType` | string | Yes | Must be `"caldav"` | | `providerType` | string | Yes | Must be `"caldav"` |
| `fastmailEmail` | string | Yes | Valid email, max 256 characters | | `fastmailEmail` | string | Yes | Valid email, max 256 characters |
| `appPassword` | string | Yes | 1500 characters | | `appPassword` | string | Yes | 1500 characters |
Zod validation errors and CalDAV validation failures return `400` with `{ "error": "Invalid request" }` — the app password is never echoed. 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": [ "calendars": [
{ "id": 1, "displayName": "Personal", "isShared": false }, { "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" } { "timezone": "America/Toronto" }
``` ```
| Field | Type | Required | Constraints | | Field | Type | Required | Constraints |
| ---------- | ------ | -------- | -------------------------- | | ---------- | ------ | -------- | ------------------------------------ |
| `timezone` | string | Yes | Valid IANA timezone, 164 characters | | `timezone` | string | Yes | Valid IANA timezone, 164 characters |
**Response 200** — `{ "ok": true }` **Response 200** — `{ "ok": true }`
@@ -1105,17 +1106,17 @@ All error responses use a consistent JSON envelope.
{ "error": "Human-readable message" } { "error": "Human-readable message" }
``` ```
| HTTP Status | Meaning | | HTTP Status | Meaning |
| ----------- | ------------------------------------------------------------------------------ | | ----------- | -------------------------------------------------------------------------------------------------------- |
| `400` | Invalid request parameters (e.g., malformed date window) | | `400` | Invalid request parameters (e.g., malformed date window) |
| `401` | Session missing or invalid | | `401` | Session missing or invalid |
| `403` | Authenticated but not authorized (wrong owner, sharee attempted owner-only op, non-admin on admin route) | | `403` | Authenticated but not authorized (wrong owner, sharee attempted owner-only op, non-admin on admin route) |
| `404` | Resource not found | | `404` | Resource not found |
| `409` | Conflict (e.g., duplicate username) | | `409` | Conflict (e.g., duplicate username) |
| `422` | Valid request but cannot be fulfilled (e.g., user has no calendar configured) | | `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)| | `423` | Locked (setup already complete, or account locked after too many failed logins) |
| `429` | Too many requests (login rate limit exceeded for this username) | | `429` | Too many requests (login rate limit exceeded for this username) |
| `503` | DB or downstream service unavailable | | `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. 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.
+45 -45
View File
@@ -88,40 +88,40 @@ familysync/
### Directory Rationale ### Directory Rationale
| Directory | Purpose | | 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/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/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/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/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/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/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/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/pwa/src/hooks/` | `useListSSE.ts` (bounded-backoff EventSource), `usePushSubscription.ts` (VAPID subscribe) |
--- ---
## Key Abstractions ## Key Abstractions
| Abstraction | File | Description | | Abstraction | File | Description |
| ------------------------------------------ | ------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ------------------------------------------------------ | ------------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `app` (Hono) | `apps/api/src/index.ts` | Root Hono app; mounts all routes and serves the PWA static build | | `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`) | | 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 | | `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 | | `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` | | `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 | | `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 | | `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 | | `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 | | `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 | | `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) | | `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 | | `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 | | `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) | | `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 | | `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 | | `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 | | 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 ## Infrastructure
| Component | Technology | | Component | Technology |
| -------------------- | ------------------------------------------------------------------------------------------------------------------- | | -------------------- | --------------------------------------------------------------------------------------------------------------------------- |
| Runtime | Node.js 22 LTS | | Runtime | Node.js 22 LTS |
| HTTP framework | Hono 4.x (`@hono/node-server`) | | HTTP framework | Hono 4.x (`@hono/node-server`) |
| Database | MariaDB 11 (Docker volume) | | Database | MariaDB 11 (Docker volume) |
| ORM | Drizzle ORM 0.45.x (`mysql2` dialect) | | ORM | Drizzle ORM 0.45.x (`mysql2` dialect) |
| Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE; optional when local auth is enabled | | 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) | | 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 source | Fastmail CalDAV (`caldav.fastmail.com`) — read via `tsdav`, write via transactional outbox |
| Calendar parsing | `ical.js` (VCALENDAR/VEVENT parse) + `rrule` (RRULE expansion) | | Calendar parsing | `ical.js` (VCALENDAR/VEVENT parse) + `rrule` (RRULE expansion) |
| App password storage | AES-256-GCM encrypted in `member_credentials.encrypted_password` | | 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 | | 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) | | Push notifications | `web-push` (VAPID) → APNs (iOS) / FCM (Android) |
| Live list sync | In-process Node.js `EventEmitter` → SSE (`text/event-stream`) | | 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) | | 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) | | PWA | React 19 + Vite 8 + `vite-plugin-pwa` (Workbox `injectManifest` mode) |
| Networking | Pangolin/Newt tunnel — no open ports; split-DNS internal domain | | 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 | | Deployment | Docker Compose on Unraid; single `api` container serves both the API and the PWA static build |
+20 -20
View File
@@ -10,14 +10,14 @@ All runtime configuration is supplied via environment variables. There are no JS
### Database ### Database
| Variable | Required | Default | Description | | 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_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_PORT` | No | `3306` | MariaDB port. |
| `DB_USER` | No | `familysync` | Database user. | | `DB_USER` | No | `familysync` | Database user. |
| `DB_PASSWORD` | **Required** | _(none)_ | Database password. Also used by the `mariadb` service as `MARIADB_PASSWORD`. | | `DB_PASSWORD` | **Required** | _(none)_ | Database password. Also used by the `mariadb` service as `MARIADB_PASSWORD`. |
| `DB_NAME` | No | `familysync` | Database name. | | `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_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. | | `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. 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`. 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_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. **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 ### Runtime Mode
| Variable | Required | Default | Description | | 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`. | | `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. | | `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`. | | `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`. These variables are never needed in production and should not appear in the production `.env`.
| Variable | Scope | Default | Description | | 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_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. | | `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`. | | `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`. |
--- ---
+23 -23
View File
@@ -100,19 +100,19 @@ Vite serves the PWA with HMR on the configured dev port. The PWA's API calls tar
### Root workspace scripts ### Root workspace scripts
| Command | Description | | Command | Description |
| ------------------------ | ------------------------------------------------------------- | | ----------------------- | --------------------------------------------------------------------------- |
| `pnpm dev:api` | Start API dev watcher (`node --watch dist/index.js`) | | `pnpm dev:api` | Start API dev watcher (`node --watch dist/index.js`) |
| `pnpm dev:pwa` | Start Vite dev server for the PWA | | `pnpm dev:pwa` | Start Vite dev server for the PWA |
| `pnpm build` | Build both `apps/api` (tsc) and `apps/pwa` (tsc + vite build) | | `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` | Run API test suite (`vitest run` in `apps/api`) |
| `pnpm test:e2e` | Run Playwright e2e harness (`apps/pwa`) | | `pnpm test:e2e` | Run Playwright e2e harness (`apps/pwa`) |
| `pnpm lint` | ESLint across all workspaces (`pnpm -r --if-present lint`) | | `pnpm lint` | ESLint across all workspaces (`pnpm -r --if-present lint`) |
| `pnpm format` | Reformat all files with Prettier (`prettier --write .`) | | `pnpm format` | Reformat all files with Prettier (`prettier --write .`) |
| `pnpm format:check` | Check formatting without writing (`prettier --check .`) | | `pnpm format:check` | Check formatting without writing (`prettier --check .`) |
| `pnpm typecheck` | `tsc --noEmit` in all workspaces | | `pnpm typecheck` | `tsc --noEmit` in all workspaces |
| `pnpm md:lint` | Markdown lint (`markdownlint-cli2`) across the repo | | `pnpm md:lint` | Markdown lint (`markdownlint-cli2`) across the repo |
| `pnpm generate-secrets` | Generate VAPID and session secret values via `scripts/generate-secrets.mjs` | | `pnpm generate-secrets` | Generate VAPID and session secret values via `scripts/generate-secrets.mjs` |
### `apps/api` scripts ### `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: 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) - **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) - **`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}`** — `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) - **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 - **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). 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 | | Job | Runs on | Checks |
| ------------- | ------------------- | ----------------------------------------------------------------------------------------------------------- | | ------------- | -------------------- | ------------------------------------------------------------------------------------------------------------ |
| `fast-checks` | Every PR | `pnpm lint``pnpm format:check``pnpm md:lint``pnpm typecheck``pnpm --filter @familysync/pwa test` | | `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) | | `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` | | `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 | | `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 | | `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. All five jobs must pass (or be legitimately skipped) before a PR can merge. See [docs/TESTING.md](TESTING.md) for test suite details.
+1
View File
@@ -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`** **`[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: 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 - 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`. - Run `pnpm generate-secrets` and add the generated `LOCAL_SESSION_SECRET` value to `.env`.
+25 -25
View File
@@ -6,10 +6,10 @@
Both apps use **Vitest** (`^4.1.8`). Both apps use **Vitest** (`^4.1.8`).
| App | Environment | Global setup | Per-file setup | | App | Environment | Global setup | Per-file setup |
| ---------- | ----------- | ----------------------------------- | ---------------------------- | | ---------- | ----------- | ------------------------------- | ---------------------------- |
| `apps/api` | `node` | `apps/api/test/global-setup.ts` | `apps/api/test/setup.ts` | | `apps/api` | `node` | `apps/api/test/global-setup.ts` | `apps/api/test/setup.ts` |
| `apps/pwa` | `jsdom` | — | `apps/pwa/src/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`. **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: The PWA has a Playwright harness configured in `apps/pwa/playwright.config.ts` with three device profiles:
| Profile | Viewport | Engine | User-Agent | | Profile | Viewport | Engine | User-Agent |
| --------- | --------- | -------- | ------------------------- | | --------- | -------- | -------- | ------------------------- |
| `iphone` | 390×844 | WebKit | Mobile Safari (iPhone 14) | | `iphone` | 390×844 | WebKit | Mobile Safari (iPhone 14) |
| `pixel` | 412×915 | Chromium | Chrome Android (Pixel 7) | | `pixel` | 412×915 | Chromium | Chrome Android (Pixel 7) |
| `desktop` | 1280×720 | Chromium | Desktop Chrome | | `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. 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. Runs the Playwright mobile and desktop e2e harness (iphone + pixel + desktop) against a runner-hosted dev stack. Skipped for doc-only PRs.
| Step | Detail | | Step | Detail |
| ---------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------ |
| MariaDB service | Same `mariadb:11` setup as the `api` job | | MariaDB service | Same `mariadb:11` setup as the `api` job |
| Schema migrations | `pnpm --filter @familysync/api db:migrate` | | Schema migrations | `pnpm --filter @familysync/api db:migrate` |
| Dev user seed | Inserts `users` row id=1 (`INSERT IGNORE`) for `DEV_AUTH_BYPASS` | | 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 | | 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) | | API build | `pnpm --filter @familysync/api build` (dist/ is gitignored) |
| Playwright install | `npx playwright install --with-deps webkit chromium` (no cache) | | 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` | | 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) | | 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) | | 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. 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. 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 | | Step | Tool/Command | Detail |
| ------------------- | ------------------------------- | -------------------------------------------------------------- | | ---------------- | --------------------------------- | ------------------------------------------------------ |
| Secret scan | `gitleaks` (v8.30.1) | Scans the PR diff range; blocks on any finding | | 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 | | 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 | | Outdated report | `node scripts/check-outdated.mjs` | Advisory only — always exits 0, logged but never gates |
### `gate` ### `gate`
+8 -7
View File
@@ -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: 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 | | 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 | | `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 | | `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 | | `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 | | `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 | | `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. 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 `:<milestone>-<sha>` 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. The immutable `:<milestone>-<sha>` 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: 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`. 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). 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).