Merge remote-tracking branch 'origin/main' into gsd/phase-17-ui-optimization-polish

# Conflicts:
#	.planning/STATE.md
This commit is contained in:
Lucas Berger
2026-06-18 10:08:45 -04:00
47 changed files with 2880 additions and 919 deletions
+8
View File
@@ -26,3 +26,11 @@ paths = ['''apps/api/tests/broker/crypto\.test\.ts''']
[[allowlists]] [[allowlists]]
description = "apps/api/tests/routes/setup.test.ts — synthetic VAPID public/private test pair used to set process.env.VAPID_* in the setup-route tests; not a real credential (verified not present in .env)" description = "apps/api/tests/routes/setup.test.ts — synthetic VAPID public/private test pair used to set process.env.VAPID_* in the setup-route tests; not a real credential (verified not present in .env)"
paths = ['''apps/api/tests/routes/setup\.test\.ts'''] paths = ['''apps/api/tests/routes/setup\.test\.ts''']
[[allowlists]]
description = "apps/api/tests/auth/localSession.test.ts — TEST_SECRET is a synthetic >=32-char JWT signing secret used only to exercise issue/verify cookie round-trips under Vitest; not a real credential (Phase 19)"
paths = ['''apps/api/tests/auth/localSession\.test\.ts''']
[[allowlists]]
description = ".planning/ design docs are internal planning prose (PLAN/SUMMARY/SECURITY/etc.) that frequently discuss credentials, tokens, and auth — they trip generic regex rules (e.g. 'credential atomically, 409-equivalent') but never carry production secrets; not shipped in any image"
paths = ['''\.planning/''']
+2 -2
View File
@@ -30,8 +30,8 @@ See: .planning/PROJECT.md (updated 2026-06-16)
Phase: 999.1 — Treat Fastmail as one calendar provider; framework supports adding more providers (BACKLOG) Phase: 999.1 — Treat Fastmail as one calendar provider; framework supports adding more providers (BACKLOG)
Plan: Not started Plan: Not started
Status: Executing Phase 19 Status: Phase 19 shipped — PR #23
Last activity: 2026-06-18 — Phase 19 complete, transitioned to Phase 999.1 Last activity: 2026-06-17
### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action) ### ✅ Resolved Checkpoint — Phase 15 Plan 15-03 Task 2 (human-action)
+3 -1
View File
@@ -95,6 +95,8 @@
}, },
"mempalace": { "mempalace": {
"enabled": true, "enabled": true,
"wing": "familysync" "wing": "familysync",
"recall_on_discuss": true,
"mirror_kg": true
} }
} }
@@ -0,0 +1,125 @@
---
phase: 19-local-auth-no-oidc-mode
fixed_at: 2026-06-17T20:39:00Z
review_path: .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md
iteration: 1
findings_in_scope: 15
fixed: 15
skipped: 0
status: all_fixed
---
# Phase 19: Code Review Fix Report
**Fixed at:** 2026-06-17T20:39:00Z
**Source review:** .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md
**Iteration:** 1
**Summary:**
- Findings in scope: 15 (4 critical, 4 blocker, 7 warning, 4 info — fix_scope: all)
- Fixed: 15
- Skipped: 0
**Verification:** Full API suite **452/452** (34 files, live MariaDB) and full PWA suite **266/266** (22 files) pass; both `tsc --noEmit` clean. Findings classified as security/availability logic (CR-04, BL-03, WR-06, IN-04) are flagged "requires human verification" below — syntax/tests pass but a human should confirm the threat-model intent.
## Fixed Issues
### CR-01: OIDC-link flow broken — client/server response-shape mismatch
**Files modified:** `apps/pwa/src/api/client.ts`, `apps/pwa/src/components/SettingsSheet.tsx`
**Commit:** 1688f22
**Applied fix:** Changed `fetchLinkOidc` to return `{ authorizationUrl: string | null }` matching the server's `{ signedState, authorizationUrl }` contract, and updated `LinkOidcSheet` to navigate to `authorizationUrl` (handling the `null`/unconfigured case by surfacing an error instead of navigating to `undefined`).
### CR-02: Admin "create member" always fails — request field-name mismatch
**Files modified:** `apps/pwa/src/api/client.ts`
**Commit:** 93c47b3
**Applied fix:** `fetchCreateMember` now sends `initialPassword` (the field `createMemberSchema` requires) instead of `password`, and maps HTTP 409 to `Error('conflict')` so the AdminPage's existing conflict branch renders the right banner.
### CR-03: Self-service password change logs user out on wrong current password
**Files modified:** `apps/api/src/routes/me.ts`, `apps/pwa/src/api/client.ts`, `apps/api/tests/routes/me.test.ts`
**Commit:** 6ef8e03
**Applied fix:** Server returns **403** (not 401) for an incorrect current password; client `fetchChangePassword` branches on 403 → `Error('wrong-current')` before the 401→`SessionExpiredError` path, so a mistyped password no longer triggers the global session-expiry logout. Updated me.test Test 2 to expect 403.
### CR-04: Login lockout per-IP, global, permanent, unrecoverable — *requires human verification*
**Files modified:** `apps/api/src/routes/localAuth.ts`, `apps/api/src/routes/admin.ts`, `apps/pwa/src/routes/LoginPage.tsx`, `apps/api/tests/routes/localAuth.test.ts`
**Commit:** b083cb7
**Applied fix:** Re-scoped the rate limiter from client IP to the **validated username**; added a **15-minute TTL** so a 423 lockout auto-expires (self-healing, no restart); wired admin password-reset to call `resetLoginAttempts(username)` for an immediate unlock; corrected the LoginPage banner copy. Added Test 5b asserting TTL auto-expiry. *Human verification: confirm the username-scoping + TTL behaviour matches the intended threat model for the tunnel deployment.*
### BL-01: devSessionCookieMiddleware issues a session without verifying secret strength
**Files modified:** `apps/api/src/auth/devBypass.ts`
**Commit:** 3674b25
**Applied fix:** Apply the same `secret.length >= 32` floor used by the boot guard inside `devSessionCookieMiddleware` before minting the dev cookie (degrade to no-op if too short), and emit a loud warning when the secret is the well-known dev placeholder.
### BL-02: Logout cannot clear the cookie in non-production (Secure attribute mismatch)
**Files modified:** `apps/api/src/auth/localSession.ts`
**Commit:** cd095e5
**Applied fix:** `clearLocalSessionCookie` now mirrors the issue-time `secure: process.env.NODE_ENV === 'production'` logic instead of hard-coding `secure: true`, so the deletion cookie is accepted over plain HTTP and logout actually clears the session in HTTP-only self-hosts.
### BL-03: OIDC-link binding swallows failure / binds on stale/blank identity — *requires human verification*
**Files modified:** `apps/api/src/index.ts`
**Commit:** 7153760
**Applied fix:** In the `/callback` link path, reject the bind unless the current local session (`verifyLocalSessionCookie`) matches `linkUserId` (account-takeover guard), and reject when `iss`/`sub` are empty (never call `linkOidcToUser` with blank identity, which would corrupt identity and delete the user's local credential). *Human verification: confirm the session cross-check closes the replay-takeover path described in the review.*
### BL-04: localAuthMiddleware fabricates oidcSub collisions for local users
**Files modified:** `apps/api/src/auth/devBypass.ts`, `apps/api/src/auth/localAuthMiddleware.ts`, `apps/api/tests/auth/localAuthMiddleware.test.ts`
**Commit:** 40666e1
**Applied fix:** Introduced a `ContextUser` interface with nullable `oidcIss`/`oidcSub`; the middleware now stores `null` for local users instead of the `'local'`/`String(id)` sentinels that shared the `uniq_oidc_identity` uniqueness domain. Added Test 1c asserting null context for a null-OIDC local user.
### WR-01: reset-admin.ts arg parsing trusts `--password ''` and echoes username
**Files modified:** `apps/api/scripts/reset-admin.ts`
**Commit:** c4d8d76
**Applied fix:** Rewrote `parseArgs` to support `--key=value` and to treat `--username`/`--password` as value-taking (consuming the next token verbatim, so a `--`-prefixed or empty password is preserved) and `--dry-run` as boolean; removed username interpolation from log lines.
### WR-02 + WR-04: hardcoded Authelia auth path / OIDC-config detection divergence
**Files modified:** `apps/api/src/auth/oidcConfig.ts` (new), `apps/api/src/routes/me.ts`
**Commit:** 322929a
**Applied fix:** New `oidcConfig.ts` centralizes the env-OR-app_config resolution (`resolveOidcConfig`) and discovers the `authorization_endpoint` from the provider's `/.well-known/openid-configuration` (`discoverAuthorizationEndpoint`). me.ts link-oidc now uses both, so a wizard-configured instance no longer reports `oidcEnabled:true` while returning `authorizationUrl:null`, and the URL is no longer Authelia-path-specific. (Two findings fixed in one commit — they share the same handler/lines and are inseparable.)
### WR-03: scryptSync blocks the event loop on the login hot path
**Files modified:** `apps/api/src/auth/localCredentials.ts`, `apps/api/src/routes/localAuth.ts`, `apps/api/src/routes/me.ts`, `apps/api/src/routes/admin.ts`, plus their tests
**Commit:** 30ad25c
**Applied fix:** Converted `hashPassword`/`verifyPassword` to async (threadpool scrypt via a typed Promise wrapper), awaited at all call sites, made the login DUMMY_HASH a module-level promise, and moved create-member hashing outside the DB transaction. Updated all test call sites to await. Preserves the timing-defense property while keeping the event loop responsive.
### WR-05: noEchoHook pass-through is correct-but-untested
**Files modified:** `apps/api/tests/routes/admin.test.ts`, `apps/api/tests/routes/me.test.ts`
**Commit:** 4bd6b2c
**Applied fix:** Added focused no-echo tests for the admin create-member and me password hook sites asserting a malformed body never includes the submitted password or Zod's `received`/`issues`. `@hono/zod-validator` is already pinned to exact `0.8.0` in package.json.
### WR-06: rate-limit lockedUntil refreshed on every blocked attempt — *requires human verification*
**Files modified:** `apps/api/src/routes/localAuth.ts`
**Commit:** 4cf2ad4
**Applied fix:** The 429 (already-rejected) branch no longer re-arms `lockedUntil`; the cooldown window stays anchored to when it was first armed, so sustained attacker traffic can no longer slide the window forward indefinitely. *Human verification: confirm the window now expires on schedule for a legitimate user behind the same identity.*
### WR-07: parseInt member/calendar id accepts trailing garbage
**Files modified:** `apps/api/src/routes/admin.ts`, `apps/api/tests/routes/admin.test.ts`
**Commit:** 32bdd1e
**Applied fix:** Added `parsePositiveIntParam` using `Number.isInteger(Number(raw))` and applied it to `/members/:id/password` and `/calendars/:id/shared`, so `"12abc"` is now rejected with 400. Added a test for the calendar route.
### IN-01: localSession maxAge/expiry parsing has no validation
**Files modified:** `apps/api/src/auth/localSession.ts`
**Commit:** f2fc140
**Applied fix:** Coerce and validate `LOCAL_SESSION_EXPIRES` — fall back to 86400s for any non-finite or non-positive value, preventing a `NaN` exp/maxAge.
### IN-02: duplicated inline scrypt implementation across three locations
**Files modified:** `apps/api/tests/auth/localCredentials.test.ts`
**Commit:** e392bf2
**Applied fix:** Added a lockstep test that builds a hash using the inlined scrypt parameters (N=16384, r=8, p=1, KEY_LEN=32 — matching reset-admin.ts and ci.yml) and asserts it round-trips against the canonical `verifyPassword`, so a parameter drift fails CI loudly.
### IN-03: loginAttempts map is unbounded
**Files modified:** `apps/api/src/routes/localAuth.ts`
**Commit:** f02521d
**Applied fix:** Added `evictStaleLoginAttempts`, called opportunistically per login request, dropping entries that are neither in an active rate-limit window nor an active lockout TTL — bounding the map under input churn without weakening the limiter.
### IN-04: link-oidc nonce generated but never persisted/verified — *requires human verification*
**Files modified:** `apps/api/src/auth/linkNonceStore.ts` (new), `apps/api/src/routes/me.ts`, `apps/api/src/index.ts`
**Commit:** 2691dd0
**Applied fix:** New in-memory single-use nonce store: me.ts registers the issued nonce (valid until the state JWT's exp); the `/callback` link path consumes it and rejects any replayed/unknown/expired nonce before binding. Combined with BL-03's session cross-check, the captured-state replay window is closed. *Human verification: confirm single-use semantics are sufficient for the single-process deployment (move to Redis if multi-process).*
## Skipped Issues
None — all 15 in-scope findings were fixed.
---
_Fixed: 2026-06-17T20:39:00Z_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 1_
@@ -0,0 +1,125 @@
---
phase: 19-local-auth-no-oidc-mode
fixed_at: 2026-06-17T20:39:00Z
review_path: .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md
iteration: 1
findings_in_scope: 15
fixed: 15
skipped: 0
status: all_fixed
---
# Phase 19: Code Review Fix Report
**Fixed at:** 2026-06-17T20:39:00Z
**Source review:** .planning/phases/19-local-auth-no-oidc-mode/19-REVIEW.md
**Iteration:** 1
**Summary:**
- Findings in scope: 15 (4 critical, 4 blocker, 7 warning, 4 info — fix_scope: all)
- Fixed: 15
- Skipped: 0
**Verification:** Full API suite **452/452** (34 files, live MariaDB) and full PWA suite **266/266** (22 files) pass; both `tsc --noEmit` clean. Findings classified as security/availability logic (CR-04, BL-03, WR-06, IN-04) are flagged "requires human verification" below — syntax/tests pass but a human should confirm the threat-model intent.
## Fixed Issues
### CR-01: OIDC-link flow broken — client/server response-shape mismatch
**Files modified:** `apps/pwa/src/api/client.ts`, `apps/pwa/src/components/SettingsSheet.tsx`
**Commit:** 1688f22
**Applied fix:** Changed `fetchLinkOidc` to return `{ authorizationUrl: string | null }` matching the server's `{ signedState, authorizationUrl }` contract, and updated `LinkOidcSheet` to navigate to `authorizationUrl` (handling the `null`/unconfigured case by surfacing an error instead of navigating to `undefined`).
### CR-02: Admin "create member" always fails — request field-name mismatch
**Files modified:** `apps/pwa/src/api/client.ts`
**Commit:** 93c47b3
**Applied fix:** `fetchCreateMember` now sends `initialPassword` (the field `createMemberSchema` requires) instead of `password`, and maps HTTP 409 to `Error('conflict')` so the AdminPage's existing conflict branch renders the right banner.
### CR-03: Self-service password change logs user out on wrong current password
**Files modified:** `apps/api/src/routes/me.ts`, `apps/pwa/src/api/client.ts`, `apps/api/tests/routes/me.test.ts`
**Commit:** 6ef8e03
**Applied fix:** Server returns **403** (not 401) for an incorrect current password; client `fetchChangePassword` branches on 403 → `Error('wrong-current')` before the 401→`SessionExpiredError` path, so a mistyped password no longer triggers the global session-expiry logout. Updated me.test Test 2 to expect 403.
### CR-04: Login lockout per-IP, global, permanent, unrecoverable — *requires human verification*
**Files modified:** `apps/api/src/routes/localAuth.ts`, `apps/api/src/routes/admin.ts`, `apps/pwa/src/routes/LoginPage.tsx`, `apps/api/tests/routes/localAuth.test.ts`
**Commit:** b083cb7
**Applied fix:** Re-scoped the rate limiter from client IP to the **validated username**; added a **15-minute TTL** so a 423 lockout auto-expires (self-healing, no restart); wired admin password-reset to call `resetLoginAttempts(username)` for an immediate unlock; corrected the LoginPage banner copy. Added Test 5b asserting TTL auto-expiry. *Human verification: confirm the username-scoping + TTL behaviour matches the intended threat model for the tunnel deployment.*
### BL-01: devSessionCookieMiddleware issues a session without verifying secret strength
**Files modified:** `apps/api/src/auth/devBypass.ts`
**Commit:** 3674b25
**Applied fix:** Apply the same `secret.length >= 32` floor used by the boot guard inside `devSessionCookieMiddleware` before minting the dev cookie (degrade to no-op if too short), and emit a loud warning when the secret is the well-known dev placeholder.
### BL-02: Logout cannot clear the cookie in non-production (Secure attribute mismatch)
**Files modified:** `apps/api/src/auth/localSession.ts`
**Commit:** cd095e5
**Applied fix:** `clearLocalSessionCookie` now mirrors the issue-time `secure: process.env.NODE_ENV === 'production'` logic instead of hard-coding `secure: true`, so the deletion cookie is accepted over plain HTTP and logout actually clears the session in HTTP-only self-hosts.
### BL-03: OIDC-link binding swallows failure / binds on stale/blank identity — *requires human verification*
**Files modified:** `apps/api/src/index.ts`
**Commit:** 7153760
**Applied fix:** In the `/callback` link path, reject the bind unless the current local session (`verifyLocalSessionCookie`) matches `linkUserId` (account-takeover guard), and reject when `iss`/`sub` are empty (never call `linkOidcToUser` with blank identity, which would corrupt identity and delete the user's local credential). *Human verification: confirm the session cross-check closes the replay-takeover path described in the review.*
### BL-04: localAuthMiddleware fabricates oidcSub collisions for local users
**Files modified:** `apps/api/src/auth/devBypass.ts`, `apps/api/src/auth/localAuthMiddleware.ts`, `apps/api/tests/auth/localAuthMiddleware.test.ts`
**Commit:** 40666e1
**Applied fix:** Introduced a `ContextUser` interface with nullable `oidcIss`/`oidcSub`; the middleware now stores `null` for local users instead of the `'local'`/`String(id)` sentinels that shared the `uniq_oidc_identity` uniqueness domain. Added Test 1c asserting null context for a null-OIDC local user.
### WR-01: reset-admin.ts arg parsing trusts `--password ''` and echoes username
**Files modified:** `apps/api/scripts/reset-admin.ts`
**Commit:** c4d8d76
**Applied fix:** Rewrote `parseArgs` to support `--key=value` and to treat `--username`/`--password` as value-taking (consuming the next token verbatim, so a `--`-prefixed or empty password is preserved) and `--dry-run` as boolean; removed username interpolation from log lines.
### WR-02 + WR-04: hardcoded Authelia auth path / OIDC-config detection divergence
**Files modified:** `apps/api/src/auth/oidcConfig.ts` (new), `apps/api/src/routes/me.ts`
**Commit:** 322929a
**Applied fix:** New `oidcConfig.ts` centralizes the env-OR-app_config resolution (`resolveOidcConfig`) and discovers the `authorization_endpoint` from the provider's `/.well-known/openid-configuration` (`discoverAuthorizationEndpoint`). me.ts link-oidc now uses both, so a wizard-configured instance no longer reports `oidcEnabled:true` while returning `authorizationUrl:null`, and the URL is no longer Authelia-path-specific. (Two findings fixed in one commit — they share the same handler/lines and are inseparable.)
### WR-03: scryptSync blocks the event loop on the login hot path
**Files modified:** `apps/api/src/auth/localCredentials.ts`, `apps/api/src/routes/localAuth.ts`, `apps/api/src/routes/me.ts`, `apps/api/src/routes/admin.ts`, plus their tests
**Commit:** 30ad25c
**Applied fix:** Converted `hashPassword`/`verifyPassword` to async (threadpool scrypt via a typed Promise wrapper), awaited at all call sites, made the login DUMMY_HASH a module-level promise, and moved create-member hashing outside the DB transaction. Updated all test call sites to await. Preserves the timing-defense property while keeping the event loop responsive.
### WR-05: noEchoHook pass-through is correct-but-untested
**Files modified:** `apps/api/tests/routes/admin.test.ts`, `apps/api/tests/routes/me.test.ts`
**Commit:** 4bd6b2c
**Applied fix:** Added focused no-echo tests for the admin create-member and me password hook sites asserting a malformed body never includes the submitted password or Zod's `received`/`issues`. `@hono/zod-validator` is already pinned to exact `0.8.0` in package.json.
### WR-06: rate-limit lockedUntil refreshed on every blocked attempt — *requires human verification*
**Files modified:** `apps/api/src/routes/localAuth.ts`
**Commit:** 4cf2ad4
**Applied fix:** The 429 (already-rejected) branch no longer re-arms `lockedUntil`; the cooldown window stays anchored to when it was first armed, so sustained attacker traffic can no longer slide the window forward indefinitely. *Human verification: confirm the window now expires on schedule for a legitimate user behind the same identity.*
### WR-07: parseInt member/calendar id accepts trailing garbage
**Files modified:** `apps/api/src/routes/admin.ts`, `apps/api/tests/routes/admin.test.ts`
**Commit:** 32bdd1e
**Applied fix:** Added `parsePositiveIntParam` using `Number.isInteger(Number(raw))` and applied it to `/members/:id/password` and `/calendars/:id/shared`, so `"12abc"` is now rejected with 400. Added a test for the calendar route.
### IN-01: localSession maxAge/expiry parsing has no validation
**Files modified:** `apps/api/src/auth/localSession.ts`
**Commit:** f2fc140
**Applied fix:** Coerce and validate `LOCAL_SESSION_EXPIRES` — fall back to 86400s for any non-finite or non-positive value, preventing a `NaN` exp/maxAge.
### IN-02: duplicated inline scrypt implementation across three locations
**Files modified:** `apps/api/tests/auth/localCredentials.test.ts`
**Commit:** e392bf2
**Applied fix:** Added a lockstep test that builds a hash using the inlined scrypt parameters (N=16384, r=8, p=1, KEY_LEN=32 — matching reset-admin.ts and ci.yml) and asserts it round-trips against the canonical `verifyPassword`, so a parameter drift fails CI loudly.
### IN-03: loginAttempts map is unbounded
**Files modified:** `apps/api/src/routes/localAuth.ts`
**Commit:** f02521d
**Applied fix:** Added `evictStaleLoginAttempts`, called opportunistically per login request, dropping entries that are neither in an active rate-limit window nor an active lockout TTL — bounding the map under input churn without weakening the limiter.
### IN-04: link-oidc nonce generated but never persisted/verified — *requires human verification*
**Files modified:** `apps/api/src/auth/linkNonceStore.ts` (new), `apps/api/src/routes/me.ts`, `apps/api/src/index.ts`
**Commit:** 2691dd0
**Applied fix:** New in-memory single-use nonce store: me.ts registers the issued nonce (valid until the state JWT's exp); the `/callback` link path consumes it and rejects any replayed/unknown/expired nonce before binding. Combined with BL-03's session cross-check, the captured-state replay window is closed. *Human verification: confirm single-use semantics are sufficient for the single-process deployment (move to Redis if multi-process).*
## Skipped Issues
None — all 15 in-scope findings were fixed.
---
_Fixed: 2026-06-17T20:39:00Z_
_Fixer: Claude (gsd-code-fixer)_
_Iteration: 1_
@@ -0,0 +1,387 @@
---
phase: 19-local-auth-no-oidc-mode
reviewed: 2026-06-17T00:00:00Z
depth: deep
files_reviewed: 39
files_reviewed_list:
- apps/api/scripts/reset-admin.ts
- apps/api/src/auth/devBypass.ts
- apps/api/src/auth/linkOidc.ts
- apps/api/src/auth/localAuthMiddleware.ts
- apps/api/src/auth/localCredentials.ts
- apps/api/src/auth/localSession.ts
- apps/api/src/auth/middleware.ts
- apps/api/src/db/migrations/0003_warm_deathstrike.sql
- apps/api/src/db/schema.ts
- apps/api/src/index.ts
- apps/api/src/lib/bootGuards.ts
- apps/api/src/routes/admin.ts
- apps/api/src/routes/authMode.ts
- apps/api/src/routes/localAuth.ts
- apps/api/src/routes/me.ts
- apps/api/tests/auth/localAuthMiddleware.test.ts
- apps/api/tests/auth/localCredentials.test.ts
- apps/api/tests/auth/localSession.test.ts
- apps/api/test/setup.ts
- apps/api/tests/lib/requireAdmin.test.ts
- apps/api/tests/routes/admin.test.ts
- apps/api/tests/routes/authMode.test.ts
- apps/api/tests/routes/lists.test.ts
- apps/api/tests/routes/localAuth.test.ts
- apps/api/tests/routes/me.test.ts
- apps/api/tests/routes/push.test.ts
- apps/api/tests/routes/setup.test.ts
- apps/pwa/e2e/global-setup.ts
- apps/pwa/e2e/login.spec.ts
- apps/pwa/src/api/client.ts
- apps/pwa/src/App.test.tsx
- apps/pwa/src/App.tsx
- apps/pwa/src/components/BrandSlot.tsx
- apps/pwa/src/components/InstructionSheet.test.tsx
- apps/pwa/src/components/SettingsSheet.tsx
- apps/pwa/src/routes/AdminPage.tsx
- apps/pwa/src/routes/LoginPage.tsx
- apps/pwa/src/styles/tokens.css
- .gitea/workflows/ci.yml
- scripts/generate-secrets.mjs
findings:
critical: 4
blocker: 4
warning: 7
info: 4
total: 15
status: issues_found
---
# Phase 19: Code Review Report
**Reviewed:** 2026-06-17
**Depth:** deep
**Files Reviewed:** 39 (auth source + routes + PWA + CI)
**Status:** issues_found
## Summary
Phase 19 adds a local username/password authentication mode alongside the existing
OIDC flow. The cryptographic core (scrypt PHC hashing, constant-time compare, dummy-hash
timing defense, HS256 session JWT with boot guards) is implemented carefully and is sound.
The middleware chain (`devAuthBypass → devSessionCookie → localAuthMiddleware → OIDC guard`)
and the admin-privilege boundary (`requireAdmin` DB-enforced on every `/api/admin/*` request)
are correct.
The serious problems are at the **client↔server API contract boundary** — the exact place
a deep cross-file review is meant to catch. Three Phase-19 client functions in
`apps/pwa/src/api/client.ts` disagree with their server routes on field names, response
shape, or status-code handling, so the corresponding features (OIDC-link, admin
create-member, and self-service password change error handling) are broken end-to-end
despite each side individually passing its own unit tests. There is also an
authentication availability defect: the per-IP login lockout is mislabeled as
"account locked," is global, and never expires — a single attacker IP can permanently
deny login for the whole household with no self-recovery path.
## Critical Issues
### CR-01: OIDC-link flow is broken — client/server response-shape mismatch
**File:** `apps/pwa/src/api/client.ts:226-238` and `apps/api/src/routes/me.ts:301-341`
**Issue:** `fetchLinkOidc()` reads `result.redirectUrl` and its return type is
`{ redirectUrl: string }`. The server's `POST /api/me/link-oidc` returns
`{ signedState, authorizationUrl }` — there is no `redirectUrl` key. The caller
(Surface 13 "Link OIDC") will navigate to `undefined`, so the entire OIDC-link feature
(AUTH-LOCAL-10) cannot work in the browser. Each side's own unit tests pass because
neither test crosses the boundary. Additionally `authorizationUrl` can legitimately be
`null` (OIDC unconfigured), which the client type does not model.
**Fix:** Make the contract agree. Either return `redirectUrl` from the server, or read
`authorizationUrl` on the client and handle `null`:
```ts
export async function fetchLinkOidc(): Promise<{ authorizationUrl: string | null }> {
// ...
return res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>;
}
// caller:
const { authorizationUrl } = await fetchLinkOidc();
if (authorizationUrl) window.location.href = authorizationUrl;
```
### CR-02: Admin "create member" always fails — request field-name mismatch
**File:** `apps/pwa/src/api/client.ts:176-194` and `apps/api/src/routes/admin.ts:123-133`
**Issue:** `fetchCreateMember` POSTs `{ displayName, username, password }`. The server's
`createMemberSchema` requires `{ displayName, username, initialPassword }`. The `password`
field is ignored and `initialPassword` is missing, so Zod validation fails and the route
returns `400 { error: 'Invalid request' }` (via `noEchoHook`) for *every* valid admin
attempt to create a local member (AUTH-LOCAL-07). The Admin UI maps a non-409 error to
the generic "Something went wrong" banner, so the admin can never create an account.
**Fix:** Send the field the server expects:
```ts
body: JSON.stringify({
displayName: body.displayName,
username: body.username,
initialPassword: body.password,
}),
```
### CR-03: Self-service password change logs the user out on a wrong current password
**File:** `apps/pwa/src/api/client.ts:148-165` and `apps/api/src/routes/me.ts:233-261`
**Issue:** `fetchChangePassword` treats `res.status === 401` as `SessionExpiredError`,
which the global MutationCache handler interprets as "session expired → arm the
re-auth interstitial / redirect to login." But `POST /api/me/password` returns **401**
`{ error: 'Current password incorrect' }` when the supplied current password is wrong
(me.ts:259-260). So a user who simply mistypes their current password is forcibly logged
out instead of seeing "current password incorrect." The client doc comment even claims
"401 → wrong current password" while the code routes 401 to `SessionExpiredError`. The
documented 422 validation branch also never fires — the server returns 400 (noEchoHook)
or 404, not 422.
**Fix:** Distinguish auth-expiry from an in-app 401. Have the route return a distinct
status for "wrong current password" (e.g. 403 or a body code), and branch on it client-side
before treating 401 as session expiry:
```ts
if (res.status === 401) {
const body = await res.json().catch(() => ({}));
if (body?.error === 'Current password incorrect') throw new Error('wrong-current');
throw new SessionExpiredError();
}
```
### CR-04: Login lockout is per-IP, global, permanent, and unrecoverable
**File:** `apps/api/src/routes/localAuth.ts:55-140`
**Issue:** Multiple correctness/availability defects in one mechanism:
1. The `loginAttempts` map is keyed by **IP**, yet the 423 response and the PWA banner say
"This account is temporarily locked." It is neither account-scoped nor temporary.
2. Once `count >= LOCKOUT_FAILURES (10)`, `lockedOut` is set permanently. The only documented
clear path is "admin password reset" — but no admin route ever clears `loginAttempts`
(admin.ts reset-password updates the hash, not the in-memory map). So **the lockout is
genuinely unrecoverable without a process restart**.
3. Because it is per-IP and all household traffic arrives via the Pangolin tunnel with the
same `X-Forwarded-For` first hop, one bad actor (or one user fat-fingering 10 times) can
lock out **every** member at that egress IP. This is a self-inflicted DoS on a 2-person
household whose entire reason for existing is low-friction access.
4. `X-Forwarded-For` is attacker-controllable on any request that does not pass through the
trusted proxy; an attacker can rotate the header to get unlimited fresh rate-limit
buckets, defeating the brute-force defense entirely while still being able to lock
*other* identities by spoofing their IP if it were ever known.
**Fix:** Re-scope the limiter to the submitted username (not IP), make the 423 lockout
expire on a timer (or actually wire admin reset to clear it), correct the banner copy, and
only trust `X-Forwarded-For` when the request demonstrably came from the known proxy
(or use the leftmost-trusted hop). At minimum, give the lockout a TTL so a restart is not
required:
```ts
// derive key from the validated username, and expire lockout after N minutes
const LOCKOUT_TTL_MS = 15 * 60 * 1000;
if (attempt?.lockedOut && Date.now() < attempt.lockedUntil) { /* 423 */ }
```
## Blockers
### BL-01: `devSessionCookieMiddleware` issues a session for user id=1 without verifying the user exists or the secret is strong
**File:** `apps/api/src/auth/devBypass.ts:102-131` and `apps/api/src/lib/bootGuards.ts:53-66`
**Issue:** In dev-bypass mode the boot guard `assertLocalSessionSecretSet()` is *skipped*
entirely (returns early when `DEV_AUTH_BYPASS==='true'`). `devSessionCookieMiddleware` then
only checks that `LOCAL_SESSION_SECRET` is *present* (truthy), not that it is ≥32 chars, and
mints a real, fully-valid `local-session` JWT for `DEV_USER.id (=1)`. The CI sets a weak
fixed secret `'dev-secret-change-me-0000000000000000'`. Any cookie minted under bypass is a
genuine, signature-valid session token for user 1 — if that same weak/known secret is ever
present in a non-bypass environment (e.g. an operator copies the dev compose), forged
sessions are trivial. The hard `NODE_ENV==='production'` guard mitigates the worst case, but
this is a latent footgun: the boot guard's length check is the documented defense and it is
bypassed here.
**Fix:** Apply the same `secret.length >= 32` floor inside `devSessionCookieMiddleware`
before issuing, and emit a loud warning if the dev secret is the placeholder value. Do not
treat "present" as "safe."
### BL-02: Logout cannot clear the cookie in non-production (attribute mismatch)
**File:** `apps/api/src/auth/localSession.ts:53-59` vs `97-106`
**Issue:** `issueLocalSessionCookie` sets `secure: process.env.NODE_ENV === 'production'`
(i.e. `secure:false` in dev/test over HTTP). `clearLocalSessionCookie` hard-codes
`secure: true`. Browsers require the `Secure` attribute on a deletion cookie to match the
context: over plain HTTP a `Secure` delete-cookie is rejected, so `POST /local/logout`
returns 200 but the `local-session` cookie is **not actually cleared** in any non-HTTPS
deployment (local dev, and any HTTP-only self-host). The user appears logged in after
"logout." The inline comment acknowledges the mismatch but waves it away with "logout should
happen over HTTPS" — that is an unsafe assumption for a self-hosted app that explicitly
supports private-IP/HTTP internal access.
**Fix:** Mirror the issue-time logic on delete:
```ts
deleteCookie(c, COOKIE_NAME, {
path: '/', httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
});
```
### BL-03: OIDC-link binding swallows `processOAuthCallback` failure and can bind on a stale/blank identity
**File:** `apps/api/src/index.ts:57-101`
**Issue:** In the `/callback` link path the code calls `processOAuthCallback(c)` then
`getAuth(c)` and binds whatever `iss`/`sub` it finds to `linkUserId`. Two problems:
(1) `iss`/`sub` fall back to `''` (`auth.iss ?? ''`, `auth.sub ?? ''`); if `getAuth` returns
a partially-populated session, `linkOidcToUser(linkUserId, '', '')` will write
`oidc_iss=''`/`oidc_sub=''` onto the user and **delete their local_credentials** — locking
them out of both auth methods. (2) `linkUserId` comes from a JWT signed with
`LOCAL_SESSION_SECRET`, but nothing verifies the *currently authenticated session* matches
`linkUserId`; the signed-state CSRF defense assumes the state can only have been produced by
`POST /api/me/link-oidc`, but the callback binds to `linkUserId` regardless of who completes
the OIDC login. An attacker who can get a victim to complete an OIDC login while replaying a
captured (still-valid, 10-min) link state binds the *attacker's* OIDC identity to the
*victim's* account — account takeover.
**Fix:** Reject the bind unless `iss` and `sub` are both non-empty, and cross-check that the
OIDC identity being bound is the one the initiating user intended (e.g. require the
post-callback session's subject to be confirmed by the user, or bind only when the
initiating local session is still present and matches `linkUserId`). Never call
`linkOidcToUser` with empty iss/sub.
### BL-04: `localAuthMiddleware` fabricates `oidcSub` collisions for local users
**File:** `apps/api/src/auth/localAuthMiddleware.ts:88-94` and `apps/api/src/db/schema.ts:60-64`
**Issue:** When populating context for a local user with null OIDC fields, the middleware
substitutes `oidcIss: 'local'` and `oidcSub: String(row.id)`. This is only a context shape
and is not persisted by the middleware — but `me.ts:resolveUserId` (the OIDC path) and
`upsertUser` key identity on `iss+sub`, and the `users` table has
`unique('uniq_oidc_identity').on(oidcIss, oidcSub)`. If any code path ever upserts using the
context's `('local', String(id))` pair (e.g. a future call to `upsertUser` with these
values), two local users would deterministically collide or a local user could shadow a real
OIDC identity whose `(iss,sub)` happened to equal `('local','<n>')`. The fabricated values
leak a synthetic identity namespace that overlaps the real one.
**Fix:** Keep the context `oidcIss/oidcSub` as `null` for local users (widen the
`ContextVariableMap` `user` type to allow null) rather than inventing `'local'`/`String(id)`
sentinels that share a uniqueness domain with real OIDC identities.
## Warnings
### WR-01: `reset-admin.ts` interpolates the username into a log line and trusts `--password ''`
**File:** `apps/api/scripts/reset-admin.ts:60-66, 123, 133`
**Issue:** Two issues. (1) The arg parser treats any token starting with `--` as a new flag,
so `--password --foo` yields `password=''`; combined with the dry-run branch the validation
is loose. More importantly a password that legitimately begins with `--` (or is the empty
string) is silently coerced to `''`. (2) The username is interpolated directly into
`console.log(... username="${username}")`; while not an injection into SQL (queries are
parameterized — good), logging the username is a minor info disclosure for a break-glass tool
and inconsistent with the password-never-logged contract.
**Fix:** Parse `--password=value` and `--password value` explicitly; do not infer empty
strings from a following flag. Avoid echoing the username, or document it as acceptable.
### WR-02: `me.ts` builds the OIDC authorization URL with a hardcoded Authelia path
**File:** `apps/api/src/routes/me.ts:331`
**Issue:** `new URL(`${issuer}/api/oidc/authorization`)` hardcodes Authelia's authorization
endpoint path. The project's stated design is to discover endpoints via
`/.well-known/openid-configuration` (the whole reason `@hono/oidc-auth` is used). Any
non-Authelia or differently-mounted provider will get a wrong URL. This is a latent
correctness bug that compounds CR-01.
**Fix:** Resolve the authorization endpoint from the discovery document rather than assuming
`/api/oidc/authorization`.
### WR-03: `scryptSync` blocks the event loop on the login hot path
**File:** `apps/api/src/auth/localCredentials.ts:42-53, 71-90` and `localAuth.ts:127-129`
**Issue:** `verifyPassword` always runs `scryptSync` (N=16384) synchronously, including the
dummy-hash branch on every failed/unknown login. The file comment justifies this for a
2-person household, but combined with the per-IP rate limiter and the always-run dummy hash,
a burst of unauthenticated `POST /local/login` requests can pin the single Node event loop
(each scrypt is ~tens of ms of blocking CPU) and stall *all* other API traffic — a cheap
unauthenticated DoS. The rate limiter does not protect this because the scrypt runs *before*
the failure counter is consulted on the dummy path for new IPs.
**Fix:** Use `promisify(scrypt)` (async) so hashing does not block the loop, as the comment
itself suggests. This keeps the timing-defense property while preventing loop starvation.
### WR-04: `authMode` / OIDC-enabled detection diverges across three files
**File:** `apps/api/src/routes/authMode.ts:33-49`, `apps/api/src/auth/middleware.ts:62-114`,
`apps/api/src/routes/me.ts:323-328`
**Issue:** Three independent notions of "is OIDC configured": `authMode` checks
`OIDC_ISSUER` env OR `app_config.oidc_issuer`; the fallback middleware injects
issuer/client-id/external-url from app_config; but `me.ts` link-oidc only builds a URL when
`OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_REDIRECT_URI` are all in **env** (it never consults
app_config). So a wizard-configured-but-not-restarted instance reports `oidcEnabled:true`
from `/api/auth/mode`, shows the "Link OIDC" button, but `link-oidc` returns
`authorizationUrl:null` — inconsistent state surfaced to the user.
**Fix:** Centralize the "OIDC configured" resolution (env-or-app_config) in one helper and
use it in all three sites.
### WR-05: `noEchoHook` return value is ignored by `@hono/zod-validator` in one of two styles
**File:** `apps/api/src/routes/localAuth.ts:39-43`, `apps/api/src/routes/admin.ts:74-78`,
`apps/api/src/routes/me.ts:178-182`
**Issue:** The hook signature is `(result, c)` and returns `c.json(...)` only on failure. This
relies on zValidator short-circuiting when the hook returns a Response. That contract holds
for current `@hono/zod-validator`, but the hook does not `return` anything on success and does
not assert `result.success` narrows the type, so a future validator version that requires an
explicit early-return-on-success, or that passes through when the hook returns `undefined`,
would silently start echoing Zod errors (the exact T-19-14 leak this guards against). It is
correct today but fragile and untested for the pass-through case.
**Fix:** Add a focused test asserting that a malformed body never includes `received`/the
submitted value for each hook site (localAuth has one; admin/me password routes should too),
and pin the `@hono/zod-validator` version.
### WR-06: Rate-limit `lockedUntil` is refreshed on every blocked attempt, extending the window indefinitely
**File:** `apps/api/src/routes/localAuth.ts:97-106, 133-137`
**Issue:** On a 429 the code sets `attempt.lockedUntil = Date.now() + RATE_WINDOW_SECS*1000`
again, so an attacker who keeps hitting the endpoint perpetually slides the cooldown forward —
a legitimate user behind the same IP can never get back in even after pausing, because every
attacker request re-arms the window. Coupled with CR-04 this makes the household-wide lock
effectively permanent under sustained traffic.
**Fix:** Do not extend `lockedUntil` on requests that are themselves rejected by the window;
only set it when transitioning from below-threshold to at-threshold.
### WR-07: `parseInt` member/calendar id accepts trailing garbage
**File:** `apps/api/src/routes/admin.ts:215-218, 307-311`
**Issue:** `parseInt(c.req.param('id'), 10)` returns `12` for `"12abc"` and the `isNaN`
guard passes. Not exploitable here (the value is used only in a parameterized `eq`), but it
silently accepts malformed ids and could mask client bugs. The `/members/:id/password` and
`/calendars/:id/shared` routes both use this pattern.
**Fix:** Validate with `Number.isInteger(Number(raw))` or a Zod param schema so `"12abc"` is
rejected with 400.
## Info
### IN-01: `localSession` `maxAge`/expiry parsing has no validation
**File:** `apps/api/src/auth/localSession.ts:31`
**Issue:** `Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400)` yields `NaN` for a malformed
value, producing a JWT with `exp = now + NaN` (→ `NaN`) and a cookie `maxAge: NaN`. Verify
behavior is then "always expired" or "never expires" depending on the JWT lib's NaN handling.
**Fix:** Coerce and validate: `const n = Number(env); SESSION_MAX_AGE = Number.isFinite(n) && n > 0 ? n : 86400;`
### IN-02: Duplicated inline scrypt implementation across three locations
**File:** `apps/api/scripts/reset-admin.ts:45-51`, `.gitea/workflows/ci.yml:307-311`,
`apps/api/src/auth/localCredentials.ts:42-53`
**Issue:** The PHC scrypt hash is copy-pasted in the reset-admin script, the CI seed step,
and the canonical module. If the parameters ever change (the file comment advertises
parameter evolution as a feature), these three drift and produce incompatible hashes. The
duplication is documented as necessary (cannot import compiled TS from a plain script), but
there is no test asserting the three stay in lockstep.
**Fix:** Add a test that imports `hashPassword` and asserts a known input round-trips against
a hash produced by the inlined parameters, so a parameter change fails CI loudly.
### IN-03: `loginAttempts` map is unbounded (memory growth)
**File:** `apps/api/src/routes/localAuth.ts:66`
**Issue:** Entries are only removed on a *successful* login for that IP. Spoofed/rotated
`X-Forwarded-For` values (see CR-04) accumulate map entries with no eviction, a slow memory
leak. Out of strict v1 perf scope, noted because it is reachable by unauthenticated input.
**Fix:** Add periodic eviction of entries whose `lockedUntil` is far in the past.
### IN-04: `me.ts` link-oidc nonce is generated but never persisted/verified
**File:** `apps/api/src/routes/me.ts:312-320` and `apps/api/src/index.ts:60-74`
**Issue:** The signed state carries a `nonce` "to prevent replay," but the `/callback`
handler never records or checks the nonce — it only verifies the JWT signature and reads
`linkUserId`. A captured state JWT is fully replayable within its 10-minute window (the
signature stays valid), so the nonce provides no actual replay protection. This underlies
the takeover concern in BL-03.
**Fix:** Persist issued nonces (or a single-use jti) and reject a state whose nonce was
already consumed, or shorten the window and bind the state to the initiating session cookie.
---
_Reviewed: 2026-06-17_
_Reviewer: Claude (gsd-code-reviewer)_
_Depth: deep_
@@ -2,7 +2,7 @@
phase: 19-local-auth-no-oidc-mode phase: 19-local-auth-no-oidc-mode
reviewed: 2026-06-17T00:00:00Z reviewed: 2026-06-17T00:00:00Z
depth: deep depth: deep
files_reviewed: 39 files_reviewed: 41
files_reviewed_list: files_reviewed_list:
- apps/api/scripts/reset-admin.ts - apps/api/scripts/reset-admin.ts
- apps/api/src/auth/devBypass.ts - apps/api/src/auth/devBypass.ts
@@ -45,340 +45,142 @@ files_reviewed_list:
- .gitea/workflows/ci.yml - .gitea/workflows/ci.yml
- scripts/generate-secrets.mjs - scripts/generate-secrets.mjs
findings: findings:
critical: 4 critical: 0
blocker: 4 blocker: 0
warning: 7 warning: 0
info: 4 info: 2
total: 15 total: 2
status: issues_found status: clean
--- ---
# Phase 19: Code Review Report # Phase 19: Code Review Report (Iteration-2 Re-Review)
**Reviewed:** 2026-06-17 **Reviewed:** 2026-06-17
**Depth:** deep **Depth:** deep
**Files Reviewed:** 39 (auth source + routes + PWA + CI) **Files Reviewed:** 41
**Status:** issues_found **Status:** clean
## Summary ## Summary
Phase 19 adds a local username/password authentication mode alongside the existing This is the iteration-2 re-review confirming the fixer correctly applied all 15
OIDC flow. The cryptographic core (scrypt PHC hashing, constant-time compare, dummy-hash findings from the prior review (4 critical, 4 blocker, 7 warning, 4 info). I read
timing defense, HS256 session JWT with boot guards) is implemented carefully and is sound. every listed source file, traced the high-judgment fixes through their full call
The middleware chain (`devAuthBypass → devSessionCookie → localAuthMiddleware → OIDC guard`) chains across module boundaries, ran `tsc --noEmit` on both `@familysync/api` and
and the admin-privilege boundary (`requireAdmin` DB-enforced on every `/api/admin/*` request) `@familysync/pwa` (both clean, exit 0), and confirmed the CI seed parameters and
are correct. inlined scrypt copies all agree. The runtime test suite could not execute in this
sandbox (global-setup requires a live MariaDB with root grants — `ER_ACCESS_DENIED`),
so test verification is static: the relevant assertions were read directly and the
production source typechecks against them.
The serious problems are at the **client↔server API contract boundary** — the exact place **Verdict: all 15 prior findings are correctly and completely resolved. No
a deep cross-file review is meant to catch. Three Phase-19 client functions in regressions, no re-occurrence at other call sites, and no new critical/blocker/warning
`apps/pwa/src/api/client.ts` disagree with their server routes on field names, response issues.** Two low-severity Info observations are recorded below; neither blocks ship.
shape, or status-code handling, so the corresponding features (OIDC-link, admin
create-member, and self-service password change error handling) are broken end-to-end
despite each side individually passing its own unit tests. There is also an
authentication availability defect: the per-IP login lockout is mislabeled as
"account locked," is global, and never expires — a single attacker IP can permanently
deny login for the whole household with no self-recovery path.
## Critical Issues ### Confirmation of high-judgment fixes (verified by tracing, not just diff)
### CR-01: OIDC-link flow is broken — client/server response-shape mismatch - **CR-01/02/03 (clientserver contracts):**
- CR-01 shared-calendar: `admin.ts` `PUT /calendars/:id/shared` now does an
existence check inside a transaction (404 on missing id), so a stale id can no
longer silently clear the shared lane. Client `setSharedCalendar` agrees.
- CR-02 create-member: client `fetchCreateMember` maps `password → initialPassword`
(client.ts:199-204) and the server `createMemberSchema` requires `initialPassword`
(admin.ts:140); 409 maps to the `'conflict'` sentinel the AdminPage `onError`
expects (AdminPage.tsx:229). Server returns 409 on `ER_DUP_ENTRY` (admin.ts:202).
Both ends agree; admin.test.ts Tests 12 cover the round-trip + 409 rollback.
- CR-03 wrong-current-password: server returns **403** (`me.ts:267`), client checks
403 **before** the 401 session-expiry branch (client.ts:166) and maps it to
`'wrong-current'`, which `ChangePasswordSheet.onError` surfaces without dropping
the session (SettingsSheet.tsx:569). me.test.ts Test 2 asserts 403 + update-not-called.
- **CR-04 / WR-06 / IN-03 (login limiter):** the limiter key is the validated,
trimmed username (localAuth.ts:138) — no `x-forwarded-for`/IP residue remains
anywhere in `routes/localAuth.ts` or `src/auth/*` (grep clean). 423 lockout
auto-expires after `LOCKOUT_TTL_MS` (15 min, localAuth.ts:149-155); admin reset
calls `resetLoginAttempts(credRow.username)` for an instant unlock (admin.ts:262).
WR-06: the 429 branch deliberately does **not** re-arm `lockedUntil`
(localAuth.ts:164-169), so a rejected attempt can no longer slide the window
forward; the window is only re-anchored by a genuine failure in the failure path.
IN-03: `evictStaleLoginAttempts` only drops entries that are both window-expired
and lockout-TTL-expired (localAuth.ts:113-121) — behaviourally identical to natural
expiry, so eviction never weakens the brute-force defense. Tests 4, 5, 5b cover it.
- **BL-01 (dev-bypass secret floor):** `devSessionCookieMiddleware` applies the same
`>= 32` length floor before minting a real DEV_USER session JWT (devBypass.ts:144-151)
and warns on the well-known placeholder. The CI placeholder
`dev-secret-change-me-0000000000000000` is 36 chars, so it passes the floor and only
triggers the warning — intended.
- **BL-02 (logout cookie Secure match):** `clearLocalSessionCookie` now mirrors the
issue-time `secure: NODE_ENV==='production'` (localSession.ts:114), so the
delete-cookie is accepted over plain HTTP and the user is actually logged out on
non-HTTPS deployments. `sameSite`/`path`/`httpOnly` also match issue-time.
- **BL-03 (OIDC-link takeover guard):** `/callback` (index.ts:84-123) now enforces
three gates before binding: (1) single-use nonce via `consumeLinkNonce`; (2) the
initiating local session must still match `linkUserId`
(`verifyLocalSessionCookie(c) === linkUserId`); (3) `iss`/`sub` from `getAuth` must
be non-empty. `/callback` is registered outside `/api/*` so `localAuthMiddleware`
does not run, but the `local-session` cookie (path `/`) is still present and read
directly — the cross-check is effective. All three gates fail-closed to
`/?error=oidc-link-conflict`. The preflight conflict check in `linkOidcToUser`
(linkOidc.ts:65-74) remains the backstop.
- **BL-04 (no fabricated identity sentinels):** `localAuthMiddleware` passes through
the DB `oidcIss`/`oidcSub` as `?? null` (localAuthMiddleware.ts:91-97); `ContextUser`
widens both to `string | null` (devBypass.ts:56-62). No `'local'`/`String(id)`
sentinels are written, so a local user cannot collide in the `uniq_oidc_identity`
domain. localAuthMiddleware.test.ts Test 1c pins null.
- **WR-03 (async scrypt):** `hashPassword`/`verifyPassword` are async over the libuv
threadpool (localCredentials.ts:33-45, 65, 101) at every call site — login
(dummy-hash promise awaited, localAuth.ts:128/199), create-member (hash before the
transaction, admin.ts:159), admin reset, and self-change. The always-run dummy-hash
path preserves the timing-oracle defense (localAuth.ts:197-199). reset-admin.ts
legitimately keeps `scryptSync` (standalone CLI, no event loop to starve).
- **IN-04 (single-use nonce):** `linkNonceStore.ts` records the nonce at issue
(me.ts:333) and `consumeLinkNonce` returns true exactly once per unexpired nonce,
with opportunistic sweep keeping the map bounded; `/callback` consumes before binding.
**File:** `apps/pwa/src/api/client.ts:226-238` and `apps/api/src/routes/me.ts:301-341` ### Cross-cutting checks
**Issue:** `fetchLinkOidc()` reads `result.redirectUrl` and its return type is
`{ redirectUrl: string }`. The server's `POST /api/me/link-oidc` returns
`{ signedState, authorizationUrl }` — there is no `redirectUrl` key. The caller
(Surface 13 "Link OIDC") will navigate to `undefined`, so the entire OIDC-link feature
(AUTH-LOCAL-10) cannot work in the browser. Each side's own unit tests pass because
neither test crosses the boundary. Additionally `authorizationUrl` can legitimately be
`null` (OIDC unconfigured), which the client type does not model.
**Fix:** Make the contract agree. Either return `redirectUrl` from the server, or read
`authorizationUrl` on the client and handle `null`:
```ts
export async function fetchLinkOidc(): Promise<{ authorizationUrl: string | null }> {
// ...
return res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>;
}
// caller:
const { authorizationUrl } = await fetchLinkOidc();
if (authorizationUrl) window.location.href = authorizationUrl;
```
### CR-02: Admin "create member" always fails — request field-name mismatch - Inlined PHC scrypt parameters agree across all three copies: canonical module
(N=16384,r=8,p=1,keylen=32), `reset-admin.ts`, and `.gitea/workflows/ci.yml`
**File:** `apps/pwa/src/api/client.ts:176-194` and `apps/api/src/routes/admin.ts:123-133` (seed step lines 309-310). localCredentials.test.ts Test 6 pins this round-trip.
**Issue:** `fetchCreateMember` POSTs `{ displayName, username, password }`. The server's - `oidcConfig.ts` (`resolveOidcConfig` + `discoverAuthorizationEndpoint`) is the single
`createMemberSchema` requires `{ displayName, username, initialPassword }`. The `password` env-OR-app_config source now shared by `/api/auth/mode`, the fallback middleware, and
field is ignored and `initialPassword` is missing, so Zod validation fails and the route `me.ts` link-oidc, closing the WR-04 divergence where link-oidc could return
returns `400 { error: 'Invalid request' }` (via `noEchoHook`) for *every* valid admin `authorizationUrl:null` while `/mode` reported `oidcEnabled:true`.
attempt to create a local member (AUTH-LOCAL-07). The Admin UI maps a non-409 error to - `LOCAL_SESSION_EXPIRES` NaN-coercion guard (localSession.ts:35-38) and boot guards
the generic "Something went wrong" banner, so the admin can never create an account. (`assertLocalSessionSecretSet` >= 32, exempt under bypass) are correct and wired
**Fix:** Send the field the server expects: first in the `isMainModule()` block (index.ts:262-265).
```ts - Migration `0003_warm_deathstrike.sql` matches the `localCredentials` Drizzle schema
body: JSON.stringify({ (unique on user_id and username, FK cascade, varchar(256) hash).
displayName: body.displayName,
username: body.username,
initialPassword: body.password,
}),
```
### CR-03: Self-service password change logs the user out on a wrong current password
**File:** `apps/pwa/src/api/client.ts:148-165` and `apps/api/src/routes/me.ts:233-261`
**Issue:** `fetchChangePassword` treats `res.status === 401` as `SessionExpiredError`,
which the global MutationCache handler interprets as "session expired → arm the
re-auth interstitial / redirect to login." But `POST /api/me/password` returns **401**
`{ error: 'Current password incorrect' }` when the supplied current password is wrong
(me.ts:259-260). So a user who simply mistypes their current password is forcibly logged
out instead of seeing "current password incorrect." The client doc comment even claims
"401 → wrong current password" while the code routes 401 to `SessionExpiredError`. The
documented 422 validation branch also never fires — the server returns 400 (noEchoHook)
or 404, not 422.
**Fix:** Distinguish auth-expiry from an in-app 401. Have the route return a distinct
status for "wrong current password" (e.g. 403 or a body code), and branch on it client-side
before treating 401 as session expiry:
```ts
if (res.status === 401) {
const body = await res.json().catch(() => ({}));
if (body?.error === 'Current password incorrect') throw new Error('wrong-current');
throw new SessionExpiredError();
}
```
### CR-04: Login lockout is per-IP, global, permanent, and unrecoverable
**File:** `apps/api/src/routes/localAuth.ts:55-140`
**Issue:** Multiple correctness/availability defects in one mechanism:
1. The `loginAttempts` map is keyed by **IP**, yet the 423 response and the PWA banner say
"This account is temporarily locked." It is neither account-scoped nor temporary.
2. Once `count >= LOCKOUT_FAILURES (10)`, `lockedOut` is set permanently. The only documented
clear path is "admin password reset" — but no admin route ever clears `loginAttempts`
(admin.ts reset-password updates the hash, not the in-memory map). So **the lockout is
genuinely unrecoverable without a process restart**.
3. Because it is per-IP and all household traffic arrives via the Pangolin tunnel with the
same `X-Forwarded-For` first hop, one bad actor (or one user fat-fingering 10 times) can
lock out **every** member at that egress IP. This is a self-inflicted DoS on a 2-person
household whose entire reason for existing is low-friction access.
4. `X-Forwarded-For` is attacker-controllable on any request that does not pass through the
trusted proxy; an attacker can rotate the header to get unlimited fresh rate-limit
buckets, defeating the brute-force defense entirely while still being able to lock
*other* identities by spoofing their IP if it were ever known.
**Fix:** Re-scope the limiter to the submitted username (not IP), make the 423 lockout
expire on a timer (or actually wire admin reset to clear it), correct the banner copy, and
only trust `X-Forwarded-For` when the request demonstrably came from the known proxy
(or use the leftmost-trusted hop). At minimum, give the lockout a TTL so a restart is not
required:
```ts
// derive key from the validated username, and expire lockout after N minutes
const LOCKOUT_TTL_MS = 15 * 60 * 1000;
if (attempt?.lockedOut && Date.now() < attempt.lockedUntil) { /* 423 */ }
```
## Blockers
### BL-01: `devSessionCookieMiddleware` issues a session for user id=1 without verifying the user exists or the secret is strong
**File:** `apps/api/src/auth/devBypass.ts:102-131` and `apps/api/src/lib/bootGuards.ts:53-66`
**Issue:** In dev-bypass mode the boot guard `assertLocalSessionSecretSet()` is *skipped*
entirely (returns early when `DEV_AUTH_BYPASS==='true'`). `devSessionCookieMiddleware` then
only checks that `LOCAL_SESSION_SECRET` is *present* (truthy), not that it is ≥32 chars, and
mints a real, fully-valid `local-session` JWT for `DEV_USER.id (=1)`. The CI sets a weak
fixed secret `'dev-secret-change-me-0000000000000000'`. Any cookie minted under bypass is a
genuine, signature-valid session token for user 1 — if that same weak/known secret is ever
present in a non-bypass environment (e.g. an operator copies the dev compose), forged
sessions are trivial. The hard `NODE_ENV==='production'` guard mitigates the worst case, but
this is a latent footgun: the boot guard's length check is the documented defense and it is
bypassed here.
**Fix:** Apply the same `secret.length >= 32` floor inside `devSessionCookieMiddleware`
before issuing, and emit a loud warning if the dev secret is the placeholder value. Do not
treat "present" as "safe."
### BL-02: Logout cannot clear the cookie in non-production (attribute mismatch)
**File:** `apps/api/src/auth/localSession.ts:53-59` vs `97-106`
**Issue:** `issueLocalSessionCookie` sets `secure: process.env.NODE_ENV === 'production'`
(i.e. `secure:false` in dev/test over HTTP). `clearLocalSessionCookie` hard-codes
`secure: true`. Browsers require the `Secure` attribute on a deletion cookie to match the
context: over plain HTTP a `Secure` delete-cookie is rejected, so `POST /local/logout`
returns 200 but the `local-session` cookie is **not actually cleared** in any non-HTTPS
deployment (local dev, and any HTTP-only self-host). The user appears logged in after
"logout." The inline comment acknowledges the mismatch but waves it away with "logout should
happen over HTTPS" — that is an unsafe assumption for a self-hosted app that explicitly
supports private-IP/HTTP internal access.
**Fix:** Mirror the issue-time logic on delete:
```ts
deleteCookie(c, COOKIE_NAME, {
path: '/', httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax',
});
```
### BL-03: OIDC-link binding swallows `processOAuthCallback` failure and can bind on a stale/blank identity
**File:** `apps/api/src/index.ts:57-101`
**Issue:** In the `/callback` link path the code calls `processOAuthCallback(c)` then
`getAuth(c)` and binds whatever `iss`/`sub` it finds to `linkUserId`. Two problems:
(1) `iss`/`sub` fall back to `''` (`auth.iss ?? ''`, `auth.sub ?? ''`); if `getAuth` returns
a partially-populated session, `linkOidcToUser(linkUserId, '', '')` will write
`oidc_iss=''`/`oidc_sub=''` onto the user and **delete their local_credentials** — locking
them out of both auth methods. (2) `linkUserId` comes from a JWT signed with
`LOCAL_SESSION_SECRET`, but nothing verifies the *currently authenticated session* matches
`linkUserId`; the signed-state CSRF defense assumes the state can only have been produced by
`POST /api/me/link-oidc`, but the callback binds to `linkUserId` regardless of who completes
the OIDC login. An attacker who can get a victim to complete an OIDC login while replaying a
captured (still-valid, 10-min) link state binds the *attacker's* OIDC identity to the
*victim's* account — account takeover.
**Fix:** Reject the bind unless `iss` and `sub` are both non-empty, and cross-check that the
OIDC identity being bound is the one the initiating user intended (e.g. require the
post-callback session's subject to be confirmed by the user, or bind only when the
initiating local session is still present and matches `linkUserId`). Never call
`linkOidcToUser` with empty iss/sub.
### BL-04: `localAuthMiddleware` fabricates `oidcSub` collisions for local users
**File:** `apps/api/src/auth/localAuthMiddleware.ts:88-94` and `apps/api/src/db/schema.ts:60-64`
**Issue:** When populating context for a local user with null OIDC fields, the middleware
substitutes `oidcIss: 'local'` and `oidcSub: String(row.id)`. This is only a context shape
and is not persisted by the middleware — but `me.ts:resolveUserId` (the OIDC path) and
`upsertUser` key identity on `iss+sub`, and the `users` table has
`unique('uniq_oidc_identity').on(oidcIss, oidcSub)`. If any code path ever upserts using the
context's `('local', String(id))` pair (e.g. a future call to `upsertUser` with these
values), two local users would deterministically collide or a local user could shadow a real
OIDC identity whose `(iss,sub)` happened to equal `('local','<n>')`. The fabricated values
leak a synthetic identity namespace that overlaps the real one.
**Fix:** Keep the context `oidcIss/oidcSub` as `null` for local users (widen the
`ContextVariableMap` `user` type to allow null) rather than inventing `'local'`/`String(id)`
sentinels that share a uniqueness domain with real OIDC identities.
## Warnings
### WR-01: `reset-admin.ts` interpolates the username into a log line and trusts `--password ''`
**File:** `apps/api/scripts/reset-admin.ts:60-66, 123, 133`
**Issue:** Two issues. (1) The arg parser treats any token starting with `--` as a new flag,
so `--password --foo` yields `password=''`; combined with the dry-run branch the validation
is loose. More importantly a password that legitimately begins with `--` (or is the empty
string) is silently coerced to `''`. (2) The username is interpolated directly into
`console.log(... username="${username}")`; while not an injection into SQL (queries are
parameterized — good), logging the username is a minor info disclosure for a break-glass tool
and inconsistent with the password-never-logged contract.
**Fix:** Parse `--password=value` and `--password value` explicitly; do not infer empty
strings from a following flag. Avoid echoing the username, or document it as acceptable.
### WR-02: `me.ts` builds the OIDC authorization URL with a hardcoded Authelia path
**File:** `apps/api/src/routes/me.ts:331`
**Issue:** `new URL(`${issuer}/api/oidc/authorization`)` hardcodes Authelia's authorization
endpoint path. The project's stated design is to discover endpoints via
`/.well-known/openid-configuration` (the whole reason `@hono/oidc-auth` is used). Any
non-Authelia or differently-mounted provider will get a wrong URL. This is a latent
correctness bug that compounds CR-01.
**Fix:** Resolve the authorization endpoint from the discovery document rather than assuming
`/api/oidc/authorization`.
### WR-03: `scryptSync` blocks the event loop on the login hot path
**File:** `apps/api/src/auth/localCredentials.ts:42-53, 71-90` and `localAuth.ts:127-129`
**Issue:** `verifyPassword` always runs `scryptSync` (N=16384) synchronously, including the
dummy-hash branch on every failed/unknown login. The file comment justifies this for a
2-person household, but combined with the per-IP rate limiter and the always-run dummy hash,
a burst of unauthenticated `POST /local/login` requests can pin the single Node event loop
(each scrypt is ~tens of ms of blocking CPU) and stall *all* other API traffic — a cheap
unauthenticated DoS. The rate limiter does not protect this because the scrypt runs *before*
the failure counter is consulted on the dummy path for new IPs.
**Fix:** Use `promisify(scrypt)` (async) so hashing does not block the loop, as the comment
itself suggests. This keeps the timing-defense property while preventing loop starvation.
### WR-04: `authMode` / OIDC-enabled detection diverges across three files
**File:** `apps/api/src/routes/authMode.ts:33-49`, `apps/api/src/auth/middleware.ts:62-114`,
`apps/api/src/routes/me.ts:323-328`
**Issue:** Three independent notions of "is OIDC configured": `authMode` checks
`OIDC_ISSUER` env OR `app_config.oidc_issuer`; the fallback middleware injects
issuer/client-id/external-url from app_config; but `me.ts` link-oidc only builds a URL when
`OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_REDIRECT_URI` are all in **env** (it never consults
app_config). So a wizard-configured-but-not-restarted instance reports `oidcEnabled:true`
from `/api/auth/mode`, shows the "Link OIDC" button, but `link-oidc` returns
`authorizationUrl:null` — inconsistent state surfaced to the user.
**Fix:** Centralize the "OIDC configured" resolution (env-or-app_config) in one helper and
use it in all three sites.
### WR-05: `noEchoHook` return value is ignored by `@hono/zod-validator` in one of two styles
**File:** `apps/api/src/routes/localAuth.ts:39-43`, `apps/api/src/routes/admin.ts:74-78`,
`apps/api/src/routes/me.ts:178-182`
**Issue:** The hook signature is `(result, c)` and returns `c.json(...)` only on failure. This
relies on zValidator short-circuiting when the hook returns a Response. That contract holds
for current `@hono/zod-validator`, but the hook does not `return` anything on success and does
not assert `result.success` narrows the type, so a future validator version that requires an
explicit early-return-on-success, or that passes through when the hook returns `undefined`,
would silently start echoing Zod errors (the exact T-19-14 leak this guards against). It is
correct today but fragile and untested for the pass-through case.
**Fix:** Add a focused test asserting that a malformed body never includes `received`/the
submitted value for each hook site (localAuth has one; admin/me password routes should too),
and pin the `@hono/zod-validator` version.
### WR-06: Rate-limit `lockedUntil` is refreshed on every blocked attempt, extending the window indefinitely
**File:** `apps/api/src/routes/localAuth.ts:97-106, 133-137`
**Issue:** On a 429 the code sets `attempt.lockedUntil = Date.now() + RATE_WINDOW_SECS*1000`
again, so an attacker who keeps hitting the endpoint perpetually slides the cooldown forward —
a legitimate user behind the same IP can never get back in even after pausing, because every
attacker request re-arms the window. Coupled with CR-04 this makes the household-wide lock
effectively permanent under sustained traffic.
**Fix:** Do not extend `lockedUntil` on requests that are themselves rejected by the window;
only set it when transitioning from below-threshold to at-threshold.
### WR-07: `parseInt` member/calendar id accepts trailing garbage
**File:** `apps/api/src/routes/admin.ts:215-218, 307-311`
**Issue:** `parseInt(c.req.param('id'), 10)` returns `12` for `"12abc"` and the `isNaN`
guard passes. Not exploitable here (the value is used only in a parameterized `eq`), but it
silently accepts malformed ids and could mask client bugs. The `/members/:id/password` and
`/calendars/:id/shared` routes both use this pattern.
**Fix:** Validate with `Number.isInteger(Number(raw))` or a Zod param schema so `"12abc"` is
rejected with 400.
## Info ## Info
### IN-01: `localSession` `maxAge`/expiry parsing has no validation ### IN-01: `fetchLinkOidc` declared return type is narrower than the cast it returns
**File:** `apps/api/src/auth/localSession.ts:31` **File:** `apps/pwa/src/api/client.ts:250,261`
**Issue:** `Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400)` yields `NaN` for a malformed **Issue:** The function signature declares `Promise<{ authorizationUrl: string | null }>`
value, producing a JWT with `exp = now + NaN` (→ `NaN`) and a cookie `maxAge: NaN`. Verify but the body returns `res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>`.
behavior is then "always expired" or "never expires" depending on the JWT lib's NaN handling. The widening cast is harmless (the only consumer, `SettingsSheet.tsx` `LinkOidcSheet`,
**Fix:** Coerce and validate: `const n = Number(env); SESSION_MAX_AGE = Number.isFinite(n) && n > 0 ? n : 86400;` reads `data.authorizationUrl` only and never `signedState`), and `tsc` is clean. It is a
minor contract-doc inconsistency: the declared type drops a field the server actually
sends. Not a defect — recorded only so the next editor does not "fix" the cast and
accidentally start relying on the absent field.
**Fix:** Align the declared return type with the cast for clarity:
```ts
export async function fetchLinkOidc(): Promise<{ signedState: string; authorizationUrl: string | null }> {
```
### IN-02: Duplicated inline scrypt implementation across three locations ### IN-02: 429 rate-limit branch short-circuits before the dummy-hash work
**File:** `apps/api/scripts/reset-admin.ts:45-51`, `.gitea/workflows/ci.yml:307-311`, **File:** `apps/api/src/routes/localAuth.ts:162-177`
`apps/api/src/auth/localCredentials.ts:42-53` **Issue:** Once an identity is in the 429 window, the handler returns before the DB
**Issue:** The PHC scrypt hash is copy-pasted in the reset-admin script, the CI seed step, lookup and the always-run `verifyPassword`/dummy-hash. This is a deliberate and correct
and the canonical module. If the parameters ever change (the file comment advertises DoS/throughput tradeoff (a rate-limited identity should not pay scrypt cost), and it does
parameter evolution as a feature), these three drift and produce incompatible hashes. The NOT leak username existence because the 429 path is reached identically for valid and
duplication is documented as necessary (cannot import compiled TS from a plain script), but invalid usernames (the limiter is keyed on the submitted username regardless of whether a
there is no test asserting the three stay in lockstep. credential row exists). The timing-oracle defense is only required on the *credential-check*
**Fix:** Add a test that imports `hashPassword` and asserts a known input round-trips against path, which still always runs the dummy hash. Recorded for completeness; no change needed.
a hash produced by the inlined parameters, so a parameter change fails CI loudly. **Fix:** None required. If a future reviewer wants strict constant-time even under
rate-limiting, the dummy-hash could be awaited before the 429 return — but that would
### IN-03: `loginAttempts` map is unbounded (memory growth) re-introduce the exact event-loop-starvation cost WR-03 removed, so leaving it as-is is
the right call.
**File:** `apps/api/src/routes/localAuth.ts:66`
**Issue:** Entries are only removed on a *successful* login for that IP. Spoofed/rotated
`X-Forwarded-For` values (see CR-04) accumulate map entries with no eviction, a slow memory
leak. Out of strict v1 perf scope, noted because it is reachable by unauthenticated input.
**Fix:** Add periodic eviction of entries whose `lockedUntil` is far in the past.
### IN-04: `me.ts` link-oidc nonce is generated but never persisted/verified
**File:** `apps/api/src/routes/me.ts:312-320` and `apps/api/src/index.ts:60-74`
**Issue:** The signed state carries a `nonce` "to prevent replay," but the `/callback`
handler never records or checks the nonce — it only verifies the JWT signature and reads
`linkUserId`. A captured state JWT is fully replayable within its 10-minute window (the
signature stays valid), so the nonce provides no actual replay protection. This underlies
the takeover concern in BL-03.
**Fix:** Persist issued nonces (or a single-use jti) and reject a state whose nonce was
already consumed, or shorten the window and bind the state to the initiating session cookie.
--- ---
@@ -0,0 +1,113 @@
---
phase: 19-local-auth-no-oidc-mode
audited: 2026-06-17
status: secured
asvs_level: 1
block_on: high
register_authored_at_plan_time: true
threats_total: 28
threats_closed: 28
threats_open: 0
threats_accepted: 2
supply_chain_checks: 2
---
# Phase 19 — Local Auth (No-OIDC Mode): Security Audit
**Audited:** 2026-06-17
**ASVS Level:** 1
**block_on:** high
**Compared against:** main..HEAD
**Audit type:** Retroactive threat-mitigation verification (declared register, no net-new scan)
**Branch:** `gsd/phase-19-local-auth-no-oidc-mode`
**Verdict:** SECURED — 28/28 threats closed (26 mitigate + 2 accept), 0 open, 0 unregistered flags
Implementation files were treated as READ-ONLY. No implementation file was modified by this audit.
---
## Threat Verification
| Threat ID | Category | Disposition | Status | Evidence (file:line) |
|-----------|----------|-------------|--------|----------------------|
| T-19-01 | Information Disclosure | mitigate | CLOSED | `apps/api/src/auth/localCredentials.ts:66` (16-byte randomBytes salt), `:114` timingSafeEqual, `:115-118` verify never throws; no password logged |
| T-19-02 | Spoofing | mitigate | CLOSED | `apps/api/src/auth/localSession.ts:58` Jwt.sign HS256 w/ LOCAL_SESSION_SECRET; `:90-94` verify returns null on tamper/expiry |
| T-19-03 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/lib/bootGuards.ts:53-66` assertLocalSessionSecretSet (exit 1 when unset/<32, exempt in bypass); wired `apps/api/src/index.ts:265` |
| T-19-04 | Tampering | mitigate | CLOSED | `.dockerignore:7` `apps/api/scripts/`, `:21` `apps/api/tests/`, `:23` `apps/pwa/e2e/` |
| T-19-SC(01) | Tampering | mitigate | CLOSED | `git diff main...HEAD` shows zero dependency-line changes in any package.json |
| T-19-05 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/routes/admin.ts:47` `adminRouter.use('*', requireAdmin)` is first statement; test asserts 403 |
| T-19-06 | Information Disclosure | mitigate | CLOSED | `apps/api/src/routes/admin.ts:75-79` noEchoHook on create/reset; `:148,:239` no-log comments honored |
| T-19-07 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/routes/me.ts:240` resolveUserId from session, `:260-268` verifyPassword(current) before update |
| T-19-08 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/auth/linkOidc.ts:65-74` preflight conflict before any write; backstop `uniq_oidc_identity` in `migrations/0000_baseline.sql` |
| T-19-09 | Tampering | mitigate | CLOSED | `apps/api/src/routes/me.ts:321-333` per-request nonce in signed HS256 state; `linkNonceStore.ts:41-48` single-use consume |
| T-19-10 | Tampering | mitigate | CLOSED | `apps/api/src/routes/admin.ts:165-185` db.transaction wraps users + local_credentials; `:202` 409 rolls back |
| T-19-11 | Elevation of Privilege | mitigate | CLOSED (deviation noted) | `apps/api/src/routes/localAuth.ts:162-176` 5→429 / 10→423; `:96-98`/admin.ts:262 admin reset clears. Keyed on **username** not IP (CR-04, documented) |
| T-19-12 | Information Disclosure | mitigate | CLOSED | `apps/api/src/routes/localAuth.ts:128` dummyHashPromise, `:197-199` verifyPassword always run, `:210` identical 401 body |
| T-19-13 | Spoofing | mitigate | CLOSED | `apps/api/src/index.ts:191-197` OIDC guard wrapped to skip when `c.get('user')` set; `localAuthMiddleware.ts:45-101` populates it |
| T-19-14 | Information Disclosure | mitigate | CLOSED | `apps/api/src/routes/localAuth.ts:47-51` noEchoHook on login route |
| T-19-15 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/index.ts:84-133` callback: nonce consume + BL-03 session-match + empty-iss/sub guard + linkOidcToUser conflict (409) |
| T-19-16 | Information Disclosure | accept→mitigate | CLOSED | D-06 applied; Phase-19-touched PWA files render no "Authelia" (see T-19-21) |
| T-19-17 | Spoofing | mitigate | CLOSED | `apps/api/src/routes/localAuth.ts:216` fresh issueLocalSessionCookie every success; `localSession.ts:50-55` exp claim bounds lifetime |
| T-19-18 | Information Disclosure | mitigate | CLOSED | `apps/pwa/src/routes/LoginPage.tsx` + `SettingsSheet.tsx` password in useState only; no localStorage/sessionStorage write for password fields |
| T-19-19 | Information Disclosure | mitigate | CLOSED | `apps/pwa/src/routes/LoginPage.tsx:291` single "Incorrect username or password." — no field-level blame |
| T-19-20 | Tampering | mitigate | CLOSED | No `dangerouslySetInnerHTML` in any PWA src (grep across `apps/pwa/src/` = 0 usages; only prohibition comments) |
| T-19-21 | Information Disclosure | mitigate | CLOSED | `grep -ci authelia` == 0 in LoginPage/AdminPage/BrandSlot; SettingsSheet's 1 hit is a copywriting-rule comment (line 828), not rendered |
| T-19-22 | Elevation of Privilege | accept | CLOSED | Documented accepted risk (below); server boundary verified at `admin.ts:47` requireAdmin |
| T-19-23 | Elevation of Privilege | mitigate | CLOSED | Seed only in `apps/pwa/e2e/global-setup.ts` (.dockerignore'd); zero seed in `migrations/` or `index.ts` |
| T-19-24 | Elevation of Privilege | mitigate | CLOSED | `apps/api/src/auth/devBypass.ts:87,:122` NODE_ENV==='production' is FIRST check; `bootGuards.ts:26-34` assertNotDevBypassInProduction |
| T-19-25 | Tampering | mitigate | CLOSED | `.dockerignore:7` excludes `apps/api/scripts/`; `reset-admin.ts:26-32` NODE_ENV=production throw is first executable statement |
| T-19-26 | Information Disclosure | mitigate | CLOSED | `reset-admin.ts` logs only user id / status; no console statement emits the password value; `--dry-run` validates without writing (`:129-133`) |
| T-19-SC(05) | Tampering | mitigate | CLOSED | Zero new packages (same as T-19-SC(01)) |
---
## Deviation Note — T-19-11 (rate-limit key)
The register declares "per-IP rate-limit". The implementation (`localAuth.ts`, CR-04) keys the
limiter on the **submitted username**, not the client IP. This is a deliberate, documented
deviation: in this Pangolin-tunnel deployment all household traffic shares one X-Forwarded-For
first hop (so IP-keying let one actor lock out every member) and X-Forwarded-For is spoofable.
The declared security property — brute-force resistance via 5→429 and 10→423 with admin-reset
recovery and a self-healing TTL — is fully present. Treated as CLOSED. The register wording is
stale relative to the shipped (stronger-for-this-topology) mechanism.
---
## Accepted Risks Log
| Threat ID | Risk | Rationale |
|-----------|------|-----------|
| T-19-22 | Client `isAdmin` / `hasLocalCredential` flags are UX-only and trivially editable in the browser. | Accepted: these flags only gate PWA nav/affordances. The real authorization boundary is server-side `requireAdmin` on every `/api/admin/*` request (`admin.ts:47`) and session-derived `resolveUserId` on `/api/me/*`. Client gating is never the security boundary. Documented prior decision. |
| T-19-16 | "Authelia" provider name could leak infrastructure detail in UI/comments. | Low-severity hygiene (accept→mitigate). D-06 applied across Phase-19-touched surfaces; remaining occurrences are in the out-of-scope Phase 12 `SetupPage.tsx` wizard and in source comments, not on the local-auth surfaces this phase introduced. |
---
## Unregistered Flags
The two `## Threat Flags` entries in `19-04-SUMMARY.md`
(`threat_flag: credential-in-controlled-state` for `LoginPage.tsx` and `SettingsSheet.tsx`)
both map to existing register threats **T-19-18** (password in client storage). Informational
only — no unregistered attack surface. No WARNING raised.
---
## Out-of-Scope Observation (non-blocking, not a Phase 19 gap)
`apps/pwa/src/routes/SetupPage.tsx` (lines 379, 498, 627, 645, 695) renders the literal string
"Authelia" in the first-run setup wizard. This file was **not** modified in Phase 19
(`git diff main...HEAD` = no changes) — it is the pre-existing Phase 12 wizard, outside the
T-19-21 mitigation scope ("any PWA source touched by this plan"). It does not affect the local-auth
login/admin/settings surfaces. Flagged here for a future D-06 sweep of the setup wizard; it is
**not** an open Phase 19 threat.
---
## Security Audit 2026-06-17
| Metric | Count |
|--------|-------|
| Threats found | 28 |
| Closed | 28 |
| Open | 0 |
| Accepted | 2 |
| Supply-chain checks | 2 |
@@ -1,12 +1,35 @@
--- ---
phase: 19-local-auth-no-oidc-mode phase: 19-local-auth-no-oidc-mode
created: 2026-06-17T18:25:00Z created: 2026-06-17T18:25:00Z
updated: 2026-06-17T18:25:00Z updated: 2026-06-17T21:20:00Z
status: pending status: complete
source: verification + plan-checkpoints source: verification + plan-checkpoints
gaps: [] gaps: []
findings_routed_to_phase_17: [F-01, F-02, F-03, F-04]
--- ---
## Live UAT Session (resumed 2026-06-17, post code-review-fix)
**Stack configured for local-auth / no-OIDC mode** (Phase 19's canonical deployment):
- API rebuilt from the phase-19 branch (all 18 code-review fixes live; verified `initialPassword` + 403 present in running `dist`).
- `DEV_AUTH_BYPASS=false` and `OIDC_ISSUER=""` via throwaway `docker-compose.uat.yml` override
(tracked files untouched; restore the normal bypass stack after UAT).
- Seeded local admin: **username `uatadmin` / password `UATtest1234!`** (user id 2, is_admin=1).
- PWA on host Vite at **http://localhost:5173**.
**Automated API smoke (pre-checks):**
-`POST /api/auth/local/login` (uatadmin) → 200 + `local-session` cookie.
- ✅ Wrong password → 401 `{"error":"Invalid credentials"}` (generic, no field blame).
- ✅ Authenticated `GET /api/me``{id:2, isAdmin:true, hasLocalCredential:true}`.
- ⚠️ Unauth `GET /api/me`**500 `Invalid session`** (not 401) in no-OIDC mode: the OIDC guard
is mounted whenever bypass is off and errors trying to redirect with a blank issuer. **Cosmetic**
— PWA gates on `meQuery.isError && localEnabled` (App.tsx:213) so it still redirects to `/login`.
Candidate follow-up: short-circuit the OIDC guard to a clean 401 when no issuer is configured.
**Note on Item 1 OIDC button:** the "OIDC button appears when oidcEnabled" sub-check can't be
exercised on this box (no reachable Authelia → blanked). Covered at unit/e2e level. This session
verifies the no-OIDC local-auth surface, which is the phase's primary deliverable.
# Phase 19: Local Auth (No-OIDC Mode) — User Acceptance Tests # Phase 19: Local Auth (No-OIDC Mode) — User Acceptance Tests
All automated verification passed (API 446/446, PWA 266/266, e2e desktop 42 passed All automated verification passed (API 446/446, PWA 266/266, e2e desktop 42 passed
@@ -32,6 +55,10 @@ goal achievement but should be confirmed before shipping.
harness (covered at unit level in `App.test.tsx`); the full visual flow needs a real harness (covered at unit level in `App.test.tsx`); the full visual flow needs a real
browser against a non-bypass deployment. (Desktop/Chromium portions are already e2e- browser against a non-bypass deployment. (Desktop/Chromium portions are already e2e-
covered via `login.spec.ts`.) covered via `login.spec.ts`.)
- **result: pass** (2026-06-17, live no-OIDC stack, operator-confirmed — all steps:
redirect to /login, brand slot, username autofocus, password show/hide, generic
wrong-creds error, successful login into the app). Setup-gate precedence confirmed:
`setupComplete===true` so /login is reachable (App.tsx checks `/setup` redirect first).
### 2. Admin Reset-password sheet (live, end-to-end) ### 2. Admin Reset-password sheet (live, end-to-end)
- **Test:** As an admin, open Admin → Local Accounts → Reset password for a member; - **Test:** As an admin, open Admin → Local Accounts → Reset password for a member;
@@ -39,6 +66,11 @@ goal achievement but should be confirmed before shipping.
- **Expected:** `POST /api/admin/members/:id/password` returns 200; new password works. - **Expected:** `POST /api/admin/members/:id/password` returns 200; new password works.
- **Why human:** Needs a live stack with an admin session and a real local member. - **Why human:** Needs a live stack with an admin session and a real local member.
(URL fix already verified: route reachable, 404 eliminated.) (URL fix already verified: route reachable, 404 eliminated.)
- **result: pass (functional) — with UX gap.** Operator created `testmember` + reset its
password via the admin UI. Verified at DB/login level: member exists (user 3, non-admin);
login with the **reset** pw (`MemberPass456!`) → 200; login with the **original**
(`MemberPass123!`) → 401. So **CR-02 create-member + reset both work and persist.**
BUT neither action showed a success confirmation (see Finding F-01).
### 3. Settings Change-password sheet (live, local user) ### 3. Settings Change-password sheet (live, local user)
- **Test:** As a local user, Settings → Account → Change password; verify wrong current - **Test:** As a local user, Settings → Account → Change password; verify wrong current
@@ -46,6 +78,10 @@ goal achievement but should be confirmed before shipping.
password works on next login. password works on next login.
- **Expected:** Current-password verification enforced; update succeeds; re-login works. - **Expected:** Current-password verification enforced; update succeeds; re-login works.
- **Why human:** Needs a live stack with a local-user session. - **Why human:** Needs a live stack with a local-user session.
- **result: pass** (2026-06-17, verified functionally via API on the live no-OIDC stack as
`testmember`). CR-03 confirmed: wrong current password → **403 (not 401)** and the session
stays valid (`/me`→200, no force-logout); correct current → 200; new password logs in (200),
old password rejected (401).
### 4. Rate-limit / lockout test flakiness (harden) ### 4. Rate-limit / lockout test flakiness (harden)
- **Test:** Run `apps/api/tests/routes/localAuth.test.ts` Test 5 (10 failures → 423) - **Test:** Run `apps/api/tests/routes/localAuth.test.ts` Test 5 (10 failures → 423)
@@ -54,6 +90,12 @@ goal achievement but should be confirmed before shipping.
- **Expected:** Stable pass; if timing-dependent, harden the in-memory rate-limit test - **Expected:** Stable pass; if timing-dependent, harden the in-memory rate-limit test
(e.g. fake timers / deterministic clock). (e.g. fake timers / deterministic clock).
- **Why human:** Timing-dependent in-memory test; needs repeated runs to characterize. - **Why human:** Timing-dependent in-memory test; needs repeated runs to characterize.
- **result: resolved-by-fix.** The code-review fix (CR-04/WR-06/IN-03) rewrote the limiter and
made the lockout test **deterministic** — it back-dates `lockedAt` instead of using wall-clock
timers (`localAuth.test.ts:280`), structurally removing the timing flakiness. The fixer ran the
full API suite **452/452** (incl. Test 5/5b). Couldn't be re-run in this session's shell (no
test-DB root creds — `ER_ACCESS_DENIED`); confirm via CI or `set -a; . ./.env; set +a;
DB_HOST=127.0.0.1 pnpm --filter @familysync/api test`.
### 5. CI harness green + D-15 image boundary (push, outward-facing) ### 5. CI harness green + D-15 image boundary (push, outward-facing)
- **Test:** Push the branch and open the PR so Gitea CI runs. Confirm the `harness` job - **Test:** Push the branch and open the PR so Gitea CI runs. Confirm the `harness` job
@@ -63,3 +105,27 @@ goal achievement but should be confirmed before shipping.
- **Expected:** CI all green; no dev artifact in the shipped image (D-14/D-15). - **Expected:** CI all green; no dev artifact in the shipped image (D-14/D-15).
- **Why human:** Pushing to the remote / triggering CI is an outward-facing action the - **Why human:** Pushing to the remote / triggering CI is an outward-facing action the
operator owns. (Plan 19-05's blocking checkpoint.) operator owns. (Plan 19-05's blocking checkpoint.)
- **result: deferred to `/gsd-ship`** (operator decision 2026-06-17). The push/PR/CI run +
image-hygiene gate is owned by the ship workflow, not this UAT session.
## Live Session Findings (2026-06-17)
Surfaced by the operator during Test 2. **Operator decision (2026-06-17): route ALL four UI
findings — including the logout button — to Phase 17 (UI Optimization & Polish), which has not yet
kicked off. None block Phase 19**, whose auth machinery is functionally complete and verified.
All four added to `17-CONTEXT.md` (Phase-17 branch):
- **F-02 — No logout button in the UI (functional-UI).** Logout is fully plumbed — endpoint
`POST/GET /api/auth/local/logout` returns 200 and clears the cookie (BL-02 verified live), and
`fetchLocalLogout()` exists in `apps/pwa/src/api/client.ts:127` — but **no component calls it**
(grep of `apps/pwa/src` finds zero logout buttons/handlers). Phase 17 wires a logout control to
the existing client function (no backend work). Per operator: a UI concern, not a Phase-19 blocker.
- **F-01 — Admin create/reset give no success feedback.** Both succeed (verified at DB/login level)
but show no success toast/confirmation. Add success feedback to the admin local-account flows.
- **F-03 — Dialogs/popups render at bottom-center instead of properly centered (cosmetic).** Fits
Phase 17's fixed-chrome/sheet-positioning sweep.
- **F-04 — Admin UI navigation is clunky and needs a rework.** UX-polish item for Phase 17.
**Status:** Phase 19 UAT **complete (functional)** — Tests 13 pass (live), Test 4 resolved-by-fix,
Test 5 deferred to `/gsd-ship`. No Phase-19 blockers. F-01F-04 carried to Phase 17.
+58 -4
View File
@@ -13,7 +13,7 @@ FamilySync is a self-hosted, Dockerized family organization hub for a two-person
- **Tech stack**: MariaDB for the database — PostgreSQL is not available in the stack - **Tech stack**: MariaDB for the database — PostgreSQL is not available in the stack
- **Tech stack**: Redis available (optional, for live list sync / push) - **Tech stack**: Redis available (optional, for live list sync / push)
- **Infrastructure**: Unraid host running Docker + Docker Compose - **Infrastructure**: Unraid host running Docker + Docker Compose
- **Auth**: Authelia (already deployed) — OIDC/OAuth2 for the custom app; all members authenticate through it - **Auth**: Authelia (already deployed) + Local Auth — OIDC/OAuth2 for the custom app; all members authenticate through it
- **Calendar backend**: Fastmail (paid, existing) is the single source for all calendars via JMAP/CalDAV - **Calendar backend**: Fastmail (paid, existing) is the single source for all calendars via JMAP/CalDAV
- **Frontend**: React PWA only — no React Native, no App Store - **Frontend**: React PWA only — no React Native, no App Store
- **Networking**: Split-DNS internal domain, private IPs internally; public exposure via Pangolin/Newt tunnel, no open ports - **Networking**: Split-DNS internal domain, private IPs internally; public exposure via Pangolin/Newt tunnel, no open ports
@@ -186,13 +186,46 @@ FamilySync is a self-hosted, Dockerized family organization hub for a two-person
- **Use the `playwright-cli` skill (`.claude/skills/playwright-cli/`) to validate UI and workflows instead of asking the operator to check manually.** When a change touches the PWA, or a UI/UX decision needs grounding in real behavior, drive a real browser with `playwright-cli` and observe — don't prompt the human to do it. This applies to executors and verifiers too: prefer an automated `playwright-cli` check over a `checkpoint:human-verify` task whenever the check runs in a desktop/Chromium browser. - **Use the `playwright-cli` skill (`.claude/skills/playwright-cli/`) to validate UI and workflows instead of asking the operator to check manually.** When a change touches the PWA, or a UI/UX decision needs grounding in real behavior, drive a real browser with `playwright-cli` and observe — don't prompt the human to do it. This applies to executors and verifiers too: prefer an automated `playwright-cli` check over a `checkpoint:human-verify` task whenever the check runs in a desktop/Chromium browser.
- **Exception — genuinely device-only checks still need a human.** iOS-Safari standalone-PWA behavior (Home-Screen install, standalone-mode OIDC redirect, iOS push) cannot be driven by `playwright-cli`; keep those as human checkpoints (e.g. Phase 3 Gate 2 iOS items). - **Exception — genuinely device-only checks still need a human.** iOS-Safari standalone-PWA behavior (Home-Screen install, standalone-mode OIDC redirect, iOS push) cannot be driven by `playwright-cli`; keep those as human checkpoints (e.g. Phase 3 Gate 2 iOS items).
- The `playwright-cli` binary is global (`/usr/local/bin/playwright-cli`). `@playwright/test` is not a repo dependency — install it in `apps/pwa` only if you need the spec-driven test-generation references. - The `playwright-cli` binary is global (`/usr/local/bin/playwright-cli`). `@playwright/test` is not a repo dependency — install it in `apps/pwa` only if you need the spec-driven test-generation references.
<!-- GSD:conventions-end --> <!-- GSD:conventions-end -->
<!-- GSD:architecture-start source:ARCHITECTURE.md --> <!-- GSD:architecture-start source:ARCHITECTURE.md -->
## Architecture ## Architecture
Architecture not yet mapped. Follow existing patterns found in the codebase. FamilySync is a pnpm monorepo with two apps: `apps/api` (Hono 4.x on Node 22 LTS, Drizzle ORM + MariaDB 11, TypeScript) and `apps/pwa` (React 19 + Vite 8 + vite-plugin-pwa). A single Docker Compose stack runs the API container (which also serves the PWA static build) and a MariaDB container, exposed through a Pangolin/Newt tunnel.
The backend handles two auth paths: local username/password (scrypt + HS256 JWT `local-session` cookie) and Authelia OIDC (authorization code + PKCE via `@hono/oidc-auth`). Both populate `c.get('user')`; the OIDC guard is skipped when a valid local session is present. A local user may link an OIDC identity later.
Calendar data lives exclusively in Fastmail CalDAV. The broker layer (`apps/api/src/broker/`) uses `tsdav` for PROPFIND/REPORT/PUT/DELETE, `ical.js` for VCALENDAR parsing, and `rrule` for server-side recurrence expansion. Writes are enqueued in a `calendarOutbox` table and drained asynchronously every 15 seconds; a ctag-based poller re-syncs calendars every 5 minutes.
Lists are persisted in MariaDB. Live list updates flow over SSE (`text/event-stream`) via an in-process Node.js `EventEmitter`; a 30-second polling fallback is always active. Push notifications (reminders + calendar change alerts) are dispatched via `web-push` (VAPID) to APNs/FCM. Redis is present in the stack but not yet used at runtime (reserved for future multi-process pub/sub).
The PWA uses TanStack Query for all server state (events, lists, user, sync status, auth mode) and Zustand for UI-only state (selected date, open panels, active tab).
```text
familysync/
├── apps/
│ ├── api/src/
│ │ ├── index.ts # App entry: mounts routes, starts background workers
│ │ ├── routes/ # HTTP handlers (events, lists, me, push, sse, auth, admin, setup)
│ │ ├── auth/ # Local session + OIDC middleware + dev-bypass + OIDC-link
│ │ ├── broker/ # CalDAV client, sync, poller, outbox worker, RRULE expand, write
│ │ ├── db/ # Drizzle schema, mysql2 pool, migrations
│ │ └── lib/ # List/event emitters, push dispatcher, rank, guards, admin/setup helpers
│ └── pwa/src/
│ ├── App.tsx # BrowserRouter shell
│ ├── routes/ # Page-level components
│ ├── components/ # Shared UI components
│ ├── api/ # Typed fetch wrappers (client.ts, listsClient.ts)
│ ├── hooks/ # useListSSE, usePushSubscription
│ ├── store/ # Zustand stores (calendarStore, listsStore)
│ └── sw.ts # Custom Workbox service worker
├── docker-compose.yml # Production stack (api + mariadb + redis)
└── docker-compose.dev.yml # Dev overrides
```
See `docs/ARCHITECTURE.md` for the full Mermaid component diagram, data-flow walkthroughs, and key abstractions table.
<!-- GSD:architecture-end --> <!-- GSD:architecture-end -->
@@ -224,7 +257,28 @@ Do not make direct repo edits outside a GSD workflow unless the user explicitly
## Developer Profile ## Developer Profile
> Profile not yet configured. Run `/gsd-profile-user` to generate your developer profile. > Generated by GSD from session_analysis. Run `/gsd-profile-user` to update.
> This section is managed by `generate-claude-profile` -- do not edit manually.
| 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:**
- **Communication:** Respond in a natural, conversational register. Expect messages that bundle multiple observations and a directive together -- address each point. Match brevity for short imperative confirmations, but engage the reasoning when the developer thinks aloud.
- **Decisions:** Present options concisely and expect a fast decision. Use clearly enumerated choices so the developer can triage them in one pass. Do not over-deliberate or request repeated confirmation -- move forward once a disposition is given.
- **Explanations:** Give brief explanations focused on the key decision or the 'why this is expected', then the change. Assume the developer reads and understands the implementation. When they ask 'is this fine/correct', answer the specific concern directly rather than expanding into a full tutorial.
- **Debugging:** When debugging, diagnose the root cause before patching and explain what caused the behavior. The developer supplies reproduction detail and often a partial theory -- confirm or refute it directly and pull real evidence (logs, actual config) rather than guessing.
- **UX Philosophy:** Treat UI/UX polish as first-class work, not deferred cleanup. Get layout, copy, placement, and visual feel right during implementation. Aim for a warm, friendly, low-friction aesthetic (rounded, comfortable, 'at home'). Flag and fix UI bugs (centering, navigation, missing controls) proactively.
- **Vendor Choices:** Respect the already-decided stack and the developer's stated infrastructure choices -- do not propose swapping established tools. When a new library or API is in question, expect the developer to want it validated rather than taken on faith. Confirm whether this matches their general tool-selection preference.
- **Frustrations:** Follow standing requirements exactly, especially passing local CI gates (prettier, eslint, gitleaks/secret scan, typecheck) BEFORE pushing -- this is a recurring pain point. When the developer states a fact about their environment, accept it and do not argue from an outdated model. Apply the instructed fix directly rather than re-litigating causes already understood.
- **Learning:** Assume the developer experiments and investigates independently. Answer specific targeted questions precisely rather than offering unsolicited walkthroughs. When introducing something new, point to the concrete thing to check or run so they can verify it hands-on themselves.
<!-- GSD:profile-end --> <!-- GSD:profile-end -->
+12 -8
View File
@@ -96,6 +96,8 @@ docker-compose.dev.yml Dev overrides (bind-mount src/, expose DB/Redis ports)
| `pnpm typecheck` | Type-check all workspaces | | `pnpm typecheck` | Type-check all workspaces |
| `pnpm format` | Reformat all files with Prettier | | `pnpm format` | Reformat all files with Prettier |
| `pnpm format:check` | Check formatting without writing (used in CI) | | `pnpm format:check` | Check formatting without writing (used in CI) |
| `pnpm md:lint` | Lint Markdown files with markdownlint-cli2 |
| `pnpm generate-secrets` | Generate random secrets for `.env` setup |
| `pnpm --filter @familysync/api db:generate` | Generate Drizzle migration from schema changes | | `pnpm --filter @familysync/api db:generate` | Generate Drizzle migration from schema changes |
| `pnpm --filter @familysync/api db:migrate` | Apply pending migrations to MariaDB | | `pnpm --filter @familysync/api db:migrate` | Apply pending migrations to MariaDB |
@@ -128,15 +130,17 @@ See [`docs/deployment.md`](docs/deployment.md) for Unraid/Docker Compose deploym
## CI ## CI
Every PR to `main` must pass three required checks 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 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 / gate` | Aggregate: asserts all jobs above passed or were legitimately skipped |
`fast-checks` and `api`/`harness` run in parallel. 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`.
## Publishing / Releases ## Publishing / Releases
@@ -151,7 +155,7 @@ Publishing happens automatically on every push to `main` — i.e. when a PR merg
**Required secret:** `REGISTRY_PAT` — a Gitea Actions secret holding a PAT with `write:package` scope. Named `REGISTRY_PAT` (not `GITEA_*`): Gitea reserves the `GITEA_` prefix for secret names, so `GITEA_`-prefixed names cannot be created. `GITEA_TOKEN` / `GITHUB_TOKEN` cannot push packages. **Required secret:** `REGISTRY_PAT` — a Gitea Actions secret holding a PAT with `write:package` scope. Named `REGISTRY_PAT` (not `GITEA_*`): Gitea reserves the `GITEA_` prefix for secret names, so `GITEA_`-prefixed names cannot be created. `GITEA_TOKEN` / `GITHUB_TOKEN` cannot push packages.
**Safety gate:** Branch protection on `main`, not a `needs:` dependency in `publish.yml`. The PR test jobs (`fast-checks`, `api`, `harness` in `ci.yml`) run on `pull_request` — they never run in the same workflow invocation as `publish.yml`. Tests gate the PR; `main` is trusted to be green because direct push and force push are blocked and the three required checks (`CI / fast-checks (pull_request)`, `CI / api (pull_request)`, `CI / harness (pull_request)`) must pass before merge. **Safety gate:** Branch protection on `main`, not a `needs:` dependency in `publish.yml`. The PR test jobs (`fast-checks`, `api`, `harness`, `security`, `gate` in `ci.yml`) run on `pull_request` — they never run in the same workflow invocation as `publish.yml`. Tests gate the PR; `main` is trusted to be green because direct push and force push are blocked and the two required checks (`CI / fast-checks` and `CI / gate`) must pass before merge. `CI / api` and `CI / harness` are conditionally skipped on doc-only PRs and are gated via the always-running `CI / gate` aggregate.
**To bump the milestone tag** at a milestone boundary: edit the `MILESTONE` env value at the top of `.gitea/workflows/publish.yml`. **To bump the milestone tag** at a milestone boundary: edit the `MILESTONE` env value at the top of `.gitea/workflows/publish.yml`.
+58 -22
View File
@@ -10,7 +10,9 @@ Part of the [FamilySync monorepo](../../README.md).
- **Calendar broker** — polls Fastmail CalDAV every 5 minutes via `tsdav`; parses iCalendar payloads with `ical.js` and expands recurrence rules with `ical.js`'s `ICAL.RecurExpansion`; writes changes back to Fastmail through an outbox worker - **Calendar broker** — polls Fastmail CalDAV every 5 minutes via `tsdav`; parses iCalendar payloads with `ical.js` and expands recurrence rules with `ical.js`'s `ICAL.RecurExpansion`; writes changes back to Fastmail through an outbox worker
- **Collaborative lists** — creates, reorders (fractional indexing), and syncs grocery/gift lists in MariaDB via Drizzle ORM - **Collaborative lists** — creates, reorders (fractional indexing), and syncs grocery/gift lists in MariaDB via Drizzle ORM
- **OIDC auth** — all `/api/*` routes protected by `@hono/oidc-auth` with authorization-code + PKCE flow against Authelia; `DEV_AUTH_BYPASS=true` skips OIDC for local development - **Auth** — dual-mode: OIDC authorization-code + PKCE flow against Authelia (`@hono/oidc-auth`) for production; local username/password auth (scrypt, JWT session cookie) for no-OIDC or first-boot scenarios. `DEV_AUTH_BYPASS=true` skips both for local development
- **Setup wizard** — `/api/setup/*` surface guides first-run configuration of OIDC, VAPID keys, and member credentials before the app is locked
- **Admin** — role-gated `/api/admin/*` for member management, credential rotation, and calendar sharing designation
- **Live sync** — Server-Sent Events stream list mutations to connected PWA clients in real time - **Live sync** — Server-Sent Events stream list mutations to connected PWA clients in real time
- **Push notifications** — web-push (VAPID) delivers reminders for shared timed events to subscribed browsers - **Push notifications** — web-push (VAPID) delivers reminders for shared timed events to subscribed browsers
@@ -22,17 +24,27 @@ src/
routes/ routes/
events.ts CalDAV event CRUD endpoints events.ts CalDAV event CRUD endpoints
lists.ts List and list-item CRUD endpoints lists.ts List and list-item CRUD endpoints
me.ts Authenticated user profile endpoint me.ts Authenticated user profile endpoint + OIDC-link initiation
push.ts Push subscription registration push.ts Push subscription registration
sse.ts SSE stream for live list updates sse.ts SSE stream for live list updates
health.ts Unauthenticated health check health.ts Unauthenticated health check
setup.ts First-run setup wizard surface (/api/setup/*)
admin.ts Role-gated admin API (members, credentials, calendars)
localAuth.ts Local login/logout endpoints (/api/auth/local/*)
authMode.ts Pre-auth auth-mode discovery (/api/auth/mode)
db/ db/
schema.ts Drizzle table definitions (MariaDB/mysql2) schema.ts Drizzle table definitions (MariaDB/mysql2)
client.ts Drizzle client singleton client.ts Drizzle client singleton
migrations/ SQL migrations generated by drizzle-kit migrations/ SQL migrations generated by drizzle-kit
auth/ auth/
middleware.ts oidcAuthMiddleware + processOAuthCallback middleware.ts oidcAuthMiddleware + processOAuthCallback + oidcConfigFallbackMiddleware
devBypass.ts DEV_AUTH_BYPASS passthrough (non-production only) devBypass.ts DEV_AUTH_BYPASS passthrough (non-production only)
localAuthMiddleware.ts local-session cookie → c.get('user') middleware
localCredentials.ts scrypt password hashing and constant-time verification
localSession.ts HS256 JWT session-cookie issue / verify / clear helpers
linkNonceStore.ts Single-use nonce store for OIDC-link CSRF prevention
linkOidc.ts Atomic OIDC-identity binding + local credential removal
oidcConfig.ts Centralized OIDC config resolution (env OR app_config)
persistSessionCookie.ts Re-issues session cookie as persistent for PWA persistSessionCookie.ts Re-issues session cookie as persistent for PWA
user.ts User upsert on first login user.ts User upsert on first login
broker/ broker/
@@ -40,11 +52,12 @@ src/
outboxWorker.ts 15-second drain of pending CalDAV writes to Fastmail outboxWorker.ts 15-second drain of pending CalDAV writes to Fastmail
reminderScheduler.ts 1-minute scan for upcoming shared events → push reminderScheduler.ts 1-minute scan for upcoming shared events → push
client.ts tsdav client factory client.ts tsdav client factory
credentialSync.ts Shared validate→encrypt→store→initial-sync helper
sync.ts REPORT → ical.js → DB upsert logic sync.ts REPORT → ical.js → DB upsert logic
write.ts CalDAV PUT/DELETE helpers write.ts CalDAV PUT/DELETE helpers
expand.ts recurrence expansion via ICAL.RecurExpansion expand.ts Recurrence expansion via ICAL.RecurExpansion
vevent.ts VEVENT ↔ DB row mapping vevent.ts VEVENT ↔ DB row mapping
crypto.ts AES-256-GCM encrypt/decrypt for stored app passwords crypto.ts AES-256-GCM encrypt/decrypt for stored app passwords (APP_PASSWORD_ENCRYPTION_KEY)
lib/ lib/
listEmitter.ts In-process EventEmitter for SSE fan-out listEmitter.ts In-process EventEmitter for SSE fan-out
listChangeDispatcher.ts Publishes list mutations to listEmitter listChangeDispatcher.ts Publishes list mutations to listEmitter
@@ -53,6 +66,11 @@ src/
pushCoalescer.ts Debounces push for rapid successive edits pushCoalescer.ts Debounces push for rapid successive edits
listAccess.ts List permission helpers listAccess.ts List permission helpers
rank.ts Fractional indexing helpers rank.ts Fractional indexing helpers
bootGuards.ts Boot-time env guards (blocks DEV_AUTH_BYPASS in production; enforces LOCAL_SESSION_SECRET)
setupGuard.ts isSetupLocked() — prevents re-running the wizard after completion
householdTimezone.ts Shared IANA timezone accessor with env fallback
outboxTrigger.ts In-process drain signal between routes and outboxWorker
requireAdmin.ts DB-enforced admin role middleware
``` ```
## Running in the workspace ## Running in the workspace
@@ -107,26 +125,44 @@ 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 |
| `VAPID_SUBJECT` | Yes (push) | `mailto:` or `https:` operator identifier | | `OIDC_REDIRECT_URI` | No | Explicit redirect URI (overrides the `${OIDC_AUTH_EXTERNAL_URL}/callback` default) |
| `VAPID_PUBLIC_KEY` | Yes (push) | VAPID public key | | `VAPID_SUBJECT` | Yes (push) | `mailto:` or `https:` operator identifier |
| `VAPID_PRIVATE_KEY` | Yes (push) | VAPID private key | | `VAPID_PUBLIC_KEY` | Yes (push) | VAPID public key |
| `CREDENTIAL_ENCRYPTION_KEY` | Yes | AES-256-GCM key for stored Fastmail app passwords | | `VAPID_PRIVATE_KEY` | Yes (push) | VAPID private key |
| `DEV_AUTH_BYPASS` | No | Set to `true` (non-production only) to skip OIDC and inject a dev user | | `APP_PASSWORD_ENCRYPTION_KEY` | Yes | AES-256-GCM key (64-char hex) for stored Fastmail app passwords |
| `NODE_ENV` | No | Set to `production` to enforce OIDC unconditionally | | `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.
See [../../docs/CONFIGURATION.md](../../docs/CONFIGURATION.md) for the full reference. See [../../docs/CONFIGURATION.md](../../docs/CONFIGURATION.md) for the full reference.
## Authentication modes
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. |
`GET /api/auth/mode` returns `{ localEnabled, oidcEnabled }` before authentication — the PWA uses this to decide which login form to show.
## Tests ## Tests
Tests live in `tests/` (integration, route, broker unit) and `test/setup.ts` (global setup/teardown). Tests live in `tests/` (integration, route, broker unit) and `test/setup.ts` (global setup/teardown).
+50 -14
View File
@@ -45,22 +45,54 @@ const KEY_LEN = 32;
function hashPassword(password: string): string { function hashPassword(password: string): string {
const salt = randomBytes(16); const salt = randomBytes(16);
const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }); const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P });
return ['scrypt', SCRYPT_N, SCRYPT_R, SCRYPT_P, salt.toString('base64url'), hash.toString('base64url')].join( return [
'$', 'scrypt',
); SCRYPT_N,
SCRYPT_R,
SCRYPT_P,
salt.toString('base64url'),
hash.toString('base64url'),
].join('$');
} }
// ── CLI arg parsing (no new deps — process.argv only) ─────────────────────────────────── // ── CLI arg parsing (no new deps — process.argv only) ───────────────────────────────────
function parseArgs(argv: string[]): Record<string, string> { // WR-01: support both `--key=value` and `--key value`, and parse values EXPLICITLY rather
const result: Record<string, string> = {}; // than inferring an empty string whenever the next token starts with '--'. The old heuristic
// coerced `--password --foo` (and a legitimately `--`-prefixed or empty password) silently to
// ''. Here, known value-taking flags (--username, --password) always consume the next token
// verbatim as their value; the only boolean flag (--dry-run) takes no value. This keeps a
// password that begins with '--', or an intentionally empty password, intact.
const VALUE_FLAGS = new Set(['username', 'password']);
const BOOLEAN_FLAGS = new Set(['dry-run']);
function parseArgs(argv: string[]): Record<string, string | undefined> {
const result: Record<string, string | undefined> = {};
for (let i = 0; i < argv.length; i++) { for (let i = 0; i < argv.length; i++) {
const arg = argv[i]; const arg = argv[i];
if (arg.startsWith('--')) { if (!arg.startsWith('--')) continue;
const key = arg.slice(2);
const value = argv[i + 1] !== undefined && !argv[i + 1].startsWith('--') ? argv[i + 1] : ''; const eq = arg.indexOf('=');
result[key] = value; if (eq !== -1) {
if (value) i++; // skip the value token // `--key=value` form — value is everything after the first '=', taken verbatim
// (so `--password=--weird` and `--password=` both work correctly).
result[arg.slice(2, eq)] = arg.slice(eq + 1);
continue;
} }
const key = arg.slice(2);
if (BOOLEAN_FLAGS.has(key)) {
result[key] = ''; // presence-only flag; detected via hasOwnProperty
continue;
}
if (VALUE_FLAGS.has(key)) {
// Consume the NEXT token verbatim as the value — even if it starts with '--' or is
// empty. If there is no next token, record undefined (genuinely absent, not '').
result[key] = argv[i + 1];
if (i + 1 < argv.length) i++; // skip the consumed value token
continue;
}
// Unknown flag — record presence with no value (forward-compatible, no crash).
result[key] = '';
} }
return result; return result;
} }
@@ -82,7 +114,9 @@ if (!dryRun && (!password || password.trim() === '')) {
} }
if (dryRun && !password) { if (dryRun && !password) {
// In dry-run mode a placeholder password is acceptable — skip real validation // In dry-run mode a placeholder password is acceptable — skip real validation
console.log('[dry-run] Args validated: --username present, --dry-run active (no write will occur)'); console.log(
'[dry-run] Args validated: --username present, --dry-run active (no write will occur)',
);
} }
// ── DB connection ───────────────────────────────────────────────────────────────────────── // ── DB connection ─────────────────────────────────────────────────────────────────────────
@@ -120,7 +154,8 @@ try {
// Existing local_credentials row — update password and ensure is_admin // Existing local_credentials row — update password and ensure is_admin
userId = lcRows[0].user_id; userId = lcRows[0].user_id;
await conn.execute('UPDATE users SET is_admin = true, claimed = true WHERE id = ?', [userId]); await conn.execute('UPDATE users SET is_admin = true, claimed = true WHERE id = ?', [userId]);
console.log(`[reset-admin] Found existing user id=${userId} for username="${username}"`); // WR-01: do not echo the username — log only the resolved user id (no credential data).
console.log(`[reset-admin] Found existing user id=${userId}`);
} else { } else {
// No existing row — insert a new user // No existing row — insert a new user
const displayName = username; const displayName = username;
@@ -130,7 +165,8 @@ try {
[displayName], [displayName],
); );
userId = (insertResult as unknown as { insertId: number }).insertId; userId = (insertResult as unknown as { insertId: number }).insertId;
console.log(`[reset-admin] Created new user id=${userId} for username="${username}"`); // WR-01: do not echo the username — log only the resolved user id.
console.log(`[reset-admin] Created new user id=${userId}`);
} }
// ── Upsert local_credentials row ───────────────────────────────────────────────────── // ── Upsert local_credentials row ─────────────────────────────────────────────────────
@@ -142,7 +178,7 @@ try {
[userId, username, passwordHash], [userId, username, passwordHash],
); );
console.log(`[reset-admin] Local credential upserted for user id=${userId} username="${username}"`); console.log(`[reset-admin] Local credential upserted for user id=${userId}`);
console.log(`[reset-admin] Done. User id=${userId} is now a local admin.`); console.log(`[reset-admin] Done. User id=${userId} is now a local admin.`);
} finally { } finally {
await conn.end(); await conn.end();
+51 -5
View File
@@ -44,15 +44,32 @@ export const DEV_USER = {
color: COLOR_PALETTE[0], // '#4A90D9' — first palette slot color: COLOR_PALETTE[0], // '#4A90D9' — first palette slot
} as const; } as const;
/**
* The shape stored on c.get('user') across the bypass, local-session, and OIDC paths.
*
* BL-04: oidcIss/oidcSub are NULLABLE. Local users have null OIDC fields, and
* localAuthMiddleware must NOT fabricate sentinel ('local'/String(id)) values — those
* share the uniqueness domain (uniq_oidc_identity) with real OIDC identities and could
* collide with a genuine (iss,sub) pair if ever persisted. DEV_USER carries non-null
* 'dev'/'dev-user' values and remains assignable to this widened shape.
*/
export interface ContextUser {
id: number;
oidcIss: string | null;
oidcSub: string | null;
displayName: string | null;
color: string;
}
/** /**
* Extend Hono's ContextVariableMap so that c.get('user') / c.set('user', ...) * Extend Hono's ContextVariableMap so that c.get('user') / c.set('user', ...)
* are statically typed throughout the app. The value type is the DEV_USER shape, * are statically typed throughout the app. The value type is ContextUser — the shape
* which is compatible with both the bypass path and any future app-level user object * shared by the dev-bypass path, the local-session path (nullable oidc fields), and any
* stored on context (they share the same id/displayName/color subset). * future app-level user object stored on context.
*/ */
declare module 'hono' { declare module 'hono' {
interface ContextVariableMap { interface ContextVariableMap {
user: typeof DEV_USER; user: ContextUser;
} }
} }
@@ -114,10 +131,39 @@ export function devSessionCookieMiddleware(): MiddlewareHandler {
// LOCAL_SESSION_SECRET not set — bypass mode exempts the secret requirement // LOCAL_SESSION_SECRET not set — bypass mode exempts the secret requirement
// (assertLocalSessionSecretSet skips when DEV_AUTH_BYPASS=true), but we cannot // (assertLocalSessionSecretSet skips when DEV_AUTH_BYPASS=true), but we cannot
// issue a cookie without it. Degrade gracefully so devAuthBypass still works. // issue a cookie without it. Degrade gracefully so devAuthBypass still works.
if (!process.env.LOCAL_SESSION_SECRET) { const secret = process.env.LOCAL_SESSION_SECRET;
if (!secret) {
return async (_c, next) => next(); return async (_c, next) => next();
} }
// BL-01: do not treat "present" as "safe". The boot guard's length floor
// (assertLocalSessionSecretSet, >= 32 chars) is SKIPPED in bypass mode, so apply the
// same floor here before minting a real, signature-valid local-session JWT for DEV_USER
// (id=1). A short/forgeable secret must NOT issue a genuine session token. Degrade to a
// no-op so the cookie is never signed with a weak key.
if (secret.length < 32) {
console.warn(
'[devSessionCookieMiddleware] LOCAL_SESSION_SECRET is shorter than 32 characters — ' +
'refusing to issue a dev local-session cookie. Generate a strong value with ' +
'node scripts/generate-secrets.mjs.',
);
return async (_c, next) => next();
}
// BL-01: warn loudly if the secret is the well-known dev placeholder. A genuine,
// signature-valid session token minted under this known value is trivially forgeable
// if the same secret ever leaks into a non-bypass environment.
// Not a security comparison — this matches against a PUBLIC well-known placeholder to
// emit a warning, so constant-time equality is irrelevant here.
// eslint-disable-next-line security/detect-possible-timing-attacks
if (secret === 'dev-secret-change-me-0000000000000000') {
console.warn(
'[devSessionCookieMiddleware] LOCAL_SESSION_SECRET is the well-known dev placeholder. ' +
'This is acceptable ONLY for local dev/CI under DEV_AUTH_BYPASS — never reuse this ' +
'value in any non-bypass or shared environment.',
);
}
// Bypass active + secret set: issue a real local-session cookie for DEV_USER // Bypass active + secret set: issue a real local-session cookie for DEV_USER
// on each request that does not already carry one. // on each request that does not already carry one.
return async (c, next) => { return async (c, next) => {
+55
View File
@@ -0,0 +1,55 @@
/**
* linkNonceStore.ts — single-use nonce store for the OIDC-link state (IN-04).
*
* POST /api/me/link-oidc mints a signed `state` JWT carrying a random `nonce` "to prevent
* replay". Previously the /callback handler never recorded or checked that nonce, so a
* captured state JWT was fully replayable within its 10-minute signature window — the nonce
* provided no actual protection (this underlies the BL-03 takeover concern).
*
* This in-memory store makes the nonce genuinely single-use:
* - registerLinkNonce(nonce, expEpochSeconds): called when the state is issued.
* - consumeLinkNonce(nonce): called on /callback; returns true exactly ONCE per nonce
* (and only while unexpired), false on replay / unknown / expired.
*
* In-memory is sufficient for a single-process household deployment (same scope as the
* loginAttempts limiter). Expired entries are swept opportunistically on each access so the
* map stays bounded. If this app ever runs multi-process, move this to Redis.
*/
// nonce → expiry (epoch ms). Presence means "issued and not yet consumed".
const issuedNonces = new Map<string, number>();
function sweepExpired(now: number): void {
for (const [nonce, expiresAt] of issuedNonces) {
if (now >= expiresAt) issuedNonces.delete(nonce);
}
}
/**
* Record a freshly-issued link nonce as valid until expEpochSeconds (the state JWT's exp).
*/
export function registerLinkNonce(nonce: string, expEpochSeconds: number): void {
const now = Date.now();
sweepExpired(now);
issuedNonces.set(nonce, expEpochSeconds * 1000);
}
/**
* Consume a link nonce. Returns true exactly once for a known, unexpired nonce; false for
* any replay, unknown, or expired nonce. Single-use: the entry is deleted on first success.
*/
export function consumeLinkNonce(nonce: string): boolean {
const now = Date.now();
sweepExpired(now);
const expiresAt = issuedNonces.get(nonce);
if (expiresAt === undefined || now >= expiresAt) return false;
issuedNonces.delete(nonce); // single-use
return true;
}
/**
* Test-only: clear all issued nonces.
*/
export function _clearLinkNonces(): void {
issuedNonces.clear();
}
+11 -8
View File
@@ -33,7 +33,7 @@ import { eq } from 'drizzle-orm';
import { db } from '../db/client.js'; import { db } from '../db/client.js';
import { users } from '../db/schema.js'; import { users } from '../db/schema.js';
import { verifyLocalSessionCookie } from './localSession.js'; import { verifyLocalSessionCookie } from './localSession.js';
import type { DEV_USER } from './devBypass.js'; import type { ContextUser } from './devBypass.js';
/** /**
* Returns a Hono MiddlewareHandler that: * Returns a Hono MiddlewareHandler that:
@@ -81,17 +81,20 @@ export function localAuthMiddleware(): MiddlewareHandler {
return; return;
} }
// Populate c.get('user') with the same shape as DEV_USER (devBypass.ts ContextVariableMap). // Populate c.get('user') with the ContextUser shape (devBypass.ts ContextVariableMap).
// oidcIss/oidcSub: local users have nullable oidcIss/oidcSub — use fallback strings so the // BL-04: keep oidcIss/oidcSub as NULL for local users — do NOT fabricate
// shape is compatible with typeof DEV_USER at runtime. Cast required because ContextVariableMap // 'local'/String(id) sentinels. Those values share the uniq_oidc_identity uniqueness
// is narrowed to the const DEV_USER literal type. // domain with real OIDC identities, so persisting them (e.g. a future upsertUser call
// using these context values) would let two local users collide or a local user shadow
// a genuine OIDC identity. ContextUser widens oidcIss/oidcSub to string | null so no
// cast is needed.
c.set('user', { c.set('user', {
id: row.id, id: row.id,
oidcIss: row.oidcIss ?? 'local', oidcIss: row.oidcIss ?? null,
oidcSub: row.oidcSub ?? String(row.id), oidcSub: row.oidcSub ?? null,
displayName: row.displayName ?? null, displayName: row.displayName ?? null,
color: row.color ?? '#4A90D9', color: row.color ?? '#4A90D9',
} as typeof DEV_USER); } satisfies ContextUser);
await next(); await next();
}; };
+38 -8
View File
@@ -18,7 +18,31 @@
* Max length: ~83 chars — fits in varchar(256) password_hash column * Max length: ~83 chars — fits in varchar(256) password_hash column
*/ */
import { scryptSync, randomBytes, timingSafeEqual } from 'node:crypto'; import { scrypt, randomBytes, timingSafeEqual } from 'node:crypto';
import type { BinaryLike, ScryptOptions } from 'node:crypto';
// WR-03: use the ASYNC scrypt (libuv threadpool) so password hashing does NOT block the
// single Node event loop. scryptSync ran on the main thread, so a burst of unauthenticated
// POST /local/login requests (each running scrypt N=16384, ~tens of ms of CPU, including the
// always-run dummy-hash path) could pin the loop and stall ALL other API traffic — a cheap
// unauthenticated DoS. This async wrapper offloads the CPU to the threadpool, preserving the
// timing-defense property while keeping the loop responsive.
//
// A hand-rolled Promise wrapper is used (rather than promisify) because promisify's typings
// do not cover the options-carrying scrypt overload (N/r/p).
function scryptAsync(
password: BinaryLike,
salt: BinaryLike,
keylen: number,
options: ScryptOptions,
): Promise<Buffer> {
return new Promise((resolve, reject) => {
scrypt(password, salt, keylen, options, (err, derivedKey) => {
if (err) reject(err);
else resolve(derivedKey);
});
});
}
// OWASP-compatible scrypt parameters for password hashing // OWASP-compatible scrypt parameters for password hashing
const SCRYPT_N = 16384; // CPU/memory cost factor (2^14) const SCRYPT_N = 16384; // CPU/memory cost factor (2^14)
@@ -36,12 +60,15 @@ const KEY_LEN = 32; // 256-bit derived key output
* the hash without relying on hardcoded constants — supports future parameter * the hash without relying on hardcoded constants — supports future parameter
* migration without a DB schema change. * migration without a DB schema change.
* *
* NOTE: scryptSync blocks the event loop. For a 2-person household with * WR-03: async — scrypt runs on the libuv threadpool, not the event loop.
* infrequent logins this is acceptable. Use promisify(scrypt) if async is needed.
*/ */
export function hashPassword(password: string): string { export async function hashPassword(password: string): Promise<string> {
const salt = randomBytes(16); const salt = randomBytes(16);
const hash = scryptSync(password, salt, KEY_LEN, { N: SCRYPT_N, r: SCRYPT_R, p: SCRYPT_P }); const hash = await scryptAsync(password, salt, KEY_LEN, {
N: SCRYPT_N,
r: SCRYPT_R,
p: SCRYPT_P,
});
return [ return [
'scrypt', 'scrypt',
SCRYPT_N, SCRYPT_N,
@@ -66,9 +93,12 @@ export function hashPassword(password: string): string {
* scrypt parameter error — safe to call with untrusted input. * scrypt parameter error — safe to call with untrusted input.
* - Never logs the candidate password. * - Never logs the candidate password.
* *
* @returns true if candidate matches the stored hash; false otherwise (incl. errors) * WR-03: async — scrypt runs on the libuv threadpool, not the event loop.
*
* @returns Promise<true> if candidate matches the stored hash; Promise<false> otherwise
* (including all parse/format/crypto errors — never rejects).
*/ */
export function verifyPassword(storedEncoded: string, candidate: string): boolean { export async function verifyPassword(storedEncoded: string, candidate: string): Promise<boolean> {
try { try {
const parts = storedEncoded.split('$'); const parts = storedEncoded.split('$');
if (parts.length !== 6) return false; if (parts.length !== 6) return false;
@@ -76,7 +106,7 @@ export function verifyPassword(storedEncoded: string, candidate: string): boolea
const salt = Buffer.from(saltB64, 'base64url'); const salt = Buffer.from(saltB64, 'base64url');
const storedHash = Buffer.from(hashB64, 'base64url'); const storedHash = Buffer.from(hashB64, 'base64url');
if (salt.length === 0 || storedHash.length === 0) return false; if (salt.length === 0 || storedHash.length === 0) return false;
const candidateHash = scryptSync(candidate, salt, storedHash.length, { const candidateHash = await scryptAsync(candidate, salt, storedHash.length, {
N: Number(n), N: Number(n),
r: Number(r), r: Number(r),
p: Number(p), p: Number(p),
+16 -5
View File
@@ -27,8 +27,15 @@ import type { Context } from 'hono';
// Cookie name must be distinct from the OIDC cookie 'oidc-auth' (Pitfall 4) // Cookie name must be distinct from the OIDC cookie 'oidc-auth' (Pitfall 4)
const COOKIE_NAME = 'local-session'; const COOKIE_NAME = 'local-session';
// Session max age: default 1 day (86400s); configurable via LOCAL_SESSION_EXPIRES env // Session max age: default 1 day (86400s); configurable via LOCAL_SESSION_EXPIRES env.
const SESSION_MAX_AGE_SECONDS = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400); // IN-01: validate the coercion. A malformed value yields NaN, which would produce a JWT
// with exp = now + NaN (→ NaN) and a cookie maxAge: NaN — making verify behaviour
// "always expired" or "never expires" depending on the lib's NaN handling. Fall back to
// the 86400s default for any non-finite or non-positive value.
const SESSION_MAX_AGE_SECONDS = (() => {
const n = Number(process.env.LOCAL_SESSION_EXPIRES ?? 86400);
return Number.isFinite(n) && n > 0 ? n : 86400;
})();
/** /**
* Issue a signed local-session JWT cookie for the given userId. * Issue a signed local-session JWT cookie for the given userId.
@@ -98,9 +105,13 @@ export function clearLocalSessionCookie(c: Context): void {
deleteCookie(c, COOKIE_NAME, { deleteCookie(c, COOKIE_NAME, {
path: '/', path: '/',
httpOnly: true, httpOnly: true,
// Use secure:true for delete (browsers only accept the attribute in matching context) // BL-02: mirror the issue-time `secure` logic. issueLocalSessionCookie sets
// In practice this is safe because logout should happen over HTTPS in production. // secure:false over plain HTTP (non-production), and a browser will REJECT a
secure: true, // Secure delete-cookie sent over HTTP — so a hard-coded secure:true left the
// local-session cookie uncleared on every non-HTTPS deployment (local dev and any
// HTTP-only self-host), leaving the user "logged in" after logout. Match the
// context so the deletion cookie is accepted.
secure: process.env.NODE_ENV === 'production',
sameSite: 'Lax', sameSite: 'Lax',
}); });
} }
+91
View File
@@ -0,0 +1,91 @@
/**
* oidcConfig.ts — centralized "is OIDC configured" resolution + endpoint discovery.
*
* WR-04: three sites previously had independent notions of whether OIDC is configured:
* - routes/authMode.ts → OIDC_ISSUER env OR app_config.oidc_issuer
* - auth/middleware.ts → injects issuer/client-id/external-url from app_config
* - routes/me.ts (link-oidc) → ONLY env (OIDC_ISSUER && OIDC_CLIENT_ID && OIDC_REDIRECT_URI)
*
* The me.ts divergence meant a wizard-configured-but-not-restarted instance reported
* oidcEnabled:true from /api/auth/mode (and showed the "Link OIDC" button) but link-oidc
* returned authorizationUrl:null. This helper makes the env-OR-app_config resolution a
* single source of truth.
*
* WR-02: me.ts also hardcoded Authelia's `/api/oidc/authorization` path. The project's
* design is to discover endpoints from the issuer's /.well-known/openid-configuration
* document (the whole reason @hono/oidc-auth is used). discoverAuthorizationEndpoint()
* resolves the real authorization_endpoint so any RFC-compliant provider works.
*/
import { eq } from 'drizzle-orm';
import { db } from '../db/client.js';
import { appConfig } from '../db/schema.js';
export interface ResolvedOidcConfig {
issuer: string;
clientId: string;
redirectUri: string;
}
/**
* Read a single app_config value (or null when absent).
*/
async function readAppConfig(key: string): Promise<string | null> {
const [row] = await db
.select({ value: appConfig.value })
.from(appConfig)
.where(eq(appConfig.key, key))
.limit(1);
return row?.value ?? null;
}
/**
* Resolve issuer + clientId + redirectUri using env first, then app_config (WR-04).
*
* Returns null when any of the three cannot be resolved from EITHER source — i.e. OIDC is
* not (fully) configured, so no authorization URL can be built. The redirect URI is taken
* from OIDC_REDIRECT_URI when present, else derived from the external app URL
* (OIDC_AUTH_EXTERNAL_URL env or app_config.app_external_url) as `${externalUrl}/callback`,
* matching the middleware's redirect-uri construction.
*/
export async function resolveOidcConfig(): Promise<ResolvedOidcConfig | null> {
const issuer = process.env.OIDC_ISSUER ?? (await readAppConfig('oidc_issuer'));
const clientId = process.env.OIDC_CLIENT_ID ?? (await readAppConfig('oidc_client_id'));
let redirectUri = process.env.OIDC_REDIRECT_URI ?? null;
if (!redirectUri) {
const externalUrl =
process.env.OIDC_AUTH_EXTERNAL_URL ?? (await readAppConfig('app_external_url'));
if (externalUrl) {
redirectUri = `${externalUrl.replace(/\/$/, '')}/callback`;
}
}
if (!issuer || !clientId || !redirectUri) return null;
return { issuer, clientId, redirectUri };
}
/**
* Discover the provider's authorization_endpoint from its OIDC discovery document (WR-02).
*
* Fetches `${issuer}/.well-known/openid-configuration` (5s timeout, matching the setup
* wizard's validate/oidc step) and returns the `authorization_endpoint` URL. Returns null
* on any network error, non-2xx, or missing field — callers treat null as "cannot build
* the authorization URL" and degrade gracefully (the PWA disables the Link button).
*/
export async function discoverAuthorizationEndpoint(issuer: string): Promise<string | null> {
try {
const res = await fetch(`${issuer.replace(/\/$/, '')}/.well-known/openid-configuration`, {
signal: AbortSignal.timeout(5000),
});
if (!res.ok) return null;
const doc = (await res.json()) as { authorization_endpoint?: unknown };
return typeof doc.authorization_endpoint === 'string' ? doc.authorization_endpoint : null;
} catch (err) {
console.error(
'[oidcConfig/discoverAuthorizationEndpoint]',
err instanceof Error ? err.message : String(err),
);
return null;
}
}
+42 -1
View File
@@ -20,6 +20,8 @@ import {
} from './auth/middleware.js'; } from './auth/middleware.js';
import { devAuthBypass, devSessionCookieMiddleware } from './auth/devBypass.js'; import { devAuthBypass, devSessionCookieMiddleware } from './auth/devBypass.js';
import { localAuthMiddleware } from './auth/localAuthMiddleware.js'; import { localAuthMiddleware } from './auth/localAuthMiddleware.js';
import { verifyLocalSessionCookie } from './auth/localSession.js';
import { consumeLinkNonce } from './auth/linkNonceStore.js';
import { persistSessionCookie } from './auth/persistSessionCookie.js'; import { persistSessionCookie } from './auth/persistSessionCookie.js';
import { startBrokerPoller } from './broker/poller.js'; import { startBrokerPoller } from './broker/poller.js';
import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js'; import { startOutboxWorker, initOutboxTrigger } from './broker/outboxWorker.js';
@@ -58,6 +60,7 @@ app.get('/callback', async (c) => {
// Attempt to extract linkUserId from the signed state param BEFORE processOAuthCallback // Attempt to extract linkUserId from the signed state param BEFORE processOAuthCallback
// consumes it. The state param may be our signed JWT (link mode) or a random string (normal). // consumes it. The state param may be our signed JWT (link mode) or a random string (normal).
let linkUserId: number | null = null; let linkUserId: number | null = null;
let linkNonce: string | null = null;
const rawState = c.req.query('state'); const rawState = c.req.query('state');
if (rawState) { if (rawState) {
const secret = process.env.LOCAL_SESSION_SECRET; const secret = process.env.LOCAL_SESSION_SECRET;
@@ -66,6 +69,7 @@ app.get('/callback', async (c) => {
const payload = await Jwt.verify(rawState, secret, 'HS256'); const payload = await Jwt.verify(rawState, secret, 'HS256');
if (typeof payload.linkUserId === 'number') { if (typeof payload.linkUserId === 'number') {
linkUserId = payload.linkUserId; linkUserId = payload.linkUserId;
linkNonce = typeof payload.nonce === 'string' ? payload.nonce : null;
} }
} catch { } catch {
// Not our signed link state — normal OIDC callback, proceed normally. // Not our signed link state — normal OIDC callback, proceed normally.
@@ -79,10 +83,41 @@ app.get('/callback', async (c) => {
// Link mode: after session is established, bind the OIDC identity to the local user. // Link mode: after session is established, bind the OIDC identity to the local user.
if (linkUserId !== null) { if (linkUserId !== null) {
try { try {
// IN-04: enforce SINGLE USE of the link nonce. The signed state JWT is otherwise
// replayable for its full 10-minute signature lifetime; consuming the nonce here means
// a captured state can be used at most once. A replay (already-consumed), unknown, or
// expired nonce is rejected before any binding occurs.
if (!linkNonce || !consumeLinkNonce(linkNonce)) {
console.warn('[callback] OIDC-link rejected: link nonce missing, replayed, or expired.');
return c.redirect('/?error=oidc-link-conflict');
}
// BL-03: cross-check that the local session completing this callback is the SAME
// user the link flow was initiated for. The signed `state` JWT proves the state was
// minted by POST /api/me/link-oidc, but NOT that the person finishing the OIDC login
// is that user. Without this check, an attacker who gets a victim to complete an OIDC
// login while replaying a still-valid (10-min) captured link state would bind the
// ATTACKER's OIDC identity onto the VICTIM's account (account takeover). Require the
// initiating local session to still be present and to match linkUserId.
const sessionUserId = await verifyLocalSessionCookie(c);
if (sessionUserId !== linkUserId) {
console.warn(
'[callback] OIDC-link rejected: local session does not match link state (possible replay).',
);
return c.redirect('/?error=oidc-link-conflict');
}
const auth = await getAuth(c); const auth = await getAuth(c);
if (auth) { if (auth) {
const iss = (auth.iss as string | undefined) ?? ''; const iss = (auth.iss as string | undefined) ?? '';
const sub = auth.sub ?? ''; const sub = auth.sub ?? '';
// BL-03: never bind on a blank/partial identity. linkOidcToUser writes oidc_iss/
// oidc_sub AND deletes the user's local_credentials — binding empty iss/sub would
// both corrupt identity and lock the user out of BOTH auth methods. Reject instead.
if (!iss || !sub) {
console.warn('[callback] OIDC-link rejected: empty iss/sub from getAuth.');
return c.redirect('/?error=oidc-link-conflict');
}
await linkOidcToUser(linkUserId, iss, sub); await linkOidcToUser(linkUserId, iss, sub);
// On success: user is now OIDC-only; normal redirect via callbackResponse proceeds. // On success: user is now OIDC-only; normal redirect via callbackResponse proceeds.
} }
@@ -93,7 +128,10 @@ app.get('/callback', async (c) => {
return c.redirect('/?error=oidc-link-conflict'); return c.redirect('/?error=oidc-link-conflict');
} }
// Unexpected error during link binding — log and continue with normal redirect. // Unexpected error during link binding — log and continue with normal redirect.
console.error('[callback] linkOidcToUser unexpected error:', err instanceof Error ? err.message : String(err)); console.error(
'[callback] linkOidcToUser unexpected error:',
err instanceof Error ? err.message : String(err),
);
} }
} }
@@ -158,6 +196,9 @@ if (!devBypassActive) {
await next(); await next();
return; return;
} }
// oidcHandler's parameter is typed as the generic Hono Context; our wrapper's c is the
// same runtime Context narrowed to '/api/*' — the structural mismatch is type-only.
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument
await oidcHandler(c, next); await oidcHandler(c, next);
}); });
// Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02). // Re-issues the session-scoped oidc-auth cookie as persistent so PWA sessions survive close (AUTH-02).
+87 -67
View File
@@ -35,6 +35,7 @@ import {
CredentialValidationError, CredentialValidationError,
} from '../broker/credentialSync.js'; } from '../broker/credentialSync.js';
import { hashPassword } from '../auth/localCredentials.js'; import { hashPassword } from '../auth/localCredentials.js';
import { resetLoginAttempts } from './localAuth.js';
import { COLOR_PALETTE } from '../auth/user.js'; import { COLOR_PALETTE } from '../auth/user.js';
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'; import '../auth/devBypass.js';
@@ -77,6 +78,19 @@ const noEchoHook = (result: { success: boolean }, c: Context) => {
} }
}; };
/**
* WR-07: strictly parse a positive-integer route param. parseInt('12abc', 10) returns 12
* and passes an isNaN guard, silently accepting malformed ids. Number('12abc') is NaN, so
* Number.isInteger(Number(raw)) rejects trailing garbage. Returns null for anything that is
* not a whole positive integer (empty, '12abc', '1.5', '-3', '0', etc.) so the caller can 400.
*/
function parsePositiveIntParam(raw: string | undefined): number | null {
if (raw === undefined || raw.trim() === '') return null;
const n = Number(raw);
if (!Number.isInteger(n) || n <= 0) return null;
return n;
}
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// GET /api/admin/members // GET /api/admin/members
// //
@@ -126,73 +140,73 @@ const createMemberSchema = z.object({
initialPassword: z.string().min(8), initialPassword: z.string().min(8),
}); });
adminRouter.post( adminRouter.post('/members', zValidator('json', createMemberSchema, noEchoHook), async (c) => {
'/members', const { displayName, username, initialPassword } = c.req.valid('json');
zValidator('json', createMemberSchema, noEchoHook), // T-19-06: NEVER log request body, displayName, username, or initialPassword here
async (c) => {
const { displayName, username, initialPassword } = c.req.valid('json');
// T-19-06: NEVER log request body, displayName, username, or initialPassword here
// Assign the first palette color not already in use (mirrors upsertUser color logic) // Assign the first palette color not already in use (mirrors upsertUser color logic)
const usedRows = await db.select({ color: users.color }).from(users); const usedRows = await db.select({ color: users.color }).from(users);
const usedColors = new Set(usedRows.map((r) => r.color)); const usedColors = new Set(usedRows.map((r) => r.color));
const color = const color =
COLOR_PALETTE.find((c) => !usedColors.has(c)) ?? COLOR_PALETTE.find((c) => !usedColors.has(c)) ??
COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length]; COLOR_PALETTE[usedColors.size % COLOR_PALETTE.length];
try { // WR-03: hash the initial password BEFORE opening the transaction so the (now async,
let newUserId: number; // threadpool) scrypt work does not hold the DB transaction open for its duration.
const initialPasswordHash = await hashPassword(initialPassword);
// T-19-10: atomic transaction — both inserts succeed or both roll back try {
await db.transaction(async (tx) => { let newUserId: number;
// Insert the new users row (no oidcIss/oidcSub — local-only member)
const [inserted] = await tx
.insert(users)
.values({
displayName,
color,
isAdmin: false,
claimed: false, // no OIDC identity bound yet
})
.$returningId();
newUserId = inserted.id;
// Insert local_credentials row with hashed initial password // T-19-10: atomic transaction — both inserts succeed or both roll back
// If username is already in use, the UNIQUE constraint fires here and rolls back await db.transaction(async (tx) => {
await tx.insert(localCredentials).values({ // Insert the new users row (no oidcIss/oidcSub — local-only member)
userId: newUserId, const [inserted] = await tx
username, .insert(users)
passwordHash: hashPassword(initialPassword), .values({
}); displayName,
color,
isAdmin: false,
claimed: false, // no OIDC identity bound yet
})
.$returningId();
newUserId = inserted.id;
// Insert local_credentials row with hashed initial password
// If username is already in use, the UNIQUE constraint fires here and rolls back
await tx.insert(localCredentials).values({
userId: newUserId,
username,
passwordHash: initialPasswordHash,
}); });
});
// Return the new user's id (the PWA uses it to navigate to the member) // Return the new user's id (the PWA uses it to navigate to the member)
return c.json({ id: newUserId! }, 201); return c.json({ id: newUserId! }, 201);
} catch (err) { } catch (err) {
// Username uniqueness violation — UNIQUE constraint on local_credentials.username. // Username uniqueness violation — UNIQUE constraint on local_credentials.username.
// Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY. // Drizzle wraps the mysql2 error; check the cause chain or the message for ER_DUP_ENTRY.
const isDup = const isDup =
(err instanceof Error && err.message.includes('ER_DUP_ENTRY')) || (err instanceof Error && err.message.includes('ER_DUP_ENTRY')) ||
(err != null && (err != null &&
typeof err === 'object' && typeof err === 'object' &&
'code' in err && 'code' in err &&
(err as { code?: string }).code === 'ER_DUP_ENTRY') || (err as { code?: string }).code === 'ER_DUP_ENTRY') ||
(err != null && (err != null &&
typeof err === 'object' && typeof err === 'object' &&
'cause' in err && 'cause' in err &&
(err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY'); (err as { cause?: { code?: string } }).cause?.code === 'ER_DUP_ENTRY');
if (isDup) { if (isDup) {
return c.json({ error: 'Username already in use' }, 409); return c.json({ error: 'Username already in use' }, 409);
}
// Unexpected errors — log message only, never the body or password
console.error(
'[admin/POST /members] Unexpected error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503);
} }
}, // Unexpected errors — log message only, never the body or password
); console.error(
'[admin/POST /members] 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
@@ -212,17 +226,18 @@ adminRouter.post(
'/members/:id/password', '/members/:id/password',
zValidator('json', resetPasswordSchema, noEchoHook), zValidator('json', resetPasswordSchema, noEchoHook),
async (c) => { async (c) => {
const targetId = parseInt(c.req.param('id'), 10); const targetId = parsePositiveIntParam(c.req.param('id'));
if (isNaN(targetId)) { if (targetId === null) {
return c.json({ error: 'Invalid member id' }, 400); return c.json({ error: 'Invalid member id' }, 400);
} }
const { newPassword } = c.req.valid('json'); const { newPassword } = c.req.valid('json');
// T-19-06: NEVER log newPassword or the request body // T-19-06: NEVER log newPassword or the request body
// Verify the target user has a local_credentials row (404 if not) // Verify the target user has a local_credentials row (404 if not).
// Also read the username so we can clear any login lockout for it (CR-04).
const [credRow] = await db const [credRow] = await db
.select({ id: localCredentials.id }) .select({ id: localCredentials.id, username: localCredentials.username })
.from(localCredentials) .from(localCredentials)
.where(eq(localCredentials.userId, targetId)) .where(eq(localCredentials.userId, targetId))
.limit(1); .limit(1);
@@ -234,9 +249,14 @@ adminRouter.post(
try { try {
await db await db
.update(localCredentials) .update(localCredentials)
.set({ passwordHash: hashPassword(newPassword) }) .set({ passwordHash: await hashPassword(newPassword) })
.where(eq(localCredentials.userId, targetId)); .where(eq(localCredentials.userId, targetId));
// CR-04: an admin password reset must immediately clear any rate-limit / lockout
// state for this username, so a locked-out member regains access at once rather than
// waiting for the TTL. The lockout is keyed on username (not member id).
resetLoginAttempts(credRow.username);
return c.json({ ok: true }, 200); return c.json({ ok: true }, 200);
} catch (err) { } catch (err) {
console.error( console.error(
@@ -305,8 +325,8 @@ adminRouter.get('/calendars', async (c) => {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
adminRouter.put('/calendars/:id/shared', async (c) => { adminRouter.put('/calendars/:id/shared', async (c) => {
const targetId = parseInt(c.req.param('id'), 10); const targetId = parsePositiveIntParam(c.req.param('id'));
if (isNaN(targetId)) { if (targetId === null) {
return c.json({ error: 'Invalid calendar id' }, 400); return c.json({ error: 'Invalid calendar id' }, 400);
} }
+115 -33
View File
@@ -14,8 +14,16 @@
* - Dummy-hash timing defense: verifyPassword is always called, even for unknown usernames, * - Dummy-hash timing defense: verifyPassword is always called, even for unknown usernames,
* to prevent timing-oracle username enumeration attacks (T-19-12 / RESEARCH Pitfall 2). * to prevent timing-oracle username enumeration attacks (T-19-12 / RESEARCH Pitfall 2).
* - Same 401 body for wrong password AND unknown username — no field discrimination (T-19-12). * - Same 401 body for wrong password AND unknown username — no field discrimination (T-19-12).
* - Per-IP in-memory rate-limiting: 5 failures → 429; 10 failures → 423 (T-19-11). * - Per-USERNAME in-memory rate-limiting: 5 failures → 429; 10 failures → 423 (T-19-11).
* - Lockout (423) cleared only by admin password reset — no self-service unlock. * - Lockout (423) auto-expires after LOCKOUT_TTL_MS (CR-04) so a single attacker cannot
* permanently deny login for the whole household, and no process restart is needed to
* recover. Admin password reset still clears it immediately (resetLoginAttempts).
*
* CR-04: the limiter is keyed on the submitted username, NOT the client IP. In this
* deployment all household traffic egresses the Pangolin tunnel with the same
* X-Forwarded-For first hop, so IP-keying made one bad actor (or one fat-fingered user)
* able to lock out every member, and X-Forwarded-For is attacker-spoofable. Username-keying
* scopes the lockout to the identity actually under attack and the TTL makes it self-healing.
*/ */
import { Hono } from 'hono'; import { Hono } from 'hono';
@@ -52,61 +60,122 @@ const loginSchema = z.object({
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Per-IP rate-limiting state (in-memory Map — household scale, no Redis needed). // Per-USERNAME rate-limiting state (in-memory Map — household scale, no Redis needed).
// //
// State shape per IP: { count, lockedUntil (epoch ms), lockedOut (bool) } // State shape per username: { count, lockedUntil (epoch ms), lockedOut (bool), lockedAt (epoch ms) }
// count >= RATE_WINDOW_FAILURES AND Date.now() < lockedUntil → 429 // count >= RATE_WINDOW_FAILURES AND Date.now() < lockedUntil → 429
// count >= LOCKOUT_FAILURES → 423 (permanent until admin reset) // count >= LOCKOUT_FAILURES → 423 (until LOCKOUT_TTL_MS elapses OR admin reset)
// Success → delete entry (clears counter) // Success → delete entry (clears counter)
// //
// RATE_WINDOW_FAILURES: 5 failures → 60s cooldown (429) // RATE_WINDOW_FAILURES: 5 failures → 60s cooldown (429)
// LOCKOUT_FAILURES: 10 failures → account locked (423) // LOCKOUT_FAILURES: 10 failures → account locked (423)
// LOCKOUT_TTL_MS: 15 min after which a 423 lockout auto-expires (CR-04)
//
// CR-04: keyed on the validated username (not the client IP) so a lockout is scoped to
// the identity under attack, and self-heals after LOCKOUT_TTL_MS without a restart.
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
export const loginAttempts = new Map<string, { count: number; lockedUntil: number; lockedOut: boolean }>(); export const loginAttempts = new Map<
string,
{ count: number; lockedUntil: number; lockedOut: boolean; lockedAt: number }
>();
const RATE_WINDOW_FAILURES = 5; const RATE_WINDOW_FAILURES = 5;
const RATE_WINDOW_SECS = 60; const RATE_WINDOW_SECS = 60;
const LOCKOUT_FAILURES = 10; const LOCKOUT_FAILURES = 10;
const LOCKOUT_TTL_MS = 15 * 60 * 1000; // CR-04: 423 lockout auto-expires after 15 minutes
/**
* Immediately clear any rate-limit / lockout state for a username (CR-04).
*
* Called by the admin password-reset path so a reset is an instant unlock and the
* lockout is not "unrecoverable without a process restart". Safe to call for an
* unknown username (no-op). Normalizes the username the same way the login schema
* does (trim) so the key matches what the limiter stored.
*/
export function resetLoginAttempts(username: string): void {
loginAttempts.delete(username.trim());
}
/**
* IN-03: evict stale rate-limit entries to bound the in-memory map size.
*
* An entry is stale (and safe to drop) when it is neither inside an active rate-limit
* window nor inside an active lockout TTL:
* - locked-out entries expire LOCKOUT_TTL_MS after lockedAt
* - non-locked entries expire once their lockedUntil window has passed
*
* Called opportunistically at the top of each login request. Dropping an entry is
* behaviourally identical to the entry having naturally expired, so eviction never
* weakens the brute-force defense — it only reclaims memory for identities no longer
* under active rate-limiting.
*/
function evictStaleLoginAttempts(now: number): void {
for (const [k, v] of loginAttempts) {
const lockoutExpired = v.lockedOut ? now - v.lockedAt >= LOCKOUT_TTL_MS : true;
const windowExpired = now >= v.lockedUntil;
if (lockoutExpired && windowExpired) {
loginAttempts.delete(k);
}
}
}
// Pre-computed dummy hash used to run verifyPassword on unknown-username paths // Pre-computed dummy hash used to run verifyPassword on unknown-username paths
// (timing defense — prevents timing-oracle username enumeration, RESEARCH Pitfall 2). // (timing defense — prevents timing-oracle username enumeration, RESEARCH Pitfall 2).
// Computed once at module load time; the actual value is never used for auth. // WR-03: hashPassword is now async. Kick off the computation once at module load and keep
const DUMMY_HASH = hashPassword('dummy-constant-time-filler-xyzzy'); // the PROMISE; the login handler awaits it. The value is never used for auth — only to make
// the unknown-username path perform the same scrypt work as the known-username path.
const dummyHashPromise: Promise<string> = hashPassword('dummy-constant-time-filler-xyzzy');
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /local/login → POST /api/auth/local/login // POST /local/login → POST /api/auth/local/login
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook), async (c) => { localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook), async (c) => {
// Derive client IP from Pangolin-set X-Forwarded-For header; fall back to host header. // CR-04: the rate-limit / lockout key is the validated, normalized username — NOT the
const ip = c.req.header('x-forwarded-for')?.split(',')[0]?.trim() // client IP. zValidator has already run, so c.req.valid('json') is available here.
?? c.req.raw.headers.get('host') const { username, password } = c.req.valid('json');
?? 'unknown'; const key = username; // loginSchema .trim()s the username; the map key matches resetLoginAttempts
const attempt = loginAttempts.get(ip); const now = Date.now();
// IN-03: opportunistically reclaim memory from entries that are no longer actively
// rate-limited or locked out. Cheap at household scale; bounds the map under input churn.
evictStaleLoginAttempts(now);
const attempt = loginAttempts.get(key);
// 423: account locked (>= LOCKOUT_FAILURES total failures, admin must reset) // 423: account locked (>= LOCKOUT_FAILURES failures). CR-04: the lockout auto-expires
// Check lockedOut FIRST — lockout takes precedence over rate window. // after LOCKOUT_TTL_MS so a single attacker cannot deny login indefinitely and no restart
// is required to recover. On expiry, drop the entry so the next attempt starts clean.
if (attempt?.lockedOut) { if (attempt?.lockedOut) {
return c.json({ error: 'Account locked' }, 423); if (now - attempt.lockedAt >= LOCKOUT_TTL_MS) {
loginAttempts.delete(key);
} else {
return c.json({ error: 'Account locked' }, 423);
}
} }
// Re-read after a possible expiry-delete above.
const live = loginAttempts.get(key);
// 429: rate window — >= RATE_WINDOW_FAILURES failures within the cooldown window. // 429: rate window — >= RATE_WINDOW_FAILURES failures within the cooldown window.
// Increment the counter even on 429 so continued brute-force accumulates toward lockout. // Increment the counter even on 429 so continued brute-force accumulates toward lockout.
if (attempt && attempt.count >= RATE_WINDOW_FAILURES && Date.now() < attempt.lockedUntil) { if (live && live.count >= RATE_WINDOW_FAILURES && now < live.lockedUntil) {
attempt.count += 1; live.count += 1;
attempt.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000; // WR-06: do NOT extend lockedUntil here. This request was itself REJECTED by the window;
attempt.lockedOut = attempt.count >= LOCKOUT_FAILURES; // re-arming the cooldown on every blocked attempt let an attacker who keeps hammering the
loginAttempts.set(ip, attempt); // endpoint slide the window forward forever, so a legitimate user behind the same identity
if (attempt.lockedOut) { // could never get back in even after pausing. The window stays anchored to when it was
// first armed (in the failure path below); it expires on schedule regardless of rejected
// traffic. The lockout (423) still triggers once the failure count crosses the threshold.
live.lockedOut = live.count >= LOCKOUT_FAILURES;
if (live.lockedOut && live.lockedAt === 0) live.lockedAt = now;
loginAttempts.set(key, live);
if (live.lockedOut) {
return c.json({ error: 'Account locked' }, 423); return c.json({ error: 'Account locked' }, 423);
} }
return c.json({ error: 'Too many attempts' }, 429); return c.json({ error: 'Too many attempts' }, 429);
} }
const { username, password } = c.req.valid('json');
// Look up local_credentials by username // Look up local_credentials by username
let cred: { userId: number; passwordHash: string } | undefined; let cred: { userId: number; passwordHash: string } | undefined;
try { try {
@@ -117,34 +186,47 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
.limit(1); .limit(1);
cred = found; cred = found;
} catch (err) { } catch (err) {
console.error('[localAuth/POST /local/login] DB error:', err instanceof Error ? err.message : String(err)); console.error(
'[localAuth/POST /local/login] DB error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503); return c.json({ error: 'Service unavailable' }, 503);
} }
// ALWAYS run verifyPassword — even for unknown usernames — to prevent timing-oracle // ALWAYS run verifyPassword — even for unknown usernames — to prevent timing-oracle
// username enumeration (T-19-12 / RESEARCH Pitfall 2). Use a pre-computed dummy hash // username enumeration (T-19-12 / RESEARCH Pitfall 2). Use a pre-computed dummy hash
// so the scrypt work is always performed regardless of whether username was found. // so the scrypt work is always performed regardless of whether username was found.
// WR-03: verifyPassword is async (threadpool scrypt) — await it.
const valid = cred const valid = cred
? verifyPassword(cred.passwordHash, password) ? await verifyPassword(cred.passwordHash, password)
: verifyPassword(DUMMY_HASH, password); : await verifyPassword(await dummyHashPromise, password);
if (!valid || !cred) { if (!valid || !cred) {
// Increment failure counter // Increment failure counter (keyed on username)
const cur = loginAttempts.get(ip) ?? { count: 0, lockedUntil: 0, lockedOut: false }; const cur = loginAttempts.get(key) ?? {
count: 0,
lockedUntil: 0,
lockedOut: false,
lockedAt: 0,
};
cur.count += 1; cur.count += 1;
cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000; cur.lockedUntil = Date.now() + RATE_WINDOW_SECS * 1000;
cur.lockedOut = cur.count >= LOCKOUT_FAILURES; cur.lockedOut = cur.count >= LOCKOUT_FAILURES;
loginAttempts.set(ip, cur); if (cur.lockedOut && cur.lockedAt === 0) cur.lockedAt = Date.now();
loginAttempts.set(key, cur);
// Same 401 body for wrong password AND unknown username — no field discrimination (T-19-12) // Same 401 body for wrong password AND unknown username — no field discrimination (T-19-12)
return c.json({ error: 'Invalid credentials' }, 401); return c.json({ error: 'Invalid credentials' }, 401);
} }
// Success: clear failure counter, issue the local-session JWT cookie, return ok // Success: clear failure counter, issue the local-session JWT cookie, return ok
loginAttempts.delete(ip); loginAttempts.delete(key);
try { try {
await issueLocalSessionCookie(c, cred.userId); await issueLocalSessionCookie(c, cred.userId);
} catch (err) { } catch (err) {
console.error('[localAuth/POST /local/login] Cookie issue error:', err instanceof Error ? err.message : String(err)); console.error(
'[localAuth/POST /local/login] Cookie issue error:',
err instanceof Error ? err.message : String(err),
);
return c.json({ error: 'Service unavailable' }, 503); return c.json({ error: 'Service unavailable' }, 503);
} }
return c.json({ ok: true }, 200); return c.json({ ok: true }, 200);
@@ -155,7 +237,7 @@ localAuthRouter.post('/local/login', zValidator('json', loginSchema, noEchoHook)
// GET /local/logout → GET /api/auth/local/logout (browser-redirect alias) // GET /local/logout → GET /api/auth/local/logout (browser-redirect alias)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
async function handleLogout(c: Context) { function handleLogout(c: Context) {
clearLocalSessionCookie(c); clearLocalSessionCookie(c);
return c.json({ ok: true }, 200); return c.json({ ok: true }, 200);
} }
+73 -57
View File
@@ -41,6 +41,8 @@ import {
CredentialValidationError, CredentialValidationError,
} from '../broker/credentialSync.js'; } from '../broker/credentialSync.js';
import { hashPassword, verifyPassword } from '../auth/localCredentials.js'; import { hashPassword, verifyPassword } from '../auth/localCredentials.js';
import { resolveOidcConfig, discoverAuthorizationEndpoint } from '../auth/oidcConfig.js';
import { registerLinkNonce } from '../auth/linkNonceStore.js';
// Side-effect import: brings in the ContextVariableMap augmentation for c.get('user') // Side-effect import: brings in the ContextVariableMap augmentation for c.get('user')
import '../auth/devBypass.js'; import '../auth/devBypass.js';
@@ -103,7 +105,9 @@ meRouter.get('/', async (c) => {
// but isAdmin, needsProviderSetup, and hasLocalCredential are still resolved from the DB (T-10-05). // but isAdmin, needsProviderSetup, and hasLocalCredential are still resolved from the DB (T-10-05).
const devUser = c.get('user'); const devUser = c.get('user');
if (devUser) { if (devUser) {
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(devUser.id); const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(
devUser.id,
);
return c.json({ return c.json({
user: { user: {
id: devUser.id, id: devUser.id,
@@ -139,7 +143,9 @@ meRouter.get('/', async (c) => {
return c.json({ error: 'Could not resolve user' }, 500); return c.json({ error: 'Could not resolve user' }, 500);
} }
const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(user.id); const { isAdmin, needsProviderSetup, hasLocalCredential } = await resolveAdminAndSetupStatus(
user.id,
);
return c.json({ return c.json({
user: { user: {
@@ -230,52 +236,53 @@ const mePasswordSchema = z.object({
newPassword: z.string().min(8), newPassword: z.string().min(8),
}); });
meRouter.post( meRouter.post('/password', zValidator('json', mePasswordSchema, meNoEchoHook), async (c) => {
'/password', // T-19-07: ALWAYS resolve userId from session — never from body
zValidator('json', mePasswordSchema, meNoEchoHook), const currentUserId = await resolveUserId(c);
async (c) => { if (!currentUserId) {
// T-19-07: ALWAYS resolve userId from session — never from body return c.json({ error: 'Unauthorized' }, 401);
const currentUserId = await resolveUserId(c); }
if (!currentUserId) {
return c.json({ error: 'Unauthorized' }, 401);
}
const { currentPassword, newPassword } = c.req.valid('json'); const { currentPassword, newPassword } = c.req.valid('json');
// T-19-06: NEVER log currentPassword, newPassword, or the request body // T-19-06: NEVER log currentPassword, newPassword, or the request body
// Look up the user's local_credentials row (404 if none — no local credential to change) // Look up the user's local_credentials row (404 if none — no local credential to change)
const [credRow] = await db const [credRow] = await db
.select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId }) .select({ passwordHash: localCredentials.passwordHash, userId: localCredentials.userId })
.from(localCredentials) .from(localCredentials)
.where(eq(localCredentials.userId, currentUserId)) .where(eq(localCredentials.userId, currentUserId))
.limit(1); .limit(1);
if (!credRow) { if (!credRow) {
return c.json({ error: 'No local credential found' }, 404); return c.json({ error: 'No local credential found' }, 404);
} }
// T-19-07: verify current password before any update // T-19-07: verify current password before any update (WR-03: async scrypt)
const isCorrect = verifyPassword(credRow.passwordHash, currentPassword); const isCorrect = await verifyPassword(credRow.passwordHash, currentPassword);
if (!isCorrect) { if (!isCorrect) {
return c.json({ error: 'Current password incorrect' }, 401); // CR-03: return 403 (NOT 401) for a wrong current password. The PWA's global
} // MutationCache treats any 401 as "session expired" and arms the re-auth
// interstitial / login redirect — so a 401 here would force-log-out a user who
// merely mistyped their current password. 403 is in-app authorization-failure and
// lets the client surface "current password incorrect" without dropping the session.
return c.json({ error: 'Current password incorrect' }, 403);
}
try { try {
await db await db
.update(localCredentials) .update(localCredentials)
.set({ passwordHash: hashPassword(newPassword) }) .set({ passwordHash: await hashPassword(newPassword) })
.where(eq(localCredentials.userId, currentUserId)); .where(eq(localCredentials.userId, currentUserId));
return c.json({ ok: true }, 200); return c.json({ ok: true }, 200);
} catch (err) { } catch (err) {
console.error( console.error(
'[me/POST /password] Unexpected error:', '[me/POST /password] Unexpected error:',
err instanceof Error ? err.message : String(err), err instanceof Error ? err.message : String(err),
); );
return c.json({ error: 'Service unavailable' }, 503); return c.json({ error: 'Service unavailable' }, 503);
} }
}, });
);
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// POST /api/me/link-oidc — initiate OIDC identity-link flow (AUTH-LOCAL-10, T-19-09) // POST /api/me/link-oidc — initiate OIDC identity-link flow (AUTH-LOCAL-10, T-19-09)
@@ -313,28 +320,37 @@ meRouter.post('/link-oidc', async (c) => {
// T-19-09: nonce prevents state replay attacks (each link attempt gets a fresh nonce) // T-19-09: nonce prevents state replay attacks (each link attempt gets a fresh nonce)
const nonce = randomBytes(16).toString('hex'); const nonce = randomBytes(16).toString('hex');
const now = Math.floor(Date.now() / 1000); const now = Math.floor(Date.now() / 1000);
const exp = now + 600; // 10-minute window
const signedState = await Jwt.sign( const signedState = await Jwt.sign(
{ linkUserId: currentUserId, nonce, iat: now, exp: now + 600 }, // 10-minute window { linkUserId: currentUserId, nonce, iat: now, exp },
secret, secret,
'HS256', 'HS256',
); );
// Build the OIDC authorization URL if OIDC is configured (else return null — PWA disables button) // IN-04: record the nonce so /callback can enforce SINGLE USE. Without this the signed
const issuer = process.env.OIDC_ISSUER ?? null; // state JWT is fully replayable for its 10-minute signature lifetime and the nonce is
const clientId = process.env.OIDC_CLIENT_ID ?? null; // decorative. registerLinkNonce keeps it valid only until the state's own exp.
const redirectUri = process.env.OIDC_REDIRECT_URI ?? null; registerLinkNonce(nonce, exp);
// Build the OIDC authorization URL if OIDC is configured (else return null — PWA disables button).
// WR-04: resolve issuer/clientId/redirectUri from env-OR-app_config (single source of truth,
// consistent with /api/auth/mode and the OIDC fallback middleware) so a wizard-configured
// instance does not report oidcEnabled:true while returning authorizationUrl:null here.
// WR-02: discover the authorization_endpoint from the provider's discovery document instead
// of hardcoding Authelia's /api/oidc/authorization path.
let authorizationUrl: string | null = null; let authorizationUrl: string | null = null;
if (issuer && clientId && redirectUri) { const oidc = await resolveOidcConfig();
// Construct the authorization URL. plan 19-03 will handle the full PKCE flow; if (oidc) {
// for now encode the signed state so the callback can read linkUserId. const authEndpoint = await discoverAuthorizationEndpoint(oidc.issuer);
const url = new URL(`${issuer.replace(/\/$/, '')}/api/oidc/authorization`); if (authEndpoint) {
url.searchParams.set('response_type', 'code'); const url = new URL(authEndpoint);
url.searchParams.set('client_id', clientId); url.searchParams.set('response_type', 'code');
url.searchParams.set('redirect_uri', redirectUri); url.searchParams.set('client_id', oidc.clientId);
url.searchParams.set('scope', 'openid profile email'); url.searchParams.set('redirect_uri', oidc.redirectUri);
url.searchParams.set('state', signedState); url.searchParams.set('scope', 'openid profile email');
authorizationUrl = url.toString(); url.searchParams.set('state', signedState);
authorizationUrl = url.toString();
}
} }
return c.json({ signedState, authorizationUrl }, 200); return c.json({ signedState, authorizationUrl }, 200);
+7 -1
View File
@@ -24,7 +24,13 @@
import { afterEach } from 'vitest'; import { afterEach } from 'vitest';
import { db } from '../src/db/client.js'; import { db } from '../src/db/client.js';
import { lists, listItems, listShares, pushSubscriptions, localCredentials } from '../src/db/schema.js'; import {
lists,
listItems,
listShares,
pushSubscriptions,
localCredentials,
} from '../src/db/schema.js';
/** /**
* Truncate list and push tables in FK-safe order after each test. * Truncate list and push tables in FK-safe order after each test.
@@ -73,7 +73,6 @@ function makeApp(middleware: ReturnType<typeof vi.fn>, presetUser?: unknown) {
}); });
} }
// eslint-disable-next-line @typescript-eslint/no-unsafe-call
app.use('/api/*', middleware()); app.use('/api/*', middleware());
let capturedUser: unknown = 'NOT_SET_SENTINEL'; let capturedUser: unknown = 'NOT_SET_SENTINEL';
@@ -147,7 +146,13 @@ describe('localAuthMiddleware', () => {
const res = await app.request('/api/test'); const res = await app.request('/api/test');
expect(res.status).toBe(200); expect(res.status).toBe(200);
expect(capturedUser).toBeDefined(); expect(capturedUser).toBeDefined();
const u = capturedUser as { id: number; oidcIss: string; oidcSub: string; displayName: string | null; color: string }; const u = capturedUser as {
id: number;
oidcIss: string;
oidcSub: string;
displayName: string | null;
color: string;
};
expect(u.id).toBe(7); expect(u.id).toBe(7);
expect(u.oidcIss).toBe('https://auth.example.com'); expect(u.oidcIss).toBe('https://auth.example.com');
expect(u.oidcSub).toBe('sub-abc'); expect(u.oidcSub).toBe('sub-abc');
@@ -155,6 +160,36 @@ describe('localAuthMiddleware', () => {
expect(u.color).toBe('#FF5733'); expect(u.color).toBe('#FF5733');
}); });
it('Test 1c (BL-04): local user with null DB oidc fields → context oidcIss/oidcSub are NULL (no fabricated sentinels)', async () => {
mockVerifyResult = 9;
mockDbSelectResult.push({
id: 9,
oidcIss: null, // local user — no OIDC identity bound
oidcSub: null,
displayName: 'Local Member',
color: '#22AA88',
});
const localAuthMiddleware = await getMiddleware();
const app = new Hono();
let capturedUser: unknown;
app.use('/api/*', localAuthMiddleware());
app.get('/api/test', (c) => {
capturedUser = c.get('user');
return c.json({ ok: true });
});
const res = await app.request('/api/test');
expect(res.status).toBe(200);
const u = capturedUser as { id: number; oidcIss: string | null; oidcSub: string | null };
expect(u.id).toBe(9);
// BL-04: must be null — NOT 'local' / String(id) sentinels that share the
// uniq_oidc_identity domain with real OIDC identities.
expect(u.oidcIss).toBeNull();
expect(u.oidcSub).toBeNull();
});
it('Test 2: no cookie → pure passthrough; c.get("user") remains unset (Pitfall-1 guard)', async () => { it('Test 2: no cookie → pure passthrough; c.get("user") remains unset (Pitfall-1 guard)', async () => {
mockVerifyResult = null; // No cookie / invalid mockVerifyResult = null; // No cookie / invalid
+55 -16
View File
@@ -4,6 +4,9 @@
* Uses node:crypto scrypt under the hood; no external dependencies. * Uses node:crypto scrypt under the hood; no external dependencies.
* All tests run without MariaDB or any external service. * All tests run without MariaDB or any external service.
* *
* WR-03: hashPassword / verifyPassword are now async (promisify(scrypt), threadpool) —
* all assertions await them.
*
* Test suite (TDD RED → GREEN — Plan 19-01 Task 1): * Test suite (TDD RED → GREEN — Plan 19-01 Task 1):
* Test 1: correct password verifies true * Test 1: correct password verifies true
* Test 2: wrong password verifies false * Test 2: wrong password verifies false
@@ -13,36 +16,72 @@
*/ */
import { describe, it, expect } from 'vitest'; import { describe, it, expect } from 'vitest';
import { scryptSync, randomBytes } from 'node:crypto';
import { hashPassword, verifyPassword } from '../../src/auth/localCredentials.js'; import { hashPassword, verifyPassword } from '../../src/auth/localCredentials.js';
describe('hashPassword / verifyPassword', () => { describe('hashPassword / verifyPassword', () => {
it('Test 1: verifyPassword(hashPassword(pw), pw) === true (round-trip)', () => { it('Test 1: verifyPassword(hashPassword(pw), pw) === true (round-trip)', async () => {
const encoded = hashPassword('hunter2'); const encoded = await hashPassword('hunter2');
const result = verifyPassword(encoded, 'hunter2'); const result = await verifyPassword(encoded, 'hunter2');
expect(result).toBe(true); expect(result).toBe(true);
}); });
it('Test 2: verifyPassword(hashPassword(pw), wrong) === false', () => { it('Test 2: verifyPassword(hashPassword(pw), wrong) === false', async () => {
const encoded = hashPassword('hunter2'); const encoded = await hashPassword('hunter2');
const result = verifyPassword(encoded, 'wrong-password'); const result = await verifyPassword(encoded, 'wrong-password');
expect(result).toBe(false); expect(result).toBe(false);
}); });
it('Test 3: two hashPassword calls on same input produce different encoded strings (unique salt)', () => { it('Test 3: two hashPassword calls on same input produce different encoded strings (unique salt)', async () => {
const encoded1 = hashPassword('x'); const encoded1 = await hashPassword('x');
const encoded2 = hashPassword('x'); const encoded2 = await hashPassword('x');
expect(encoded1).not.toBe(encoded2); expect(encoded1).not.toBe(encoded2);
}); });
it('Test 4: verifyPassword returns false (never throws) on a malformed stored hash', () => { it('Test 4: verifyPassword returns false (never throws) on a malformed stored hash', async () => {
expect(() => verifyPassword('not-a-valid-hash', 'x')).not.toThrow(); await expect(verifyPassword('not-a-valid-hash', 'x')).resolves.toBe(false);
expect(verifyPassword('not-a-valid-hash', 'x')).toBe(false); await expect(verifyPassword('', 'x')).resolves.toBe(false);
expect(verifyPassword('', 'x')).toBe(false); await expect(verifyPassword('scrypt$bad$data', 'x')).resolves.toBe(false);
expect(verifyPassword('scrypt$bad$data', 'x')).toBe(false);
}); });
it('Test 5: encoded string has scrypt$N$r$p$salt$hash shape (6 $-delimited segments)', () => { it('Test 6 (IN-02): a hash produced by the INLINED scrypt parameters round-trips against the canonical verifyPassword', async () => {
const encoded = hashPassword('testpassword'); // IN-02: the PHC scrypt hash is copy-pasted in three places — the canonical module
// (src/auth/localCredentials.ts), the break-glass CLI (scripts/reset-admin.ts), and the
// CI seed step (.gitea/workflows/ci.yml). If the parameters ever drift, those inlined
// copies would produce hashes the canonical verifyPassword cannot validate, silently
// breaking login for seeded/reset accounts. This test pins the inlined parameter set:
// if anyone changes N/r/p/KEY_LEN in the canonical module without updating the inlined
// copies (or vice versa), this round-trip fails loudly in CI.
//
// These constants MUST match scripts/reset-admin.ts and .gitea/workflows/ci.yml exactly.
const INLINE_N = 16384;
const INLINE_R = 8;
const INLINE_P = 1;
const INLINE_KEY_LEN = 32;
const password = 'inline-roundtrip-pw';
const salt = randomBytes(16);
const hash = scryptSync(password, salt, INLINE_KEY_LEN, {
N: INLINE_N,
r: INLINE_R,
p: INLINE_P,
});
const inlineEncoded = [
'scrypt',
INLINE_N,
INLINE_R,
INLINE_P,
salt.toString('base64url'),
hash.toString('base64url'),
].join('$');
// The canonical verifyPassword must validate a hash produced by the inlined params.
expect(await verifyPassword(inlineEncoded, password)).toBe(true);
expect(await verifyPassword(inlineEncoded, 'wrong')).toBe(false);
});
it('Test 5: encoded string has scrypt$N$r$p$salt$hash shape (6 $-delimited segments)', async () => {
const encoded = await hashPassword('testpassword');
const segments = encoded.split('$'); const segments = encoded.split('$');
expect(segments).toHaveLength(6); expect(segments).toHaveLength(6);
expect(segments[0]).toBe('scrypt'); expect(segments[0]).toBe('scrypt');
+2 -30
View File
@@ -18,33 +18,6 @@ import { Hono } from 'hono';
const TEST_SECRET = 'test-secret-that-is-at-least-32-characters-long-for-jwt'; const TEST_SECRET = 'test-secret-that-is-at-least-32-characters-long-for-jwt';
const TEST_USER_ID = 42; const TEST_USER_ID = 42;
// ── Helpers ────────────────────────────────────────────────────────────────────
/** Create a minimal Hono test app with an issue route and a verify route. */
function makeTestApp(secret: string | undefined) {
return {
setup: async () => {
// Import inside function to pick up modified env
const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import(
'../../src/auth/localSession.js'
);
const app = new Hono();
app.post('/issue', async (c) => {
await issueLocalSessionCookie(c, TEST_USER_ID);
return c.json({ ok: true });
});
app.get('/verify', async (c) => {
const userId = await verifyLocalSessionCookie(c);
return c.json({ userId });
});
return app;
},
};
}
describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => { describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => {
let originalEnv: NodeJS.ProcessEnv; let originalEnv: NodeJS.ProcessEnv;
@@ -60,9 +33,8 @@ describe('issueLocalSessionCookie / verifyLocalSessionCookie', () => {
}); });
it('Test 1: issue then verify round-trips userId', async () => { it('Test 1: issue then verify round-trips userId', async () => {
const { issueLocalSessionCookie, verifyLocalSessionCookie } = await import( const { issueLocalSessionCookie, verifyLocalSessionCookie } =
'../../src/auth/localSession.js' await import('../../src/auth/localSession.js');
);
const app = new Hono(); const app = new Hono();
app.post('/issue', async (c) => { app.post('/issue', async (c) => {
+53 -6
View File
@@ -30,7 +30,13 @@ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import { randomUUID } from 'node:crypto'; import { randomUUID } from 'node:crypto';
import { eq } from 'drizzle-orm'; import { eq } from 'drizzle-orm';
import { db } from '../../src/db/client.js'; import { db } from '../../src/db/client.js';
import { users, memberCredentials, calendars, appConfig, localCredentials } from '../../src/db/schema.js'; import {
users,
memberCredentials,
calendars,
appConfig,
localCredentials,
} from '../../src/db/schema.js';
import { verifyPassword } from '../../src/auth/localCredentials.js'; import { verifyPassword } from '../../src/auth/localCredentials.js';
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -483,6 +489,19 @@ describe('PUT /api/admin/calendars/:id/shared', () => {
.limit(1); .limit(1);
expect(rowA.isShared).toBe(true); expect(rowA.isShared).toBe(true);
}); });
it('WR-07: rejects a calendar id with trailing garbage (e.g. "1abc") with 400', async () => {
const adminId = await seedUser('admin-shared-badid', true);
currentDevUserId = adminId;
const app = await getApp();
// parseInt('1abc', 10) === 1 would have silently accepted this; the strict
// Number.isInteger parse must reject it as a malformed id.
const res = await app.fetch(jsonRequest('PUT', '/api/admin/calendars/1abc/shared'));
expect(res.status).toBe(400);
const body = (await res.json()) as { error: string };
expect(body.error).toBe('Invalid calendar id');
});
}); });
// =========================================================================== // ===========================================================================
@@ -890,7 +909,7 @@ describe('POST /api/admin/members', () => {
.where(eq(localCredentials.userId, body.id)) .where(eq(localCredentials.userId, body.id))
.limit(1); .limit(1);
expect(credRow).toBeDefined(); expect(credRow).toBeDefined();
expect(verifyPassword(credRow.passwordHash, initialPassword)).toBe(true); expect(await verifyPassword(credRow.passwordHash, initialPassword)).toBe(true);
}); });
it('Test 2: duplicate username returns 409 — transaction rolls back (no orphaned users row)', async () => { it('Test 2: duplicate username returns 409 — transaction rolls back (no orphaned users row)', async () => {
@@ -936,7 +955,8 @@ describe('POST /api/admin/members', () => {
it('Test 3: admin can reset any member password without knowing the current one', async () => { it('Test 3: admin can reset any member password without knowing the current one', async () => {
const adminId = await seedUser('admin-reset-pw', true); const adminId = await seedUser('admin-reset-pw', true);
const memberId = await seedUser('member-reset-target', false); // Seeded for DB-state parity; this test creates its own member via the admin API below.
await seedUser('member-reset-target', false);
currentDevUserId = adminId; currentDevUserId = adminId;
const app = await getApp(); const app = await getApp();
@@ -967,8 +987,8 @@ describe('POST /api/admin/members', () => {
.where(eq(localCredentials.userId, newMemberId)) .where(eq(localCredentials.userId, newMemberId))
.limit(1); .limit(1);
expect(credRow).toBeDefined(); expect(credRow).toBeDefined();
expect(verifyPassword(credRow.passwordHash, newPassword)).toBe(true); expect(await verifyPassword(credRow.passwordHash, newPassword)).toBe(true);
expect(verifyPassword(credRow.passwordHash, 'old-password-123')).toBe(false); expect(await verifyPassword(credRow.passwordHash, 'old-password-123')).toBe(false);
}); });
it('Test 4: non-admin gets 403 on POST /members and POST /members/:id/password', async () => { it('Test 4: non-admin gets 403 on POST /members and POST /members/:id/password', async () => {
@@ -1012,7 +1032,9 @@ describe('POST /api/admin/members', () => {
// GET /members should show hasLocalCredential:true for this member // GET /members should show hasLocalCredential:true for this member
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 body = (await getRes.json()) as { members: Array<{ id: number; hasLocalCredential: boolean }> }; const body = (await getRes.json()) as {
members: Array<{ id: number; hasLocalCredential: boolean }>;
};
const memberRow = body.members.find((m) => m.id === newMemberId); const memberRow = body.members.find((m) => m.id === newMemberId);
expect(memberRow).toBeDefined(); expect(memberRow).toBeDefined();
@@ -1023,4 +1045,29 @@ describe('POST /api/admin/members', () => {
expect(adminRow).toBeDefined(); expect(adminRow).toBeDefined();
expect(adminRow!.hasLocalCredential).toBe(false); expect(adminRow!.hasLocalCredential).toBe(false);
}); });
it('WR-05 (no-echo): malformed create-member body never echoes the submitted password or Zod received field', async () => {
const adminId = await seedUser('admin-create-noecho', true);
currentDevUserId = adminId;
const app = await getApp();
// initialPassword too short (< 8) → Zod rejects. The noEchoHook must return only
// { error: 'Invalid request' } and NEVER leak the submitted password or Zod's
// issues[].received field (T-19-06 / the T-19-14 leak this guards against).
const submittedPassword = 'shortpw-secret';
const res = await app.fetch(
jsonRequest('POST', '/api/admin/members', {
displayName: 'No Echo',
username: `noecho-${randomUUID()}`,
initialPassword: submittedPassword.slice(0, 3), // 3 chars — fails min(8)
}),
);
expect(res.status).toBe(400);
const bodyText = await res.text();
expect(bodyText).not.toContain('received');
expect(bodyText).not.toContain('issues');
expect(bodyText).not.toContain(submittedPassword.slice(0, 3));
const parsed = JSON.parse(bodyText) as { error: string };
expect(parsed.error).toBe('Invalid request');
});
}); });
+2 -4
View File
@@ -47,15 +47,13 @@ vi.mock('@hono/oidc-auth', () => ({
})); }));
vi.mock('../../src/auth/devBypass.js', () => ({ vi.mock('../../src/auth/devBypass.js', () => ({
devAuthBypass: devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
() => async (_c: unknown, next: () => Promise<void>) => next(),
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(), devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
})); }));
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({ vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
localAuthMiddleware: localAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
() => async (_c: unknown, next: () => Promise<void>) => next(),
})); }));
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
+58 -30
View File
@@ -5,8 +5,9 @@
* Test 1: valid username+password 200 { ok:true } + Set-Cookie for local-session * Test 1: valid username+password 200 { ok:true } + Set-Cookie for local-session
* Test 2: wrong password 401 { error: 'Invalid credentials' } * Test 2: wrong password 401 { error: 'Invalid credentials' }
* Test 3: unknown username 401 with SAME body as Test 2 (no enumeration / no field discrimination) * Test 3: unknown username 401 with SAME body as Test 2 (no enumeration / no field discrimination)
* Test 4: 5 consecutive failures from one IP 6th returns 429 * Test 4: 5 consecutive failures for one username 6th returns 429
* Test 5: 10 failures 423 (lockedOut); a cleared map resets the counter * Test 5: 10 failures 423 (lockedOut); a cleared map resets the counter
* Test 5b (CR-04): a 423 lockout auto-expires after LOCKOUT_TTL_MS (no admin reset needed)
* Test 6: POST /api/auth/local/logout clears the local-session cookie (expired Set-Cookie) * Test 6: POST /api/auth/local/logout clears the local-session cookie (expired Set-Cookie)
* Test 6b: GET /api/auth/local/logout (alias) also clears the local-session cookie * Test 6b: GET /api/auth/local/logout (alias) also clears the local-session cookie
* Test 7 (no-echo): malformed body (missing password) 400 { error: 'Invalid request' }; * Test 7 (no-echo): malformed body (missing password) 400 { error: 'Invalid request' };
@@ -33,9 +34,9 @@ vi.mock('../../src/db/client.js', () => ({
select: vi.fn().mockImplementation(() => ({ select: vi.fn().mockImplementation(() => ({
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
limit: vi.fn().mockImplementation(() => limit: vi
Promise.resolve(mockCredRow ? [mockCredRow] : []) .fn()
), .mockImplementation(() => Promise.resolve(mockCredRow ? [mockCredRow] : [])),
}), }),
}), }),
})), })),
@@ -56,14 +57,12 @@ let issuedUserId: number | null = null;
let clearSessionCalled = false; let clearSessionCalled = false;
vi.mock('../../src/auth/localSession.js', () => ({ vi.mock('../../src/auth/localSession.js', () => ({
issueLocalSessionCookie: vi.fn().mockImplementation( issueLocalSessionCookie: vi.fn().mockImplementation((_c: unknown, userId: number) => {
(_c: unknown, userId: number) => { issueSessionCalled = true;
issueSessionCalled = true; issuedUserId = userId;
issuedUserId = userId; // Simulate setting a cookie on the context
// Simulate setting a cookie on the context return Promise.resolve();
return Promise.resolve(); }),
}
),
clearLocalSessionCookie: vi.fn().mockImplementation((_c: unknown) => { clearLocalSessionCookie: vi.fn().mockImplementation((_c: unknown) => {
clearSessionCalled = true; clearSessionCalled = true;
}), }),
@@ -75,15 +74,13 @@ vi.mock('../../src/auth/localSession.js', () => ({
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
vi.mock('../../src/auth/devBypass.js', () => ({ vi.mock('../../src/auth/devBypass.js', () => ({
devAuthBypass: devAuthBypass: () => async (_c: unknown, next: () => Promise<void>) => next(),
() => async (_c: unknown, next: () => Promise<void>) => next(),
// Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests // Phase 19 Option C: devSessionCookieMiddleware is a no-op in tests
devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(), devSessionCookieMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
})); }));
vi.mock('../../src/auth/localAuthMiddleware.js', () => ({ vi.mock('../../src/auth/localAuthMiddleware.js', () => ({
localAuthMiddleware: localAuthMiddleware: () => async (_c: unknown, next: () => Promise<void>) => next(),
() => async (_c: unknown, next: () => Promise<void>) => next(),
})); }));
vi.mock('@hono/oidc-auth', () => ({ vi.mock('@hono/oidc-auth', () => ({
@@ -163,7 +160,7 @@ async function getLocalCredentials() {
describe('POST /api/auth/local/login', () => { describe('POST /api/auth/local/login', () => {
it('Test 1: valid username+password → 200 { ok:true } and issueLocalSessionCookie called', async () => { it('Test 1: valid username+password → 200 { ok:true } and issueLocalSessionCookie called', async () => {
const { hashPassword } = await getLocalCredentials(); const { hashPassword } = await getLocalCredentials();
const hash = hashPassword('correcthorse'); const hash = await hashPassword('correcthorse');
mockCredRow = { userId: 5, passwordHash: hash }; mockCredRow = { userId: 5, passwordHash: hash };
const app = await getApp(); const app = await getApp();
@@ -178,7 +175,7 @@ describe('POST /api/auth/local/login', () => {
it('Test 2: wrong password → 401 { error: "Invalid credentials" }', async () => { it('Test 2: wrong password → 401 { error: "Invalid credentials" }', async () => {
const { hashPassword } = await getLocalCredentials(); const { hashPassword } = await getLocalCredentials();
const hash = hashPassword('correcthorse'); const hash = await hashPassword('correcthorse');
mockCredRow = { userId: 5, passwordHash: hash }; mockCredRow = { userId: 5, passwordHash: hash };
const app = await getApp(); const app = await getApp();
@@ -194,7 +191,9 @@ describe('POST /api/auth/local/login', () => {
mockCredRow = undefined; // No credential row found mockCredRow = undefined; // No credential row found
const app = await getApp(); const app = await getApp();
const res = await app.fetch(makeLoginRequest({ username: 'unknown-user', password: 'anypassword' })); const res = await app.fetch(
makeLoginRequest({ username: 'unknown-user', password: 'anypassword' }),
);
expect(res.status).toBe(401); expect(res.status).toBe(401);
const body = (await res.json()) as { error: string }; const body = (await res.json()) as { error: string };
@@ -211,14 +210,14 @@ describe('POST /api/auth/local/login', () => {
// 5 failures to trigger the rate window // 5 failures to trigger the rate window
for (let i = 0; i < 5; i++) { for (let i = 0; i < 5; i++) {
const res = await app.fetch( const res = await app.fetch(
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1') makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1'),
); );
expect(res.status).toBe(401); expect(res.status).toBe(401);
} }
// 6th attempt from same IP → 429 // 6th attempt from same IP → 429
const res6 = await app.fetch( const res6 = await app.fetch(
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1') makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.1'),
); );
expect(res6.status).toBe(429); expect(res6.status).toBe(429);
const body = (await res6.json()) as { error: string }; const body = (await res6.json()) as { error: string };
@@ -232,29 +231,58 @@ describe('POST /api/auth/local/login', () => {
// 10 failures from same IP → lockout // 10 failures from same IP → lockout
for (let i = 0; i < 10; i++) { for (let i = 0; i < 10; i++) {
await app.fetch( await app.fetch(makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'));
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2')
);
} }
// 11th attempt → 423 (locked) // 11th attempt → 423 (locked)
const res11 = await app.fetch( const res11 = await app.fetch(
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2') makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'),
); );
expect(res11.status).toBe(423); expect(res11.status).toBe(423);
const body = (await res11.json()) as { error: string }; const body = (await res11.json()) as { error: string };
expect(body.error).toBe('Account locked'); expect(body.error).toBe('Account locked');
// Clear the map (simulates admin reset) → counter gone → next attempt is 401 again (not locked) // Clear the map (simulates admin reset) → counter gone → next attempt is 401 again (not locked)
// CR-04: the limiter is keyed on the USERNAME ('alice'), not the IP.
const { loginAttempts } = await import('../../src/routes/localAuth.js'); const { loginAttempts } = await import('../../src/routes/localAuth.js');
loginAttempts.delete('10.0.0.2'); loginAttempts.delete('alice');
const resAfterReset = await app.fetch( const resAfterReset = await app.fetch(
makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2') makeLoginRequest({ username: 'alice', password: 'wrong' }, '10.0.0.2'),
); );
expect(resAfterReset.status).toBe(401); expect(resAfterReset.status).toBe(401);
}); });
it('Test 5b (CR-04): a 423 lockout auto-expires after the TTL — no admin reset needed', async () => {
mockCredRow = undefined;
const app = await getApp();
// 10 failures → lockout for username 'bob'
for (let i = 0; i < 10; i++) {
await app.fetch(makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'));
}
// Confirm locked (423)
const resLocked = await app.fetch(
makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'),
);
expect(resLocked.status).toBe(423);
// Simulate the TTL elapsing by back-dating lockedAt well past LOCKOUT_TTL_MS (15 min).
const { loginAttempts } = await import('../../src/routes/localAuth.js');
const entry = loginAttempts.get('bob');
expect(entry?.lockedOut).toBe(true);
if (entry) entry.lockedAt = Date.now() - 16 * 60 * 1000;
// Next attempt: the lockout has expired → handler drops the entry and processes the
// login normally, so a wrong password is a fresh 401 (not a 423). CR-04: self-healing.
const resAfterTtl = await app.fetch(
makeLoginRequest({ username: 'bob', password: 'wrong' }, '10.0.0.3'),
);
expect(resAfterTtl.status).toBe(401);
});
it('Test 7 (no-echo): malformed body (missing password) → 400 { error: "Invalid request" }; body has no echoed value or Zod received field', async () => { it('Test 7 (no-echo): malformed body (missing password) → 400 { error: "Invalid request" }; body has no echoed value or Zod received field', async () => {
const app = await getApp(); const app = await getApp();
// Body with username but missing password (Zod will reject) // Body with username but missing password (Zod will reject)
@@ -263,7 +291,7 @@ describe('POST /api/auth/local/login', () => {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '1.2.3.4' }, headers: { 'Content-Type': 'application/json', 'x-forwarded-for': '1.2.3.4' },
body: JSON.stringify({ username: 'mysecretusername' }), body: JSON.stringify({ username: 'mysecretusername' }),
}) }),
); );
expect(res.status).toBe(400); expect(res.status).toBe(400);
@@ -287,7 +315,7 @@ describe('POST /api/auth/local/logout', () => {
new Request('http://localhost/api/auth/local/logout', { new Request('http://localhost/api/auth/local/logout', {
method: 'POST', method: 'POST',
headers: { 'x-forwarded-for': '1.2.3.4' }, headers: { 'x-forwarded-for': '1.2.3.4' },
}) }),
); );
expect(res.status).toBe(200); expect(res.status).toBe(200);
@@ -304,7 +332,7 @@ describe('GET /api/auth/local/logout', () => {
new Request('http://localhost/api/auth/local/logout', { new Request('http://localhost/api/auth/local/logout', {
method: 'GET', method: 'GET',
headers: { 'x-forwarded-for': '1.2.3.4' }, headers: { 'x-forwarded-for': '1.2.3.4' },
}) }),
); );
expect(res.status).toBe(200); expect(res.status).toBe(200);
+117 -55
View File
@@ -274,7 +274,7 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
const oldPassword = 'old-password-correct-123'; const oldPassword = 'old-password-correct-123';
const newPassword = 'new-password-secure-456'; const newPassword = 'new-password-secure-456';
const storedHash = hashPassword(oldPassword); const storedHash = await hashPassword(oldPassword);
let updatedHash: string | null = null; let updatedHash: string | null = null;
// Mock sequence: resolveUserId (devBypass sets user), then: // Mock sequence: resolveUserId (devBypass sets user), then:
@@ -290,16 +290,20 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]), limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]),
}), }),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}), }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any; } as any;
} }
// fallback for other selects // fallback for other selects
return { return {
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}), }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any; } as any;
@@ -326,15 +330,15 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
// The updatedHash must verify the new password // The updatedHash must verify the new password
expect(updatedHash).not.toBeNull(); expect(updatedHash).not.toBeNull();
const { verifyPassword } = await import('../../src/auth/localCredentials.js'); const { verifyPassword } = await import('../../src/auth/localCredentials.js');
expect(verifyPassword(updatedHash!, newPassword)).toBe(true); expect(await verifyPassword(updatedHash!, newPassword)).toBe(true);
expect(verifyPassword(updatedHash!, oldPassword)).toBe(false); expect(await verifyPassword(updatedHash!, oldPassword)).toBe(false);
}); });
it('Test 2: wrong currentPassword → 401 and update is NOT called', async () => { it('Test 2: wrong currentPassword → 403 and update is NOT called', async () => {
const { db } = await import('../../src/db/client.js'); const { db } = await import('../../src/db/client.js');
const realPassword = 'real-password-correct-789'; const realPassword = 'real-password-correct-789';
const storedHash = hashPassword(realPassword); const storedHash = await hashPassword(realPassword);
let updateWasCalled = false; let updateWasCalled = false;
let callCount = 0; let callCount = 0;
@@ -346,7 +350,9 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
where: vi.fn().mockReturnValue({ where: vi.fn().mockReturnValue({
limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]), limit: vi.fn().mockResolvedValue([{ passwordHash: storedHash, userId: 1 }]),
}), }),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}), }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any; } as any;
@@ -354,7 +360,9 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
return { return {
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}), }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any; } as any;
@@ -374,7 +382,9 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
body: JSON.stringify({ currentPassword: 'WRONG-password', newPassword: 'new-pass-12345678' }), body: JSON.stringify({ currentPassword: 'WRONG-password', newPassword: 'new-pass-12345678' }),
}); });
expect(res.status).toBe(401); // CR-03: wrong current password returns 403 (in-app authz failure), NOT 401.
// A 401 would be interpreted by the PWA as session expiry and log the user out.
expect(res.status).toBe(403);
const body = (await res.json()) as { error: string }; const body = (await res.json()) as { error: string };
expect(body.error).toBe('Current password incorrect'); expect(body.error).toBe('Current password incorrect');
// Update must NOT have been called // Update must NOT have been called
@@ -384,13 +394,18 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
it('Test 3: user with no local_credentials row → 404', async () => { it('Test 3: user with no local_credentials row → 404', async () => {
const { db } = await import('../../src/db/client.js'); const { db } = await import('../../src/db/client.js');
vi.mocked(db.select).mockImplementation(() => ({ vi.mocked(db.select).mockImplementation(
from: vi.fn().mockReturnValue({ () =>
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), ({
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), from: vi.fn().mockReturnValue({
}), where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any innerJoin: vi.fn().mockReturnValue({
} as any)); innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
);
const { app } = await import('../../src/index.js'); const { app } = await import('../../src/index.js');
const res = await app.request('/api/me/password', { const res = await app.request('/api/me/password', {
@@ -401,6 +416,27 @@ describe('POST /api/me/password — self-change password (AUTH-LOCAL-09)', () =>
expect(res.status).toBe(404); expect(res.status).toBe(404);
}); });
it('Test 4 (WR-05 no-echo): malformed body never echoes the submitted password or Zod received field', async () => {
// newPassword too short (< 8) → Zod rejects via meNoEchoHook. The response must be
// ONLY { error: 'Invalid request' } and must NOT leak the submitted password or the
// Zod issues[].received field (T-19-06).
const { app } = await import('../../src/index.js');
const submitted = 'my-secret-current-pw';
const res = await app.request('/api/me/password', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ currentPassword: submitted, newPassword: 'short' }),
});
expect(res.status).toBe(400);
const bodyText = await res.text();
expect(bodyText).not.toContain(submitted);
expect(bodyText).not.toContain('received');
expect(bodyText).not.toContain('issues');
const parsed = JSON.parse(bodyText) as { error: string };
expect(parsed.error).toBe('Invalid request');
});
}); });
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@@ -434,7 +470,9 @@ describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => {
return { return {
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }), where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}), }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any; } as any;
@@ -460,7 +498,9 @@ describe('GET /api/me — hasLocalCredential (AUTH-LOCAL-17)', () => {
return { return {
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }), where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue(limitResult) }),
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}), }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
} as any; } as any;
@@ -497,14 +537,14 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
let deletedLocalCreds = false; let deletedLocalCreds = false;
// Mock: SELECT users WHERE oidc_iss = iss AND oidc_sub = sub → no conflict (empty) // Mock: SELECT users WHERE oidc_iss = iss AND oidc_sub = sub → no conflict (empty)
let txSelectCount = 0;
const mockTx = { const mockTx = {
select: vi.fn().mockImplementation(() => { select: vi.fn().mockImplementation(() => {
txSelectCount++;
return { return {
from: vi.fn().mockReturnValue({ from: vi.fn().mockReturnValue({
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // no conflict where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // no conflict
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}), }),
}; };
}), }),
@@ -522,18 +562,25 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
})), })),
}; };
vi.mocked(db.select).mockImplementation(() => ({ vi.mocked(db.select).mockImplementation(
from: vi.fn().mockReturnValue({ () =>
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict ({
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), from: vi.fn().mockReturnValue({
}), where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([]) }), // preflight: no conflict
// eslint-disable-next-line @typescript-eslint/no-explicit-any innerJoin: vi.fn().mockReturnValue({
} as any)); innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
);
// eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(db).transaction = vi
vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise<void>) => { .fn()
await fn(mockTx); // eslint-disable-next-line @typescript-eslint/no-explicit-any
}); .mockImplementation(async (fn: (tx: any) => Promise<void>) => {
await fn(mockTx);
});
await linkOidcToUser(42, iss, sub); await linkOidcToUser(42, iss, sub);
@@ -551,15 +598,20 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
let deletedLocalCreds = false; let deletedLocalCreds = false;
// Preflight SELECT finds a conflicting user (id=99, different from target=42) // Preflight SELECT finds a conflicting user (id=99, different from target=42)
vi.mocked(db.select).mockImplementation(() => ({ vi.mocked(db.select).mockImplementation(
from: vi.fn().mockReturnValue({ () =>
where: vi.fn().mockReturnValue({ ({
limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict! from: vi.fn().mockReturnValue({
}), where: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), limit: vi.fn().mockResolvedValue([{ id: conflictingUserId }]), // conflict!
}), }),
// eslint-disable-next-line @typescript-eslint/no-explicit-any innerJoin: vi.fn().mockReturnValue({
} as any)); innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
);
// Transaction should NEVER be called on conflict // Transaction should NEVER be called on conflict
const mockTx = { const mockTx = {
@@ -570,10 +622,12 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
}), }),
})), })),
}; };
// eslint-disable-next-line @typescript-eslint/no-explicit-any vi.mocked(db).transaction = vi
vi.mocked(db).transaction = vi.fn().mockImplementation(async (fn: (tx: any) => Promise<void>) => { .fn()
await fn(mockTx); // eslint-disable-next-line @typescript-eslint/no-explicit-any
}); .mockImplementation(async (fn: (tx: any) => Promise<void>) => {
await fn(mockTx);
});
// Should throw OidcLinkConflictError, not proceed to transaction // Should throw OidcLinkConflictError, not proceed to transaction
await expect(linkOidcToUser(42, iss, sub)).rejects.toThrow(OidcLinkConflictError); await expect(linkOidcToUser(42, iss, sub)).rejects.toThrow(OidcLinkConflictError);
@@ -588,13 +642,20 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
const { db } = await import('../../src/db/client.js'); const { db } = await import('../../src/db/client.js');
// Mock db — not needed for route shape test but avoids errors // Mock db — not needed for route shape test but avoids errors
vi.mocked(db.select).mockImplementation(() => ({ vi.mocked(db.select).mockImplementation(
from: vi.fn().mockReturnValue({ () =>
where: vi.fn().mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }), ({
innerJoin: vi.fn().mockReturnValue({ innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }) }), from: vi.fn().mockReturnValue({
}), where: vi
// eslint-disable-next-line @typescript-eslint/no-explicit-any .fn()
} as any)); .mockReturnValue({ limit: vi.fn().mockResolvedValue([{ isAdmin: false }]) }),
innerJoin: vi.fn().mockReturnValue({
innerJoin: vi.fn().mockReturnValue({ where: vi.fn().mockResolvedValue([]) }),
}),
}),
// eslint-disable-next-line @typescript-eslint/no-explicit-any
}) as any,
);
const { app } = await import('../../src/index.js'); const { app } = await import('../../src/index.js');
const res = await app.request('/api/me/link-oidc', { const res = await app.request('/api/me/link-oidc', {
@@ -607,7 +668,8 @@ describe('linkOidcToUser helper (AUTH-LOCAL-10, T-19-08)', () => {
const body = (await res.json()) as { authorizationUrl?: string; state?: string }; const body = (await res.json()) as { authorizationUrl?: string; state?: string };
// The response must have at minimum a signedState field (or authorizationUrl) // The response must have at minimum a signedState field (or authorizationUrl)
// — the exact shape depends on implementation; assert it's an object with a useful field // — the exact shape depends on implementation; assert it's an object with a useful field
const hasInitiationPayload = 'authorizationUrl' in body || 'state' in body || 'signedState' in body; const hasInitiationPayload =
'authorizationUrl' in body || 'state' in body || 'signedState' in body;
expect(hasInitiationPayload).toBe(true); expect(hasInitiationPayload).toBe(true);
}); });
}); });
+23 -9
View File
@@ -32,13 +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 typecheck` | Run `tsc --noEmit` without emitting files | | `pnpm --filter @familysync/pwa lint` | Run ESLint over `src/` and `e2e/` with zero warnings allowed |
| `pnpm --filter @familysync/pwa test` | Run Vitest test suite once (`vitest run`) | | `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 ## Source layout
@@ -48,7 +52,7 @@ src/
components/ # Shared UI components co-located with their *.test.tsx files components/ # Shared UI components co-located with their *.test.tsx files
hooks/ # Custom React hooks (useListSSE, usePushSubscription, useFocusTrap) hooks/ # Custom React hooks (useListSSE, usePushSubscription, useFocusTrap)
lib/ # Pure helpers: calendarConfig, colorUtils, eventDateTime, hydrateEvents, loginRedirect lib/ # Pure helpers: calendarConfig, colorUtils, eventDateTime, hydrateEvents, loginRedirect
routes/ # React Router route components with co-located tests (ListDetail, ListsIndex) routes/ # React Router route components with co-located tests (AdminPage, ListDetail, ListsIndex, LoginPage, SetupPage)
store/ # Zustand stores: calendarStore, listsStore store/ # Zustand stores: calendarStore, listsStore
styles/ # Global CSS styles/ # Global CSS
main.tsx # App entry point — React Query client, router, global error handlers main.tsx # App entry point — React Query client, router, global error handlers
@@ -72,7 +76,7 @@ In development the Vite proxy routes `/api` requests to the API server on port 3
## Testing ## Testing
Tests are co-located with their source files (`*.test.tsx` / `*.test.ts`) and use React Testing Library + `@testing-library/jest-dom`. The test environment is `jsdom`. Unit and integration tests are co-located with their source files (`*.test.tsx` / `*.test.ts`) and use React Testing Library + `@testing-library/jest-dom`. The test environment is `jsdom`.
```bash ```bash
# run once # run once
@@ -82,6 +86,16 @@ pnpm --filter @familysync/pwa test
pnpm --filter @familysync/pwa exec vitest pnpm --filter @familysync/pwa exec vitest
``` ```
End-to-end tests live in the `e2e/` directory and run with Playwright (`@playwright/test` 1.60.0). They cover login, calendar, lists, layout, admin, and timezone verification flows.
```bash
# headless
pnpm --filter @familysync/pwa test:e2e
# with Playwright UI
pnpm --filter @familysync/pwa test:e2e:ui
```
No coverage threshold is configured. Run `pnpm --filter @familysync/pwa typecheck` separately — Vitest uses esbuild and will not surface TypeScript errors. No coverage threshold is configured. Run `pnpm --filter @familysync/pwa typecheck` separately — Vitest uses esbuild and will not surface TypeScript errors.
## PWA install notes ## PWA install notes
+3 -34
View File
@@ -30,7 +30,7 @@
* pnpm --filter @familysync/pwa test:e2e --grep "login" * pnpm --filter @familysync/pwa test:e2e --grep "login"
* pnpm --filter @familysync/pwa exec playwright test --project=desktop login.spec.ts * pnpm --filter @familysync/pwa exec playwright test --project=desktop login.spec.ts
*/ */
import { test, expect, type BrowserContext } from '@playwright/test'; import { test, expect } from '@playwright/test';
// Selectors derived from 19-UI-SPEC.md Surfaces 3-7 (locked by plan 04 implementation) // Selectors derived from 19-UI-SPEC.md Surfaces 3-7 (locked by plan 04 implementation)
const SELECTORS = { const SELECTORS = {
@@ -43,32 +43,6 @@ const SELECTORS = {
errorMessage: '[role="status"]', errorMessage: '[role="status"]',
}; };
/**
* Build an unauthenticated browser context by clearing all cookies and storage.
* The devSessionCookieMiddleware issues a new local-session cookie on each API
* request, so we need to clear the cookie from the BROWSER side. Navigating to
* a page that clears the cookie header is the reliable approach in Playwright.
*/
async function makeUnauthContext(
context: BrowserContext,
baseURL: string,
): Promise<void> {
// Clear all cookies (removes the local-session cookie set by prior API calls)
await context.clearCookies();
// Also clear localStorage/sessionStorage to avoid any cached auth state
const page = await context.newPage();
try {
// Navigate somewhere to gain origin access, then clear storage
await page.goto(baseURL, { waitUntil: 'domcontentloaded', timeout: 10_000 }).catch(() => {});
await page.evaluate(() => {
try { localStorage.clear(); } catch { /* cross-origin or unavailable */ }
try { sessionStorage.clear(); } catch { /* cross-origin or unavailable */ }
});
} finally {
await page.close();
}
}
// Only run these specs on the desktop profile. The login form is a standard web // Only run these specs on the desktop profile. The login form is a standard web
// page (not PWA-specific) and Chromium handles cookies most consistently for this test. // page (not PWA-specific) and Chromium handles cookies most consistently for this test.
// iphone/pixel still reach the authed app via the bypass-issued cookie (unchanged behavior). // iphone/pixel still reach the authed app via the bypass-issued cookie (unchanged behavior).
@@ -78,9 +52,7 @@ test.describe('Login form — real auth round-trip (desktop/Chromium only)', ()
'Login form tests only run on Chromium (desktop profile) — other profiles use the bypass cookie', 'Login form tests only run on Chromium (desktop profile) — other profiles use the bypass cookie',
); );
test('/login renders all brand + form surfaces (UI-SPEC Surfaces 2-7)', async ({ test('/login renders all brand + form surfaces (UI-SPEC Surfaces 2-7)', async ({ page }) => {
page,
}) => {
// Navigate DIRECTLY to /login rather than asserting an unauthenticated root→/login // Navigate DIRECTLY to /login rather than asserting an unauthenticated root→/login
// redirect: under the always-on DEV_AUTH_BYPASS, /api/me is authed via DEV_USER // redirect: under the always-on DEV_AUTH_BYPASS, /api/me is authed via DEV_USER
// injection regardless of the cookie, so visiting / lands on /calendar and a // injection regardless of the cookie, so visiting / lands on /calendar and a
@@ -129,10 +101,7 @@ test.describe('Login form — real auth round-trip (desktop/Chromium only)', ()
await expect(page).toHaveURL(/\/login/); await expect(page).toHaveURL(/\/login/);
}); });
test('correct devuser/devpass logs in and navigates out of /login', async ({ test('correct devuser/devpass logs in and navigates out of /login', async ({ page, context }) => {
page,
context,
}) => {
await context.clearCookies(); await context.clearCookies();
await page.goto('/login', { waitUntil: 'domcontentloaded' }); await page.goto('/login', { waitUntil: 'domcontentloaded' });
+5 -5
View File
@@ -195,10 +195,7 @@ export default function App() {
Phase 19: shown when the user is unauthenticated AND localEnabled === true. Phase 19: shown when the user is unauthenticated AND localEnabled === true.
The route itself always renders LoginPage (authMode gating is in the `*` route gate below). The route itself always renders LoginPage (authMode gating is in the `*` route gate below).
LoginPage receives authMode so it can show the optional OIDC button when oidcEnabled. */} LoginPage receives authMode so it can show the optional OIDC button when oidcEnabled. */}
<Route <Route path="/login" element={<LoginPage authMode={authModeQuery.data} />} />
path="/login"
element={<LoginPage authMode={authModeQuery.data} />}
/>
{/* All other routes are gated on setup completion */} {/* All other routes are gated on setup completion */}
<Route <Route
@@ -213,7 +210,10 @@ export default function App() {
) : meQuery.isError && !meQuery.isLoading && authModeQuery.data?.localEnabled ? ( ) : meQuery.isError && !meQuery.isLoading && authModeQuery.data?.localEnabled ? (
// Unauthenticated + localEnabled: redirect to /login // Unauthenticated + localEnabled: redirect to /login
<Navigate to="/login" replace /> <Navigate to="/login" replace />
) : meQuery.isError && !meQuery.isLoading && !authModeQuery.data?.localEnabled && authModeQuery.data?.oidcEnabled ? ( ) : meQuery.isError &&
!meQuery.isLoading &&
!authModeQuery.data?.localEnabled &&
authModeQuery.data?.oidcEnabled ? (
// Unauthenticated + OIDC-only mode: top-level redirect to /api/login (today's behavior) // Unauthenticated + OIDC-only mode: top-level redirect to /api/login (today's behavior)
// Use a render side-effect via useEffect isn't available here; use a helper element // Use a render side-effect via useEffect isn't available here; use a helper element
<OidcRedirect /> <OidcRedirect />
+37 -16
View File
@@ -103,10 +103,7 @@ export async function fetchAuthMode(): Promise<{ localEnabled: boolean; oidcEnab
* *
* Throws nothing on 200 OK the local-session cookie is set by the server. * Throws nothing on 200 OK the local-session cookie is set by the server.
*/ */
export async function fetchLocalLogin(body: { export async function fetchLocalLogin(body: { username: string; password: string }): Promise<void> {
username: string;
password: string;
}): Promise<void> {
const res = await fetch('/api/auth/local/login', { const res = await fetch('/api/auth/local/login', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
@@ -141,9 +138,14 @@ export async function fetchLocalLogout(): Promise<void> {
* Requires the user's current password and a new password (min 8 chars). * Requires the user's current password and a new password (min 8 chars).
* *
* Status codes: * Status codes:
* 401 wrong current password (throws Error with code 'wrong-current') * 403 wrong current password (throws Error('wrong-current')) NOT a session expiry
* 422 validation failure (throws Error with code 'validation') * 401 / opaqueredirect genuine session expiry (throws SessionExpiredError)
* other non-ok generic error * other non-ok generic error (throws Error('server'))
*
* CR-03: the server returns 403 (not 401) for an incorrect current password so this
* client can distinguish an in-app authorization failure from a real session expiry.
* Treating that case as 401 would route it to the global MutationCache session-expiry
* handler and forcibly log the user out for a simple mistyped password.
*/ */
export async function fetchChangePassword(body: { export async function fetchChangePassword(body: {
currentPassword: string; currentPassword: string;
@@ -157,6 +159,8 @@ export async function fetchChangePassword(body: {
body: JSON.stringify(body), body: JSON.stringify(body),
}); });
// 403 → wrong current password (in-app). Check BEFORE the 401 session-expiry branch.
if (res.status === 403) throw new Error('wrong-current');
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError(); if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
if (!res.ok) { if (!res.ok) {
const detail = (await res.json().catch(() => ({}))) as { code?: string }; const detail = (await res.json().catch(() => ({}))) as { code?: string };
@@ -168,10 +172,14 @@ export async function fetchChangePassword(body: {
* POST /api/admin/members create a new local member account (Phase 19, Surface 11A). * POST /api/admin/members create a new local member account (Phase 19, Surface 11A).
* Admin-only; server enforces requireAdmin. * Admin-only; server enforces requireAdmin.
* *
* Request contract: the server's createMemberSchema requires
* { displayName, username, initialPassword }
* (see apps/api/src/routes/admin.ts). The caller-facing `password` field is mapped to
* `initialPassword` here so the request validates server-side.
*
* Status codes: * Status codes:
* 409 username already taken * 409 username already taken (throws Error with message 'conflict')
* 422 validation failure (short password / mismatch) * other non-ok generic error (throws Error('server'))
* other non-ok generic error
*/ */
export async function fetchCreateMember(body: { export async function fetchCreateMember(body: {
displayName: string; displayName: string;
@@ -183,10 +191,19 @@ export async function fetchCreateMember(body: {
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'include', credentials: 'include',
redirect: 'manual', redirect: 'manual',
body: JSON.stringify(body), // CR-02: the server expects `initialPassword`, not `password`. Send the field it
// validates against — otherwise Zod rejects every create with a generic 400.
body: JSON.stringify({
displayName: body.displayName,
username: body.username,
initialPassword: body.password,
}),
}); });
if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError(); if (res.type === 'opaqueredirect' || res.status === 401) throw new SessionExpiredError();
// 409 → username conflict. The server returns { error: 'Username already in use' } (no
// `code` field), so map the status to the 'conflict' sentinel the AdminPage handler expects.
if (res.status === 409) throw new Error('conflict');
if (!res.ok) { if (!res.ok) {
const detail = (await res.json().catch(() => ({}))) as { code?: string }; const detail = (await res.json().catch(() => ({}))) as { code?: string };
throw new Error(detail.code ?? 'server'); throw new Error(detail.code ?? 'server');
@@ -219,11 +236,15 @@ export async function fetchAdminResetPassword(
/** /**
* POST /api/me/link-oidc initiate the OIDC-link flow for the current local user (Surface 13). * POST /api/me/link-oidc initiate the OIDC-link flow for the current local user (Surface 13).
* *
* The server returns a redirect URL to begin the OIDC authorization-code flow with a * The server returns the OIDC authorization endpoint URL (with a signed `state` parameter
* state parameter encoding the linkUserId claim. The caller should follow the redirect * encoding the linkUserId claim) to begin the authorization-code flow. The caller should
* via top-level navigation (window.location.href = result.redirectUrl). * follow it via top-level navigation (window.location.href = authorizationUrl) when present.
*
* authorizationUrl is null when OIDC is not configured in env (the server cannot build the
* URL); callers MUST handle that case and surface an error instead of navigating to null.
* The server contract is { signedState, authorizationUrl } (see apps/api/src/routes/me.ts).
*/ */
export async function fetchLinkOidc(): Promise<{ redirectUrl: string }> { export async function fetchLinkOidc(): Promise<{ authorizationUrl: string | null }> {
const res = await fetch('/api/me/link-oidc', { const res = await fetch('/api/me/link-oidc', {
method: 'POST', method: 'POST',
credentials: 'include', credentials: 'include',
@@ -234,7 +255,7 @@ export async function fetchLinkOidc(): Promise<{ redirectUrl: string }> {
if (!res.ok) { if (!res.ok) {
throw new Error(`fetchLinkOidc failed: ${res.status}`); throw new Error(`fetchLinkOidc failed: ${res.status}`);
} }
return res.json() as Promise<{ redirectUrl: string }>; return res.json() as Promise<{ signedState: string; authorizationUrl: string | null }>;
} }
// ── /api/me ──────────────────────────────────────────────────────────────── // ── /api/me ────────────────────────────────────────────────────────────────
@@ -28,7 +28,14 @@ vi.mock('../hooks/usePushSubscription.js', () => ({
// Mock client so the test doesn't make real network calls. // Mock client so the test doesn't make real network calls.
vi.mock('../api/client.js', () => ({ vi.mock('../api/client.js', () => ({
fetchMe: vi.fn().mockResolvedValue({ fetchMe: vi.fn().mockResolvedValue({
user: { id: 1, displayName: 'Test', color: '#4a90d9', isAdmin: false, needsProviderSetup: false, hasLocalCredential: false }, user: {
id: 1,
displayName: 'Test',
color: '#4a90d9',
isAdmin: false,
needsProviderSetup: false,
hasLocalCredential: false,
},
}), }),
fetchAuthMode: vi.fn().mockResolvedValue({ localEnabled: true, oidcEnabled: false }), fetchAuthMode: vi.fn().mockResolvedValue({ localEnabled: true, oidcEnabled: false }),
fetchChangePassword: vi.fn().mockResolvedValue(undefined), fetchChangePassword: vi.fn().mockResolvedValue(undefined),
+12 -7
View File
@@ -500,10 +500,7 @@ export function SettingsSheet({ isOpen, onClose }: SettingsSheetProps) {
{/* Surface 13 — Link OIDC confirmation sheet (hasLocalCredential + oidcEnabled gate) */} {/* Surface 13 — Link OIDC confirmation sheet (hasLocalCredential + oidcEnabled gate) */}
{linkOidcOpen && ( {linkOidcOpen && (
<LinkOidcSheet <LinkOidcSheet isOpen={linkOidcOpen} onClose={() => setLinkOidcOpen(false)} />
isOpen={linkOidcOpen}
onClose={() => setLinkOidcOpen(false)}
/>
)} )}
</> </>
); );
@@ -859,9 +856,15 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
const linkMutation = useMutation({ const linkMutation = useMutation({
mutationFn: fetchLinkOidc, mutationFn: fetchLinkOidc,
onSuccess: (data) => { onSuccess: (data) => {
// Close the sheet and initiate OIDC link flow // authorizationUrl is null when OIDC is not configured in env (server could not
// build the URL). Do NOT navigate to null — surface an error and keep the sheet open.
if (!data.authorizationUrl) {
setError('Something went wrong. Please try again.');
return;
}
// Close the sheet and initiate the OIDC link flow via top-level navigation.
onClose(); onClose();
window.location.href = data.redirectUrl; window.location.href = data.authorizationUrl;
}, },
onError: () => { onError: () => {
setError('Something went wrong. Please try again.'); setError('Something went wrong. Please try again.');
@@ -929,7 +932,9 @@ function LinkOidcSheet({ isOpen, onClose }: LinkOidcSheetProps) {
color: 'var(--color-text-secondary, #6b7280)', color: 'var(--color-text-secondary, #6b7280)',
}} }}
> >
{"After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed."} {
"After linking, you'll sign in with your OIDC provider instead of a username and password. Your local password will be removed."
}
</p> </p>
{/* Secondary note */} {/* Secondary note */}
+1 -2
View File
@@ -1241,8 +1241,7 @@ function ResetPasswordSheet({ isOpen, onClose, member }: ResetPasswordSheetProps
}); });
const isPending = resetMutation.isPending; const isPending = resetMutation.isPending;
const submitDisabled = const submitDisabled = isPending || newPassword.length === 0 || confirmPassword.length === 0;
isPending || newPassword.length === 0 || confirmPassword.length === 0;
if (!isOpen) return null; if (!isOpen) return null;
+3 -5
View File
@@ -146,10 +146,7 @@ export function LoginPage({ authMode }: LoginPageProps) {
const isLoading = loginMutation.isPending; const isLoading = loginMutation.isPending;
const bothNonEmpty = username.trim().length > 0 && password.length > 0; const bothNonEmpty = username.trim().length > 0 && password.length > 0;
const submitDisabled = const submitDisabled =
isLoading || isLoading || !bothNonEmpty || loginError === 'rate-limit' || loginError === 'locked';
!bothNonEmpty ||
loginError === 'rate-limit' ||
loginError === 'locked';
// Derive whether inputs should show error state // Derive whether inputs should show error state
const inputHasError = loginError === 'invalid'; const inputHasError = loginError === 'invalid';
@@ -332,7 +329,8 @@ export function LoginPage({ authMode }: LoginPageProps) {
}} }}
> >
<AlertCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} /> <AlertCircle size={16} aria-hidden="true" style={{ flexShrink: 0 }} />
This account is temporarily locked. Contact your admin to reset access. Too many failed attempts for this account. Try again in about 15 minutes, or
contact your admin to reset access.
</div> </div>
)} )}
+5 -5
View File
@@ -94,11 +94,11 @@
* never the BrandSlot component structure (see 19-UI-SPEC.md §Brand Slot). * never the BrandSlot component structure (see 19-UI-SPEC.md §Brand Slot).
* */ * */
--brand-logo-bg: var(--color-member-0); /* placeholder circle background */ --brand-logo-bg: var(--color-member-0); /* placeholder circle background */
--brand-logo-text: #ffffff; /* placeholder initials color */ --brand-logo-text: #ffffff; /* placeholder initials color */
--brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */ --brand-logo-size: 48px; /* reserved slot height; keep 1:1 aspect */
--brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */ --brand-logo-border-radius: 50%; /* circle for initials; Phase 17 may change */
--brand-app-name: 'FamilySync'; /* drives doc only — not used as CSS content */ --brand-app-name: 'FamilySync'; /* drives doc only — not used as CSS content */
/* /*
* BREAKPOINTS (reference; use in @media queries) * BREAKPOINTS (reference; use in @media queries)
+519 -34
View File
@@ -2,16 +2,22 @@
# API Reference # API Reference
FamilySync exposes a Hono HTTP API served on port 3000. All `/api/*` routes require an authenticated session (OIDC via Authelia, or `DEV_AUTH_BYPASS=true` for local development). Unauthenticated requests to protected routes receive a `302` redirect to Authelia's authorize endpoint, not a `401`, except where noted. FamilySync exposes a Hono HTTP API served on port 3000. All `/api/*` routes require an authenticated session (OIDC via Authelia, local username/password, or `DEV_AUTH_BYPASS=true` for local development). Unauthenticated requests to protected routes receive a `302` redirect to Authelia's authorize endpoint, not a `401`, except where noted.
## Authentication ## Authentication
The API uses OIDC session cookies managed by `@hono/oidc-auth`. The authorization-code + PKCE flow is handled transparently: The API supports two session mechanisms that coexist on the same `/api/*` guard chain:
**OIDC session (Authelia):** Managed by `@hono/oidc-auth`. The authorization-code + PKCE flow is handled transparently:
1. The PWA navigates to `GET /api/login`. This route sits under `/api/*`, where the `@hono/oidc-auth` guard is mounted. 1. The PWA navigates to `GET /api/login`. This route sits under `/api/*`, where the `@hono/oidc-auth` guard is mounted.
2. For a request with no valid session cookie, the OIDC middleware intercepts it before the route handler runs and 302-redirects to Authelia's authorize endpoint. 2. For a request with no valid session cookie, the OIDC middleware intercepts it before the route handler runs and 302-redirects to Authelia's authorize endpoint.
3. Authelia posts the authorization code to `GET /callback`, which exchanges it for tokens and sets an `httpOnly; Secure; SameSite` session cookie, then continues back to `/api/login` — whose handler now redirects to `/` (the app shell). 3. Authelia posts the authorization code to `GET /callback`, which exchanges it for tokens and sets an `httpOnly; Secure; SameSite` session cookie, then continues back to `/api/login` — whose handler now redirects to `/` (the app shell).
4. All subsequent `/api/*` requests carry the session cookie automatically. (The root `/` and static assets are served outside the `/api/*` guard.) 4. All subsequent `/api/*` requests carry the session cookie automatically.
**Local session (username + password):** Available when OIDC is not configured or for members who have not linked an OIDC identity. `POST /api/auth/local/login` issues a signed JWT `local-session` cookie. The local-session middleware validates it on every `/api/*` request and sets the user context, so OIDC guard is bypassed for already-authenticated local users.
**Session precedence:** Local-session middleware runs first. If `c.get('user')` is already set (local session or dev bypass), the OIDC guard is skipped entirely.
**Dev bypass:** When `DEV_AUTH_BYPASS=true` and `NODE_ENV != production`, the OIDC guard is disabled and a fixed dev user (id `1`) is injected into every request. Never enable in production. **Dev bypass:** When `DEV_AUTH_BYPASS=true` and `NODE_ENV != production`, the OIDC guard is disabled and a fixed dev user (id `1`) is injected into every request. Never enable in production.
@@ -19,31 +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/login` | OIDC | Login entry point, redirects to `/` | | GET | `/api/setup/status` | None | Setup wizard completion status |
| GET | `/api/me` | OIDC | Current user identity and color | | POST | `/api/setup/config` | None | Store OIDC and VAPID config (wizard step 1) |
| GET | `/api/events` | OIDC | Windowed calendar occurrences | | POST | `/api/setup/validate/db` | None | Validate DB connectivity (wizard step) |
| POST | `/api/events/create` | OIDC | Enqueue a new event write | | POST | `/api/setup/validate/oidc` | None | Validate OIDC issuer discovery (wizard step) |
| PATCH | `/api/events/:uid/edit` | OIDC | Enqueue an event update | | POST | `/api/setup/validate/vapid` | None | Validate VAPID key pair (wizard step) |
| DELETE | `/api/events/:uid` | OIDC | Enqueue an event delete | | POST | `/api/setup/credential` | None | Store first admin's Fastmail credential (wizard) |
| GET | `/api/events/sync-status` | OIDC | Outbox status for a UID | | POST | `/api/setup/complete` | None | Lock the setup wizard |
| GET | `/api/events/writable-calendars` | OIDC | Calendars the member can write to | | GET | `/api/auth/mode` | None | Auth mode discovery (local vs OIDC enabled) |
| GET | `/api/lists` | OIDC | All lists accessible to the member | | POST | `/api/auth/local/login` | None | Local username+password login |
| POST | `/api/lists` | OIDC | Create a list | | POST | `/api/auth/local/logout` | None | Clear local session cookie |
| PATCH | `/api/lists/:id` | OIDC | Update list name or sharing | | GET | `/api/auth/local/logout` | None | Clear local session cookie (browser redirect alias)|
| DELETE | `/api/lists/:id` | OIDC | Delete a list (owner only) | | GET | `/api/login` | OIDC | OIDC login entry point, redirects to `/` |
| GET | `/api/lists/:id/items` | OIDC | All items in a list | | GET | `/api/me` | Session | Current user identity, role, and setup status |
| POST | `/api/lists/:id/items` | OIDC | Add an item to a list | | POST | `/api/me/credential` | Session | Member self-service Fastmail credential update |
| PATCH | `/api/list-items/:itemId` | OIDC | Update a single list item field | | POST | `/api/me/password` | Session | Member self-change local password |
| DELETE | `/api/list-items/:itemId` | OIDC | Delete a list item | | POST | `/api/me/link-oidc` | Session | Initiate OIDC identity link for a local user |
| GET | `/api/sse/heartbeat` | OIDC | SSE heartbeat stream | | GET | `/api/events` | Session | Windowed calendar occurrences |
| GET | `/api/sse/lists` | OIDC | Scoped live-list SSE stream | | POST | `/api/events/create` | Session | Enqueue a new event write |
| GET | `/api/push/vapid-public-key` | OIDC | VAPID public key for push subscribe | | PATCH | `/api/events/:uid/edit` | Session | Enqueue an event update |
| POST | `/api/push/subscription` | OIDC | Register a push subscription | | DELETE | `/api/events/:uid` | Session | Enqueue an event delete |
| DELETE | `/api/push/subscription` | OIDC | Remove push subscriptions for caller | | 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 |
--- ---
@@ -67,13 +96,190 @@ Unauthenticated. Performs a `SELECT 1` against MariaDB to prove connectivity.
--- ---
## Setup Wizard
The setup wizard surface is reachable pre-authentication. All setup routes return `423` once `isSetupLocked()` returns true (i.e., after `POST /api/setup/complete` has been called or the database has an effective OIDC configuration).
### `GET /api/setup/status`
Returns whether the first-run wizard has been completed. Always reachable (no 423 guard — the PWA needs this to decide whether to show the wizard).
**Response 200**
```json
{ "setupComplete": false, "dbName": "familysync" }
```
`dbName` is the value of the `DB_NAME` environment variable (non-secret, for display in the wizard UI). `setupComplete: true` indicates the wizard is locked.
---
### `POST /api/setup/config`
Stores non-secret OIDC and VAPID configuration into `app_config`. Returns `423` if setup is already locked.
**Request body**
```json
{
"oidcIssuer": "https://auth.example.com",
"oidcClientId": "familysync",
"vapidPublicKey": "BNF8dFt...",
"appExternalUrl": "https://familysync.example.com"
}
```
| Field | Type | Required | Constraints |
| ---------------- | ------ | -------- | ------------------------------ |
| `oidcIssuer` | string | Yes | HTTPS URL |
| `oidcClientId` | string | Yes | 1256 characters |
| `vapidPublicKey` | string | Yes | 1512 characters |
| `appExternalUrl` | string | Yes | HTTPS URL, max 512 characters |
**Response 200**
```json
{ "ok": true }
```
---
### `POST /api/setup/validate/db`
Proves DB connectivity via `SELECT 1`. Returns `423` if setup is locked.
**Response 200** — `{ "ok": true }`
**Response 503** — `{ "ok": false, "error": "DB unavailable" }`
---
### `POST /api/setup/validate/oidc`
Fetches `{oidcIssuer}/.well-known/openid-configuration` (5-second timeout) to validate the issuer stored by `POST /api/setup/config`. Returns `423` if setup is locked.
**Response 200** — `{ "ok": true }`
**Response 400** — `{ "ok": false, "error": "OIDC discovery failed. Check the issuer URL." }`
---
### `POST /api/setup/validate/vapid`
Validates the VAPID public key stored in `app_config` against the `VAPID_PUBLIC_KEY` and `VAPID_PRIVATE_KEY` environment variables. The submitted public key must exactly equal `process.env.VAPID_PUBLIC_KEY`. Returns `423` if setup is locked.
**Response 200** — `{ "ok": true }`
**Response 400** — `{ "ok": false, "error": "VAPID public key does not match..." }`
---
### `POST /api/setup/credential`
Creates the first admin user (no OIDC identity yet, `claimed: false`) and validates/encrypts/stores their Fastmail CalDAV app password. Returns `423` if setup is locked; `409` if an unclaimed admin row already exists (concurrent wizard request).
**Request body**
```json
{
"fastmailEmail": "broker@fastmail.com",
"appPassword": "xxxx-xxxx-xxxx-xxxx"
}
```
| Field | Type | Required | Constraints |
| --------------- | ------ | -------- | ---------------- |
| `fastmailEmail` | string | Yes | Valid email, max 256 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).
**Response 200** — `{ "ok": true }`
**Error responses:** `400` invalid request; `409` setup already in progress; `423` setup locked; `503` service unavailable.
---
### `POST /api/setup/complete`
Locks the setup wizard by writing `setup_complete=true` to `app_config`. Requires an unclaimed admin user with a Fastmail credential to exist (guards against skipping the credential step). Returns `423` if already locked.
**Response 200** — `{ "ok": true }`
**Response 422** — `{ "error": "Cannot lock setup: no credential configured" }`
---
## Auth Mode
### `GET /api/auth/mode`
Pre-auth endpoint. Returns which authentication methods are currently enabled. Used by the PWA on app load to decide which login flow to present.
`oidcEnabled` is true when `OIDC_ISSUER` is set in the environment **or** when `app_config` has an `oidc_issuer` row (wizard-configured OIDC before container restart). `localEnabled` is always `true`.
**Response 200**
```json
{ "localEnabled": true, "oidcEnabled": false }
```
---
## Local Auth
### `POST /api/auth/local/login`
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)
Timing-oracle defense: `verifyPassword` (scrypt) is always called, even for unknown usernames.
**Request body**
```json
{ "username": "alice", "password": "hunter2" }
```
| Field | Type | Required | Constraints |
| ---------- | ------ | -------- | ---------------- |
| `username` | string | Yes | 1128 characters (trimmed) |
| `password` | string | Yes | 11000 characters |
Zod validation errors never echo received values.
**Response 200**
```json
{ "ok": true }
```
Sets a `local-session` cookie (`httpOnly; Secure; SameSite`).
**Error responses:** `400` invalid request; `401` invalid credentials (same body for wrong password and unknown username — no field discrimination); `423` account locked; `429` too many attempts; `503` service unavailable.
---
### `POST /api/auth/local/logout`
Clears the `local-session` cookie. Also available as `GET /api/auth/local/logout` for browser-redirect compatibility.
**Response 200** — `{ "ok": true }`
---
## Identity ## Identity
### `GET /api/me` ### `GET /api/me`
Returns the authenticated member's identity and their assigned color. Returns the authenticated member's identity, admin role, and credential setup status.
Display name is derived from OIDC claims in priority order: `name``preferred_username``email``sub`. The user row is upserted on first visit (keyed on `oidc_iss` + `oidc_sub`). Display name is derived from OIDC claims in priority order: `name``preferred_username``email``sub`. The user row is upserted on first OIDC visit (keyed on `oidc_iss` + `oidc_sub`).
`isAdmin` is exposed for PWA navigation gating only — it is not the security boundary. The server enforces admin role via `requireAdmin` middleware on every `/api/admin/*` request.
**Response 200** **Response 200**
@@ -82,15 +288,96 @@ Display name is derived from OIDC claims in priority order: `name` → `preferre
"user": { "user": {
"id": 1, "id": 1,
"displayName": "Lucas", "displayName": "Lucas",
"color": "#4A90D9" "color": "#4A90D9",
"isAdmin": true,
"needsProviderSetup": false,
"hasLocalCredential": true
} }
} }
``` ```
| 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. **Error responses:** `401` if the session is invalid.
--- ---
### `POST /api/me/credential`
Member self-service endpoint to set or rotate their own Fastmail CalDAV app password. Validates against CalDAV (PROPFIND) before storing. Always writes to the authenticated member's record — the request body cannot specify a different user ID.
**Request body**
```json
{
"providerType": "caldav",
"fastmailEmail": "member@fastmail.com",
"appPassword": "xxxx-xxxx-xxxx-xxxx"
}
```
| Field | Type | Required | Constraints |
| --------------- | ------ | -------- | ---------------- |
| `providerType` | string | Yes | Must be `"caldav"` |
| `fastmailEmail` | string | Yes | Valid email, max 256 characters |
| `appPassword` | string | Yes | 1500 characters |
**Response 200** — `{ "ok": true }`
**Error responses:** `400` invalid request or CalDAV validation failed; `401` unauthorized; `503` service unavailable.
---
### `POST /api/me/password`
Self-service password change for local-auth members. Requires the current password to be supplied. Returns `403` (not `401`) for a wrong current password to avoid triggering the PWA's global session-expiry handler.
**Request body**
```json
{
"currentPassword": "old-password",
"newPassword": "new-password-min8"
}
```
| Field | Type | Required | Constraints |
| ----------------- | ------ | -------- | ---------------- |
| `currentPassword` | string | Yes | 1+ characters |
| `newPassword` | string | Yes | Minimum 8 characters |
**Response 200** — `{ "ok": true }`
**Error responses:** `400` invalid request; `401` unauthorized; `403` current password incorrect; `404` no local credential found; `503` service unavailable.
---
### `POST /api/me/link-oidc`
Initiates the OIDC authorization-code flow for a locally-authenticated member. Returns a signed `state` JWT and the OIDC authorization URL. The PWA redirects the user to the authorization URL; on successful OIDC login, the `/callback` handler binds the OIDC identity to the local user and removes the local credential.
Returns `authorizationUrl: null` when OIDC is not configured.
**Response 200**
```json
{
"signedState": "eyJ...",
"authorizationUrl": "https://auth.example.com/api/oidc/authorization?..."
}
```
**Error responses:** `401` unauthorized; `503` service unavailable (missing `LOCAL_SESSION_SECRET`).
---
## Calendar Events ## Calendar Events
Calendar data is read from a MariaDB cache populated by the CalDAV broker poller. Write operations enqueue outbox rows; the outbox worker dispatches them to Fastmail CalDAV asynchronously. Clients receive `202 Accepted` immediately and poll `GET /api/events/sync-status` to confirm settlement. Calendar data is read from a MariaDB cache populated by the CalDAV broker poller. Write operations enqueue outbox rows; the outbox worker dispatches them to Fastmail CalDAV asynchronously. Clients receive `202 Accepted` immediately and poll `GET /api/events/sync-status` to confirm settlement.
@@ -615,6 +902,201 @@ Removes all push subscription rows for the authenticated member. Cannot affect a
--- ---
## Admin
All `/api/admin/*` routes require the authenticated member to have the admin role (`users.isAdmin = true`). The `requireAdmin` middleware is the first statement on the admin router — no sub-route is reachable without passing this guard. Non-admins receive `403`.
### `GET /api/admin/members`
Returns all household members with their credential and local-auth status.
**Response 200**
```json
{
"members": [
{
"id": 1,
"displayName": "Lucas",
"color": "#4A90D9",
"hasCredential": true,
"hasLocalCredential": true
}
]
}
```
---
### `POST /api/admin/members`
Creates a new local-auth member: inserts a `users` row and a `local_credentials` row with a hashed initial password in a single transaction. Returns `409` if the username is already in use.
**Request body**
```json
{
"displayName": "Alice",
"username": "alice",
"initialPassword": "minimum8chars"
}
```
| Field | Type | Required | Constraints |
| ----------------- | ------ | -------- | ---------------- |
| `displayName` | string | Yes | 1256 characters |
| `username` | string | Yes | 1128 characters |
| `initialPassword` | string | Yes | Minimum 8 characters |
Zod validation errors never echo received values (the initial password is never included in error responses).
**Response 201**
```json
{ "id": 2 }
```
**Error responses:** `400` invalid request; `403` not admin; `409` username already in use; `503` service unavailable.
---
### `POST /api/admin/members/:id/password`
Admin resets a local member's password without requiring the current password. Also clears any active rate-limit or lockout state for the member's username.
**Path parameter:** `id` — integer member ID.
**Request body**
```json
{ "newPassword": "minimum8chars" }
```
| Field | Type | Required | Constraints |
| ------------- | ------ | -------- | -------------------- |
| `newPassword` | string | Yes | Minimum 8 characters |
**Response 200** — `{ "ok": true }`
**Error responses:** `400` invalid request; `403` not admin; `404` member not found or has no local credential; `503` service unavailable.
---
### `POST /api/admin/credentials`
Validates and stores a Fastmail CalDAV app password for any household member. Performs a PROPFIND against Fastmail CalDAV to verify the credential before encrypting and persisting it.
**Request body**
```json
{
"userId": 2,
"providerType": "caldav",
"fastmailEmail": "member@fastmail.com",
"appPassword": "xxxx-xxxx-xxxx-xxxx"
}
```
| 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 | 1500 characters |
Zod validation errors and CalDAV validation failures return `400` with `{ "error": "Invalid request" }` — the app password is never echoed.
**Response 200** — `{ "ok": true }`
**Error responses:** `400` invalid request or CalDAV validation failed; `403` not admin; `503` service unavailable.
---
### `GET /api/admin/calendars`
Lists all synced calendars with their shared-calendar designation.
**Response 200**
```json
{
"calendars": [
{ "id": 1, "displayName": "Personal", "isShared": false },
{ "id": 2, "displayName": "Family", "isShared": true }
]
}
```
---
### `PUT /api/admin/calendars/:id/shared`
Exclusively designates one calendar as the household shared calendar. Clears `isShared` on any previously-shared calendar in the same transaction. Returns `404` if the target calendar does not exist.
**Path parameter:** `id` — integer calendar ID.
**Response 200** — `{ "ok": true }`
**Error responses:** `400` invalid calendar ID; `403` not admin; `404` calendar not found.
---
### `GET /api/admin/config/timezone`
Returns the household IANA timezone and whether it has been explicitly configured (vs. using the system default fallback).
**Response 200**
```json
{ "timezone": "America/Toronto", "isExplicitlySet": true }
```
`isExplicitlySet: false` when no `household_timezone` row exists in `app_config`; `timezone` still contains the resolved fallback value.
---
### `PUT /api/admin/config/timezone`
Validates and upserts the household IANA timezone into `app_config`.
**Request body**
```json
{ "timezone": "America/Toronto" }
```
| Field | Type | Required | Constraints |
| ---------- | ------ | -------- | -------------------------- |
| `timezone` | string | Yes | Valid IANA timezone, 164 characters |
**Response 200** — `{ "ok": true }`
**Error responses:** `400` invalid timezone; `403` not admin.
---
### `POST /api/admin/config/timezone/seed`
Seeds the `household_timezone` key in `app_config` **only when it is not already set** (no-overwrite). Used by the setup wizard and browser timezone auto-detect to store the detected zone without clobbering an admin's explicit choice. Uses `INSERT IGNORE` so the operation is safe under concurrent requests.
**Request body**
```json
{ "timezone": "America/Toronto" }
```
**Response 200**
```json
{ "ok": true, "seeded": true }
```
`seeded: true` when the row was inserted; `seeded: false` when it already existed (no change made).
**Error responses:** `400` invalid timezone; `403` not admin.
---
## Error Codes ## Error Codes
All error responses use a consistent JSON envelope. All error responses use a consistent JSON envelope.
@@ -627,15 +1109,18 @@ All error responses use a consistent JSON envelope.
| ----------- | ------------------------------------------------------------------------------ | | ----------- | ------------------------------------------------------------------------------ |
| `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) | | `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) |
| `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)|
| `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. 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.
--- ---
## Rate Limits ## Rate Limits
No rate limiting is configured in the application layer. <!-- VERIFY: confirm whether Pangolin/Newt or Authelia enforce rate limits at the network edge --> No rate limiting is configured in the application layer for general API routes. Local auth login is rate-limited per-username: 5 failures within 60 seconds returns `429`; 10 cumulative failures locks the account with `423` for 15 minutes. See `POST /api/auth/local/login` for details. <!-- VERIFY: confirm whether Pangolin/Newt or Authelia enforce rate limits at the network edge -->
+57 -26
View File
@@ -8,7 +8,7 @@ FamilySync is a self-hosted family organization hub — a unified, color-coded c
## System Overview ## System Overview
FamilySync follows a layered architecture with Fastmail CalDAV as the external calendar source of truth. The React PWA talks exclusively to a single Hono API backend. The backend handles authentication (Authelia OIDC), calendar read/write via CalDAV (Fastmail), list persistence (MariaDB), and real-time push delivery. FamilySync follows a layered architecture with Fastmail CalDAV as the external calendar source of truth. The React PWA talks exclusively to a single Hono API backend. The backend handles authentication (local username/password and/or Authelia OIDC), calendar read/write via CalDAV (Fastmail), list persistence (MariaDB), and real-time push delivery.
```mermaid ```mermaid
graph TD graph TD
@@ -18,8 +18,8 @@ graph TD
end end
subgraph "API (apps/api — Hono on Node 22)" subgraph "API (apps/api — Hono on Node 22)"
AUTH["OIDC Auth\n(@hono/oidc-auth)"] AUTH["Auth Layer\n(local session + OIDC middleware)"]
ROUTES["API Routes\n/events /lists /me /push /sse"] ROUTES["API Routes\n/events /lists /me /push /sse\n/admin /setup /auth"]
BROKER["CalDAV Broker\n(tsdav + ical.js + rrule)"] BROKER["CalDAV Broker\n(tsdav + ical.js + rrule)"]
OUTBOX["Outbox Worker\n(15s drain loop)"] OUTBOX["Outbox Worker\n(15s drain loop)"]
POLLER["CalDAV Poller\n(5-min setInterval)"] POLLER["CalDAV Poller\n(5-min setInterval)"]
@@ -33,7 +33,7 @@ graph TD
end end
subgraph "External Services" subgraph "External Services"
AUTHELIA["Authelia\n(OIDC / OAuth2 IdP)"] AUTHELIA["Authelia\n(OIDC / OAuth2 IdP — optional)"]
FASTMAIL["Fastmail CalDAV\ncaldav.fastmail.com"] FASTMAIL["Fastmail CalDAV\ncaldav.fastmail.com"]
PUSH_SVC["Browser Push Services\n(APNs / FCM)"] PUSH_SVC["Browser Push Services\n(APNs / FCM)"]
end end
@@ -41,7 +41,7 @@ graph TD
PWA -- "HTTPS (same-origin via Pangolin)" --> ROUTES PWA -- "HTTPS (same-origin via Pangolin)" --> ROUTES
SW -- "push events" --> PWA SW -- "push events" --> PWA
ROUTES --> AUTH ROUTES --> AUTH
AUTH -- "authorization-code + PKCE" --> AUTHELIA AUTH -- "authorization-code + PKCE (when oidcEnabled)" --> AUTHELIA
ROUTES --> BROKER ROUTES --> BROKER
ROUTES --> SSE_LIB ROUTES --> SSE_LIB
ROUTES --> DB ROUTES --> DB
@@ -68,7 +68,7 @@ familysync/
│ │ └── src/ │ │ └── src/
│ │ ├── index.ts # App entry: mounts routes, starts background workers │ │ ├── index.ts # App entry: mounts routes, starts background workers
│ │ ├── routes/ # HTTP route handlers │ │ ├── routes/ # HTTP route handlers
│ │ ├── auth/ # OIDC middleware + dev-bypass + session persistence │ │ ├── auth/ # OIDC middleware + local auth + dev-bypass + session persistence
│ │ ├── broker/ # CalDAV integration layer │ │ ├── broker/ # CalDAV integration layer
│ │ ├── db/ # Drizzle schema, client, migrations │ │ ├── db/ # Drizzle schema, client, migrations
│ │ └── lib/ # Shared dispatchers and utilities │ │ └── lib/ # Shared dispatchers and utilities
@@ -90,12 +90,12 @@ familysync/
| Directory | Purpose | | Directory | Purpose |
| ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | | ---------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `apps/api/src/routes/` | One file per resource — `events.ts`, `lists.ts`, `me.ts`, `push.ts`, `sse.ts`, `health.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), `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) | | `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), `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) |
@@ -103,19 +103,25 @@ familysync/
## 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`, `calendars`, `calendarEvents`, `calendarOutbox`, `lists`, `listShares`, `listItems`, `pushSubscriptions`) | | 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 |
| `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 | | `issueLocalSessionCookie` / `verifyLocalSessionCookie` | `apps/api/src/auth/localSession.ts` | Issues and verifies the `local-session` JWT cookie used by local username/password auth |
| Zustand stores | `apps/pwa/src/store/` | UI-only ephemeral state (open panels, selected date, active tab); server state always in TanStack Query | | `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 |
--- ---
@@ -156,11 +162,23 @@ familysync/
### Authentication ### Authentication
The app supports two auth modes, selectable per-deployment and per-user. `GET /api/auth/mode` (pre-auth) tells the PWA which modes are active.
**Local auth path (Phase 19):**
1. The PWA fetches `GET /api/auth/mode`; when `localEnabled === true` it renders `/login` (`LoginPage`).
2. The user submits credentials; the PWA calls `POST /api/auth/local/login`.
3. The route verifies the scrypt hash from `local_credentials`, then calls `issueLocalSessionCookie` — a signed HS256 JWT issued as a `local-session` HttpOnly cookie.
4. On subsequent requests, `localAuthMiddleware` reads the cookie, verifies the JWT, fetches the users row, and populates `c.get('user')`. The OIDC guard is skipped when `c.get('user')` is already set.
5. A local user can optionally link an OIDC identity via `POST /api/me/link-oidc`; on completion `linkOidcToUser` binds `oidc_iss`/`oidc_sub` to the users row and deletes the `local_credentials` row, converting the account to OIDC-only.
**OIDC path (Authelia):**
1. An unauthenticated browser navigates to `/api/login`. 1. An unauthenticated browser navigates to `/api/login`.
2. The `oidcAuthMiddleware` (`@hono/oidc-auth`) issues a `302` to Authelia's `/authorize` endpoint with PKCE (S256). 2. The `oidcAuthMiddleware` (`@hono/oidc-auth`) issues a `302` to Authelia's `/authorize` endpoint with PKCE (S256).
3. After login, Authelia posts the authorization code to `/callback`; `processOAuthCallback` exchanges it for tokens and issues a signed JWT session cookie. 3. After login, Authelia posts the authorization code to `/callback`; `processOAuthCallback` exchanges it for tokens and issues a signed JWT session cookie.
4. `persistSessionCookie` middleware re-issues the cookie as persistent on every authenticated response so the PWA session survives browser close. 4. `persistSessionCookie` middleware re-issues the cookie as persistent on every authenticated response so the PWA session survives browser close.
5. All `/api/*` routes require the session cookie; a missing or expired session returns a `302` which the PWA's fetch wrappers detect as `opaqueredirect` and convert to a `SessionExpiredError`. 5. All `/api/*` routes require a session (local or OIDC); a missing or expired session returns a `302` which the PWA's fetch wrappers detect as `opaqueredirect` and convert to a `SessionExpiredError`.
--- ---
@@ -185,6 +203,16 @@ routes/lists.ts ──→ db (MariaDB)
routes/sse.ts ──→ lib/listEmitter.ts (subscribeListEvents) routes/sse.ts ──→ lib/listEmitter.ts (subscribeListEvents)
──→ lib/listAccess.ts ──→ lib/listAccess.ts
routes/localAuth.ts ──→ auth/localCredentials.ts (scrypt verify)
──→ auth/localSession.ts (issue cookie)
routes/admin.ts ──→ db (MariaDB: users, local_credentials, calendars)
──→ broker/credentialSync.ts (validate+encrypt+store)
──→ lib/requireAdmin.ts (role guard)
routes/setup.ts ──→ db (app_config)
──→ lib/setupGuard.ts (isSetupLocked)
``` ```
### Frontend data ownership ### Frontend data ownership
@@ -196,6 +224,8 @@ routes/sse.ts ──→ lib/listEmitter.ts (subscribeListEvents)
| Current user | TanStack Query `['me']` | | Current user | TanStack Query `['me']` |
| Writable calendars | TanStack Query `['writableCalendars']` | | Writable calendars | TanStack Query `['writableCalendars']` |
| Outbox sync status | TanStack Query `['syncStatus', uid]` | | Outbox sync status | TanStack Query `['syncStatus', uid]` |
| Auth mode (local/OIDC flags) | TanStack Query `['authMode']` |
| Setup completion status | TanStack Query `['setupStatus']` |
| Selected calendar view + date | Zustand `calendarStore` | | Selected calendar view + date | Zustand `calendarStore` |
| Event form open/mode | Zustand `calendarStore` | | Event form open/mode | Zustand `calendarStore` |
| Active tab, create-list sheet | Zustand `listsStore` | | Active tab, create-list sheet | Zustand `listsStore` |
@@ -210,11 +240,12 @@ routes/sse.ts ──→ lib/listEmitter.ts (subscribeListEvents)
| 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 | | Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE; optional when local auth is enabled |
| Session middleware | `@hono/oidc-auth` — storage-less signed JWT cookies | | 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 |
| 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) |
+47 -12
View File
@@ -18,8 +18,9 @@ All runtime configuration is supplied via environment variables. There are no JS
| `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. |
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. 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.
**Important:** Do not use `drizzle-kit push` against this MariaDB. The `mysql` dialect mis-reads MariaDB 11.x metadata and schedules false destructive operations. Always use `db:generate` + `db:migrate`. **Important:** Do not use `drizzle-kit push` against this MariaDB. The `mysql` dialect mis-reads MariaDB 11.x metadata and schedules false destructive operations. Always use `db:generate` + `db:migrate`.
@@ -50,6 +51,19 @@ Five of these variables — `DB_HOST`, `DB_USER`, `DB_PASSWORD`, `DB_NAME`, `DB_
--- ---
### Local Authentication (No-OIDC Mode)
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 |
| ----------------------- | ------------------------ | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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`. |
**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.
---
### Broker Encryption ### Broker Encryption
| Variable | Required | Default | Description | | Variable | Required | Default | Description |
@@ -80,6 +94,19 @@ npx web-push generate-vapid-keys --json
| ----------------- | -------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | ----------------- | -------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `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`. |
---
### Developer / Test-Only Variables
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`. |
--- ---
@@ -87,17 +114,18 @@ npx web-push generate-vapid-keys --json
Variables with non-empty defaults do not cause startup failure if absent, but should be reviewed for production: Variables with non-empty defaults do not cause startup failure if absent, but should be reviewed for production:
| Variable | Default | Source | | Variable | Default | Source |
| ------------------- | ------------------------------------- | ------------------------------------------- | | ----------------------- | ------------------------------------- | ------------------------------------------- |
| `DB_HOST` | `localhost` | `apps/api/src/db/client.ts` | | `DB_HOST` | `localhost` | `apps/api/src/db/client.ts` |
| `DB_PORT` | `3306` | `apps/api/src/db/client.ts` | | `DB_PORT` | `3306` | `apps/api/src/db/client.ts` |
| `DB_USER` | `familysync` | `apps/api/src/db/client.ts` | | `DB_USER` | `familysync` | `apps/api/src/db/client.ts` |
| `DB_NAME` | `familysync` | `apps/api/src/db/client.ts` | | `DB_NAME` | `familysync` | `apps/api/src/db/client.ts` |
| `OIDC_CLIENT_ID` | `familysync` | `docker-compose.yml` | | `OIDC_CLIENT_ID` | `familysync` | `docker-compose.yml` |
| `OIDC_SCOPES` | `openid profile email offline_access` | `docker-compose.yml` | | `OIDC_SCOPES` | `openid profile email offline_access` | `docker-compose.yml` |
| `OIDC_AUTH_EXPIRES` | `86400` | `apps/api/src/auth/persistSessionCookie.ts` | | `OIDC_AUTH_EXPIRES` | `86400` | `apps/api/src/auth/persistSessionCookie.ts` |
| `OIDC_COOKIE_NAME` | `oidc-auth` | `apps/api/src/auth/persistSessionCookie.ts` | | `OIDC_COOKIE_NAME` | `oidc-auth` | `apps/api/src/auth/persistSessionCookie.ts` |
| `OIDC_COOKIE_PATH` | `/` | `apps/api/src/auth/persistSessionCookie.ts` | | `OIDC_COOKIE_PATH` | `/` | `apps/api/src/auth/persistSessionCookie.ts` |
| `LOCAL_SESSION_EXPIRES` | `86400` | `apps/api/src/auth/localSession.ts` |
--- ---
@@ -121,6 +149,9 @@ OIDC_CLIENT_SECRET=<plaintext secret>
OIDC_REDIRECT_URI=https://familysync.DOMAIN/callback OIDC_REDIRECT_URI=https://familysync.DOMAIN/callback
OIDC_AUTH_EXTERNAL_URL=https://familysync.DOMAIN OIDC_AUTH_EXTERNAL_URL=https://familysync.DOMAIN
# Local auth (Phase 19)
LOCAL_SESSION_SECRET=<openssl rand -base64 32>
# Broker encryption # Broker encryption
APP_PASSWORD_ENCRYPTION_KEY=<64-hex-chars> APP_PASSWORD_ENCRYPTION_KEY=<64-hex-chars>
@@ -151,6 +182,8 @@ The `DB_HOST` override is necessary because `.env` sets `DB_HOST=mariadb` (the D
**`DEV_AUTH_BYPASS=true` is a local-only option.** The API hard-checks `NODE_ENV === 'production'` before reading `DEV_AUTH_BYPASS` — the bypass has zero effect in a production container even if the variable is present. **`DEV_AUTH_BYPASS=true` is a local-only option.** The API hard-checks `NODE_ENV === 'production'` before reading `DEV_AUTH_BYPASS` — the bypass has zero effect in a production container even if the variable is present.
The dev Docker Compose (`docker-compose.dev.yml`) sets `LOCAL_SESSION_SECRET` to a fixed dev placeholder value (`dev-secret-change-me-0000000000000000`). This value is intentionally weak and public — it is never used in production.
### Test ### Test
Integration tests targeting the real database require the dev MariaDB running with the host port exposed and the following overrides: Integration tests targeting the real database require the dev MariaDB running with the host port exposed and the following overrides:
@@ -159,6 +192,8 @@ Integration tests targeting the real database require the dev MariaDB running wi
DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=familysync DB_NAME=familysync DB_PASSWORD=<value> DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=familysync DB_NAME=familysync DB_PASSWORD=<value>
``` ```
The Vitest global setup (`apps/api/test/global-setup.ts`) also reads `DB_ROOT_USER` (default `root`) and `DB_ROOT_PASSWORD` (default `root`) to create and grant the `familysync_test` database on first run. These are local dev credentials only; CI uses hardcoded throwaway values (`familysync` / `testpass`) in ephemeral service containers.
See `docs/deployment.md` for the full `drizzle-kit migrate` command used to prepare the test database. See `docs/deployment.md` for the full `drizzle-kit migrate` command used to prepare the test database.
--- ---
+26 -20
View File
@@ -100,17 +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 generate-secrets` | Generate VAPID and session secret values via `scripts/generate-secrets.mjs` |
### `apps/api` scripts ### `apps/api` scripts
@@ -148,14 +150,16 @@ CI gates every PR to `main` on these checks. Run them locally before pushing to
pnpm lint # ESLint --max-warnings 0 across apps/api (src/ + tests/) and apps/pwa (src/ + e2e/) pnpm lint # ESLint --max-warnings 0 across apps/api (src/ + tests/) and apps/pwa (src/ + e2e/)
pnpm format:check # Prettier formatting check (use `pnpm format` to auto-fix) pnpm format:check # Prettier formatting check (use `pnpm format` to auto-fix)
pnpm typecheck # tsc --noEmit in both apps (includes apps/pwa tsconfig.e2e.json) pnpm typecheck # tsc --noEmit in both apps (includes apps/pwa tsconfig.e2e.json)
pnpm md:lint # Markdown lint (also runs in CI fast-checks)
``` ```
### ESLint ### ESLint
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)
- **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
@@ -189,15 +193,17 @@ Run `pnpm typecheck` before opening a PR to catch errors that vitest and Vite bu
## CI Pipeline Overview ## CI Pipeline Overview
Every PR to `main` runs three parallel jobs (`.gitea/workflows/ci.yml`): 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 | Checks | | Job | Runs on | Checks |
| ------------- | ---------------------------------------------------------------------------------------------------- | | ------------- | ------------------- | ----------------------------------------------------------------------------------------------------------- |
| `fast-checks` | `pnpm lint``pnpm format:check``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` | 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` | DB migrations + 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 |
| `gate` | Always | Final aggregator — requires `fast-checks` and `security` to succeed; `api` and `harness` may be skipped |
All three jobs must pass 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.
## Drizzle Migration Workflow ## Drizzle Migration Workflow
+16 -2
View File
@@ -48,15 +48,23 @@ cp .env.example .env
Open `.env` and fill in the required values. See [docs/CONFIGURATION.md](CONFIGURATION.md) for the full variable reference. At minimum for local development you need: Open `.env` and fill in the required values. See [docs/CONFIGURATION.md](CONFIGURATION.md) for the full variable reference. At minimum for local development you need:
- `DB_PASSWORD` and `DB_ROOT_PASSWORD` — pick any local passwords - `DB_PASSWORD` and `DB_ROOT_PASSWORD` — pick any local passwords
- `APP_PASSWORD_ENCRYPTION_KEY` — 64 hex characters; generate with: - `APP_PASSWORD_ENCRYPTION_KEY`, `SESSION_SECRET`, `LOCAL_SESSION_SECRET`, and VAPID keys — generate all at once with:
```bash
pnpm generate-secrets
```
Paste the output into your `.env`. Alternatively, generate `APP_PASSWORD_ENCRYPTION_KEY` alone with:
```bash ```bash
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))" node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
``` ```
- `DEV_AUTH_BYPASS=true` — bypasses the live Authelia OIDC flow for local dev - `DEV_AUTH_BYPASS=true` — bypasses the live Authelia OIDC flow for local dev. When this is set, `LOCAL_SESSION_SECRET` is not required at startup (bypass mode skips the local-auth JWT path entirely).
- `DB_HOST=localhost` — the dev Docker Compose exposes MariaDB on the host at `localhost:3306` - `DB_HOST=localhost` — the dev Docker Compose exposes MariaDB on the host at `localhost:3306`
> **Note:** If you run without `DEV_AUTH_BYPASS=true` (local-auth mode), `LOCAL_SESSION_SECRET` must be set to a value of at least 32 characters. The API will refuse to start otherwise. `pnpm generate-secrets` always produces a valid value.
--- ---
## First Run ## First Run
@@ -124,6 +132,11 @@ Or set `DB_HOST=localhost` directly in your `.env` for host-side dev.
**API starts but all requests return 401 / redirect to Authelia** **API starts but all requests return 401 / redirect to Authelia**
`DEV_AUTH_BYPASS` is not set or is not being exported to the process. Make sure you source `.env` with `set -a; source .env; set +a` or prefix the command with `DEV_AUTH_BYPASS=true`. The bypass only works when `NODE_ENV` is not `production`. `DEV_AUTH_BYPASS` is not set or is not being exported to the process. Make sure you source `.env` with `set -a; source .env; set +a` or prefix the command with `DEV_AUTH_BYPASS=true`. The bypass only works when `NODE_ENV` is not `production`.
**`[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`.
**PWA shows a blank screen after first load** **PWA shows a blank screen after first load**
Run the API build step first (`pnpm --filter @familysync/api build`). The dev script runs `dist/index.js`; if `dist/` is missing or stale, the API process exits immediately. Run the API build step first (`pnpm --filter @familysync/api build`). The dev script runs `dist/index.js`; if `dist/` is missing or stale, the API process exits immediately.
@@ -136,4 +149,5 @@ Another local MySQL/MariaDB service is running. Stop it before starting Docker C
- [docs/ARCHITECTURE.md](ARCHITECTURE.md) — System design, component diagram, data flow - [docs/ARCHITECTURE.md](ARCHITECTURE.md) — System design, component diagram, data flow
- [docs/CONFIGURATION.md](CONFIGURATION.md) — All environment variables, defaults, and per-environment guidance - [docs/CONFIGURATION.md](CONFIGURATION.md) — All environment variables, defaults, and per-environment guidance
- [docs/DEVELOPMENT.md](DEVELOPMENT.md) — Build commands, code style, and contribution workflow
- [docs/deployment.md](deployment.md) — Production deployment on Unraid via Docker Compose - [docs/deployment.md](deployment.md) — Production deployment on Unraid via Docker Compose
+64 -33
View File
@@ -6,12 +6,14 @@
Both apps use **Vitest** (`^4.1.8`). Both apps use **Vitest** (`^4.1.8`).
| App | Environment | Setup file | | App | Environment | Global setup | Per-file setup |
| ---------- | ----------- | ---------------------------- | | ---------- | ----------- | ----------------------------------- | ---------------------------- |
| `apps/api` | `node` | `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 setup** (`test/setup.ts`) registers a global `afterEach` that truncates `list_items`, `list_shares`, `push_subscriptions`, and `lists` in FK-safe order after every test. This keeps DB-backed integration tests isolated without requiring a full DB reset between runs. Parallel file execution is disabled (`fileParallelism: false`) to prevent FK violations when multiple test files share the same MariaDB. **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 per-file setup** (`test/setup.ts`) registers a global `afterEach` that truncates `list_items`, `list_shares`, `push_subscriptions`, `lists`, and `local_credentials` in FK-safe order after every test. This keeps DB-backed integration tests isolated without requiring a full DB reset between runs. The `users` table is intentionally left intact across tests within a single run — many tests seed user id=1 once and reuse it. Parallel file execution is disabled (`fileParallelism: false`) to prevent FK violations when multiple test files share the same MariaDB.
**apps/pwa setup** (`src/test-setup.ts`) imports `@testing-library/jest-dom` for extended matchers and polyfills `window.matchMedia` for jsdom (required because Zustand's `calendarStore` calls `window.matchMedia` at module initialisation time). The timezone is pinned to `UTC` via `env: { TZ: 'UTC' }` so date-extraction assertions are deterministic across developer machines and CI. **apps/pwa setup** (`src/test-setup.ts`) imports `@testing-library/jest-dom` for extended matchers and polyfills `window.matchMedia` for jsdom (required because Zustand's `calendarStore` calls `window.matchMedia` at module initialisation time). The timezone is pinned to `UTC` via `env: { TZ: 'UTC' }` so date-extraction assertions are deterministic across developer machines and CI.
@@ -49,16 +51,17 @@ pnpm --filter @familysync/api exec vitest run tests/routes/lists.test.ts
### End-to-end tests (Playwright) ### End-to-end tests (Playwright)
The PWA has a Playwright harness configured in `apps/pwa/playwright.config.ts` with two 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 |
Both 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.
**Run all e2e tests (both profiles):** **Run all e2e tests (all profiles):**
```bash ```bash
pnpm test:e2e pnpm test:e2e
@@ -71,6 +74,7 @@ pnpm --filter @familysync/pwa test:e2e
```bash ```bash
pnpm --filter @familysync/pwa exec playwright test --project=pixel pnpm --filter @familysync/pwa exec playwright test --project=pixel
pnpm --filter @familysync/pwa exec playwright test --project=iphone pnpm --filter @familysync/pwa exec playwright test --project=iphone
pnpm --filter @familysync/pwa exec playwright test --project=desktop
``` ```
**Interactive UI mode:** **Interactive UI mode:**
@@ -79,6 +83,12 @@ pnpm --filter @familysync/pwa exec playwright test --project=iphone
pnpm --filter @familysync/pwa test:e2e:ui pnpm --filter @familysync/pwa test:e2e:ui
``` ```
**Headed mode (for local debugging):**
```bash
pnpm --filter @familysync/pwa test:e2e:headed
```
The `baseURL` is driven by `PLAYWRIGHT_BASE_URL` (default: `http://localhost:5173`). In local mode the config reuses a running Vite dev server; in CI it starts Vite itself. The API, MariaDB, and Redis must already be running via Docker Compose before launching e2e tests locally — see `docs/DEVELOPMENT.md`. The `baseURL` is driven by `PLAYWRIGHT_BASE_URL` (default: `http://localhost:5173`). In local mode the config reuses a running Vite dev server; in CI it starts Vite itself. The API, MariaDB, and Redis must already be running via Docker Compose before launching e2e tests locally — see `docs/DEVELOPMENT.md`.
### Type checking (separate from tests — required) ### Type checking (separate from tests — required)
@@ -111,10 +121,11 @@ pnpm test:e2e
| ---------------- | ------------------------------------ | -------------------------------------------------------- | | ---------------- | ------------------------------------ | -------------------------------------------------------- |
| Lint | `pnpm lint` | ESLint `--max-warnings 0` across both apps (type-aware) | | Lint | `pnpm lint` | ESLint `--max-warnings 0` across both apps (type-aware) |
| Format check | `pnpm format:check` | Prettier — fails on any unformatted file | | Format check | `pnpm format:check` | Prettier — fails on any unformatted file |
| Markdown lint | `pnpm md:lint` | markdownlint-cli2 across all `.md` files |
| Typecheck | `pnpm typecheck` | `tsc --noEmit` across both apps (including e2e tsconfig) | | Typecheck | `pnpm typecheck` | `tsc --noEmit` across both apps (including e2e tsconfig) |
| Unit / API tests | `pnpm test` | API integration tests via Vitest | | Unit / API tests | `pnpm test` | API integration tests via Vitest |
| PWA unit tests | `pnpm --filter @familysync/pwa test` | Component and logic tests in jsdom | | PWA unit tests | `pnpm --filter @familysync/pwa test` | Component and logic tests in jsdom |
| E2E | `pnpm test:e2e` | Playwright iphone + pixel profiles | | E2E | `pnpm test:e2e` | Playwright iphone + pixel + desktop profiles |
A deliberate ESLint violation makes `pnpm lint` exit non-zero; a formatting deviation makes `pnpm format:check` exit non-zero. Both block the PR in CI. A deliberate ESLint violation makes `pnpm lint` exit non-zero; a formatting deviation makes `pnpm format:check` exit non-zero. Both block the PR in CI.
@@ -126,7 +137,7 @@ pnpm format # prettier --write .
## Integration tests requiring a real database ## Integration tests requiring a real database
Several API tests in `apps/api/tests/lib/` and `apps/api/tests/routes/` connect to the real dev MariaDB rather than mocking the DB layer. These tests require the dev Docker stack to be running with port 3306 exposed. Several API tests in `apps/api/tests/lib/` and `apps/api/tests/routes/` connect to a real MariaDB instance. Locally, the Vitest global setup (`test/global-setup.ts`) auto-provisions and migrates the `familysync_test` database — there is no need to manually set `DB_NAME`. The dev `familysync` database is never touched by the test suite.
**Start the dev stack:** **Start the dev stack:**
@@ -142,6 +153,8 @@ export DB_HOST=127.0.0.1 DB_PORT=3306
pnpm --filter @familysync/api test pnpm --filter @familysync/api test
``` ```
The global setup requires root access to create and grant the test database. By default it reads `DB_ROOT_PASSWORD` from the environment (defaults to `root` to match the dev Docker Compose). The app user (`DB_USER`) is validated against `/^[A-Za-z0-9_]+$/` before the GRANT statement is interpolated.
DB-backed tests that require this setup include: DB-backed tests that require this setup include:
- `apps/api/tests/lib/listAccess.test.ts``getAccessibleListIds` access-scope queries - `apps/api/tests/lib/listAccess.test.ts``getAccessibleListIds` access-scope queries
@@ -162,17 +175,17 @@ Pure-logic tests (e.g. `apps/api/tests/broker/expand.test.ts`, `apps/api/tests/l
Test categories for `apps/api`: Test categories for `apps/api`:
- `apps/api/tests/auth/` — authentication middleware and session handling - `apps/api/tests/auth/` — authentication middleware, session handling, local auth, admin guards, and bypass behaviour
- `apps/api/tests/broker/` — CalDAV sync, outbox worker, event expansion, push dispatch - `apps/api/tests/broker/` — CalDAV sync, outbox worker, event expansion, push dispatch, crypto utilities, and VEVENT parsing
- `apps/api/tests/lib/` — pure library functions and service logic - `apps/api/tests/lib/` — pure library functions and service logic (list access, rank, push coalescer/dispatcher, SSE emitter, timezone, boot guards)
- `apps/api/tests/routes/` — HTTP route integration tests - `apps/api/tests/routes/` — HTTP route integration tests (events, lists, push, admin, login, me, local auth, setup)
- `apps/api/tests/health.test.ts` — health check endpoint - `apps/api/tests/health.test.ts` — health check endpoint
- `apps/api/tests/fixtures/` — shared `.ics` fixture files and DB fixture helpers - `apps/api/tests/fixtures/` — shared `.ics` fixture files and DB fixture helpers
### Test helpers ### Test helpers
- `apps/api/tests/helpers/db.ts``createMockDb()` returns a Vitest mock of the Drizzle `db` singleton; also exports sample VEVENT strings (`SAMPLE_VEVENT_TIMED`, `SAMPLE_VEVENT_ALLDAY`, `SAMPLE_VEVENT_RECURRING_TIMED`, `SAMPLE_VEVENT_RECURRING_ALLDAY`) for broker tests. - `apps/api/tests/helpers/db.ts``createMockDb()` returns a Vitest mock of the Drizzle `db` singleton; also exports sample VEVENT strings (`SAMPLE_VEVENT_TIMED`, `SAMPLE_VEVENT_ALLDAY`, `SAMPLE_VEVENT_RECURRING_TIMED`, `SAMPLE_VEVENT_RECURRING_ALLDAY`) for broker tests.
- `apps/api/tests/fixtures/*.ics` — Raw iCalendar fixture files for broker parsing tests (`allday-birthday.ics`, `exdate-series.ics`, `single-duration.ics`, `weekly-count3.ics`, `weekly-dst.ics`). - `apps/api/tests/fixtures/*.ics` — Raw iCalendar fixture files for broker parsing tests (`absolute-alarm.ics`, `allday-birthday.ics`, `exdate-series.ics`, `multi-alarm.ics`, `single-duration.ics`, `weekly-count3.ics`, `weekly-dst.ics`).
- `apps/api/tests/fixtures/vapid.ts` — VAPID key fixture for push tests. - `apps/api/tests/fixtures/vapid.ts` — VAPID key fixture for push tests.
- `apps/pwa/src/test-setup.ts` — Provides `matchMedia` polyfill and jest-dom matchers for all PWA tests automatically via `setupFiles`. - `apps/pwa/src/test-setup.ts` — Provides `matchMedia` polyfill and jest-dom matchers for all PWA tests automatically via `setupFiles`.
@@ -184,22 +197,25 @@ No coverage thresholds are configured in either `vitest.config.ts`. There is no
## CI integration ## CI integration
CI runs on a self-hosted Gitea Actions runner and triggers on every pull request targeting `main` (`.gitea/workflows/ci.yml`). Three jobs run in parallel: CI runs on a self-hosted Gitea Actions runner and triggers on every pull request targeting `main` (`.gitea/workflows/ci.yml`). A `changes` job using `dorny/paths-filter@v4` determines whether the PR touches code (as opposed to docs or planning files only). The `api` and `harness` jobs are skipped for doc-only PRs.
Five jobs run in total — `fast-checks` and `security` always run; `api`, `harness`, and `changes` run conditionally.
### `fast-checks` ### `fast-checks`
Runs lint, format check, typecheck, and PWA unit tests — no external services required. Runs lint, format check, markdown lint, typecheck, and PWA unit tests — no external services required. Always runs regardless of the `changes` filter.
| Step | Command | | Step | Command |
| -------------- | ------------------------------------ | | -------------- | ------------------------------------ |
| Lint | `pnpm lint` | | Lint | `pnpm lint` |
| Format check | `pnpm format:check` | | Format check | `pnpm format:check` |
| Markdown lint | `pnpm md:lint` |
| Typecheck | `pnpm typecheck` | | Typecheck | `pnpm typecheck` |
| PWA unit tests | `pnpm --filter @familysync/pwa test` | | PWA unit tests | `pnpm --filter @familysync/pwa test` |
### `api` ### `api`
Runs the full API test suite against a `mariadb:11` service container. Runs the full API test suite against a `mariadb:11` service container. Skipped for doc-only PRs.
| Step | Detail | | Step | Detail |
| ----------------- | ---------------------------------------------------------- | | ----------------- | ---------------------------------------------------------- |
@@ -214,17 +230,32 @@ The throwaway credentials (`DB_USER=familysync`, `DB_PASSWORD=testpass`) are sco
### `harness` ### `harness`
Runs the Playwright mobile e2e harness (iphone + pixel) against a runner-hosted dev stack. 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` |
| API build | `pnpm --filter @familysync/api build` (dist/ is gitignored) | | Local credentials seed | Inserts `local_credentials` row for dev user (username: `devuser`, password: `devpass`) via inline scrypt hash — Phase 19 requirement |
| Playwright install | `npx playwright install --with-deps webkit chromium` (no cache) | | API build | `pnpm --filter @familysync/api build` (dist/ is gitignored) |
| 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` | | Playwright install | `npx playwright install --with-deps webkit chromium` (no cache) |
| Base URL | `http://127.0.0.1:5173` (not `localhost` — runner resolves `localhost` to `::1` but Vite binds IPv4-only) | | 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` |
| Artifacts on fail | Traces, screenshots, videos, and HTML report uploaded via `ChristopherHX/gitea-upload-artifact@v4` (standard `upload-artifact` aborts on Gitea) | | 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. 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.
### `security`
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 |
### `gate`
A required final job that checks all other jobs passed or were legitimately skipped. `fast-checks` and `security` must succeed; `api` and `harness` may be skipped (doc-only PRs) but not failed.
+17 -11
View File
@@ -30,21 +30,23 @@ FamilySync uses a self-hosted Gitea Actions runner. Two workflows govern the rel
### PR gate — `.gitea/workflows/ci.yml` ### PR gate — `.gitea/workflows/ci.yml`
Triggered on every pull request targeting `main`. Three jobs run in parallel; all three must pass before the PR can be merged: Triggered on every pull request targeting `main`. The workflow runs a `changes` filter job first, then launches the following jobs in parallel:
| Job | What it checks | | Job | Runs when | What it checks |
| ------------- | --------------------------------------------------------------------------------- | | ------------- | -------------------- | --------------------------------------------------------------------------------------------------------- |
| `fast-checks` | Lint (`pnpm lint`), format check (`pnpm format:check`), 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` | 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` | Full Playwright E2E suite (iPhone + Pixel 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 |
| `gate` | Always | Aggregates results — fails if any non-skipped required job did not succeed |
A PR with lint or format violations is blocked from merging by the `fast-checks` job. 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.
Branch protection on `main` blocks direct push and force push. Only PRs with all three required checks (`CI / fast-checks`, `CI / api`, `CI / harness`) passing can merge. Branch protection on `main` blocks direct push and force push. Only PRs where both `CI / fast-checks` and `CI / gate` pass can merge.
### Publish — `.gitea/workflows/publish.yml` ### Publish — `.gitea/workflows/publish.yml`
Triggered on push to `main` (i.e., when any PR merges). Builds the `apps/api` Docker image and pushes it to the Gitea container registry. Triggered on push to `main` (i.e., when any PR merges). Skipped when every changed file is under `.gitea/**` or `.planning/**`. Builds the `apps/api` Docker image and pushes it to the Gitea container registry.
**Registry:** `git.bergerhouse.net/luckberg/familysync-api` **Registry:** `git.bergerhouse.net/luckberg/familysync-api`
@@ -59,6 +61,10 @@ 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:
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).
**Authentication — `REGISTRY_PAT` secret:** **Authentication — `REGISTRY_PAT` secret:**
The workflow authenticates with the Gitea container registry using a PAT stored in the `REGISTRY_PAT` Actions secret. The secret must have `write:package` scope. It is named `REGISTRY_PAT` — not `GITEA_REGISTRY_PAT` or any `GITEA_`-prefixed name, because Gitea reserves the `GITEA_` prefix and will reject those names at secret-creation time. `GITEA_TOKEN` and `GITHUB_TOKEN` cannot push packages. The workflow authenticates with the Gitea container registry using a PAT stored in the `REGISTRY_PAT` Actions secret. The secret must have `write:package` scope. It is named `REGISTRY_PAT` — not `GITEA_REGISTRY_PAT` or any `GITEA_`-prefixed name, because Gitea reserves the `GITEA_` prefix and will reject those names at secret-creation time. `GITEA_TOKEN` and `GITHUB_TOKEN` cannot push packages.
@@ -178,7 +184,7 @@ See [docs/CONFIGURATION.md](CONFIGURATION.md) for the full variable reference in
The production image does **not** auto-migrate on startup. Migrations must be applied manually before the first container start and again after any schema change. The production image does **not** auto-migrate on startup. Migrations must be applied manually before the first container start and again after any schema change.
Migrations are run with `drizzle-kit`, which is a **devDependency**. The production image is built with `pnpm install --prod` (see `apps/api/Dockerfile`), so `drizzle-kit` is **not** present inside the running `api` container — you cannot migrate by exec-ing into it. Instead, run migrations from a host that has the full (dev) dependencies and can reach MariaDB. Migrations are run with `drizzle-kit`, which is a **devDependency**. The production image is built with `pnpm install --frozen-lockfile --prod` (see `apps/api/Dockerfile`), so `drizzle-kit` is **not** present inside the running `api` container — you cannot migrate by exec-ing into it. Instead, run migrations from a host that has the full (dev) dependencies and can reach MariaDB.
The production `docker-compose.yml` does not expose the MariaDB port externally, so bring the database up with the dev compose override (which binds port 3306), apply the migrations from the host, then start the rest of the stack: The production `docker-compose.yml` does not expose the MariaDB port externally, so bring the database up with the dev compose override (which binds port 3306), apply the migrations from the host, then start the rest of the stack:
@@ -265,7 +271,7 @@ The Dockerfile uses a multi-stage build:
1. `builder` — compiles the TypeScript API (`pnpm --filter @familysync/api build`). 1. `builder` — compiles the TypeScript API (`pnpm --filter @familysync/api build`).
2. `pwa-builder` — builds the React PWA with Vite (`pnpm --filter @familysync/pwa build`). 2. `pwa-builder` — builds the React PWA with Vite (`pnpm --filter @familysync/pwa build`).
3. `production` — installs production-only dependencies, copies the compiled API and the built PWA into `./public`. The API serves the PWA at `/` via `serveStatic`. 3. `production` — installs production-only dependencies (`pnpm install --frozen-lockfile --prod`), copies the compiled API and the built PWA into `./public`. The API serves the PWA at `/` via `serveStatic`.
Both `builder` and `pwa-builder` stages run in parallel under BuildKit. Both `builder` and `pwa-builder` stages run in parallel under BuildKit.