Commit Graph
171 Commits
Author SHA1 Message Date
Lucas Berger 9b569efeab feat(19-03): wire /callback link branch, OIDC-guard skip, de-Authelia comments
- /callback: reads signed state, extracts linkUserId, calls linkOidcToUser after OIDC session set; OidcLinkConflictError redirects to /?error=oidc-link-conflict
- OIDC guard: oidcAuthMiddleware() factory called once at construction, handler invoked per-request inside skip-when-user-set wrapper (D-03)
- middleware.ts: de-Authelia-ize comments — generic OIDC identity provider language (D-06, AUTH-LOCAL-18)
- localAuthMiddleware.ts: cast to typeof DEV_USER for ContextVariableMap type compatibility
- All 446 tests pass; typecheck clean
2026-06-17 16:59:06 -04:00
Lucas Berger c437f408bb feat(19-03): implement POST /api/auth/local/login (rate-limit + lockout) + logout
- Rate-limit: per-IP in-memory Map; 5 failures → 429, 10 → 423 (lockedOut)
- Counter increments even on 429 so brute-force accumulates toward lockout
- Timing-safe: DUMMY_HASH ensures verifyPassword runs for unknown usernames (T-19-12)
- noEchoHook: Zod errors never echo submitted values (T-19-14)
- Same 401 body for wrong-password and unknown-username (no enumeration)
- POST+GET /local/logout clear the local-session cookie
2026-06-17 16:49:59 -04:00
Lucas Berger be7a0aec90 feat(19-03): implement localAuthMiddleware, GET /api/auth/mode, and pre-auth route mounts
- localAuthMiddleware: cookie→c.set('user') with Pitfall-1 guard (no-set on no-cookie path)
- authMode: GET /api/auth/mode pre-auth endpoint (localEnabled:true, oidcEnabled from env+config)
- localAuth: POST /api/auth/local/login (rate-limit + timing-safe), logout routes
- index.ts: mount authModeRouter + localAuthRouter pre-auth; localAuthMiddleware after devAuthBypass; OIDC guard wrapped skip-when-user-set
2026-06-17 16:48:17 -04:00
Lucas Berger efb80c8c1a feat(19-02): linkOidcToUser helper + POST /api/me/link-oidc initiation
apps/api/src/auth/linkOidc.ts (new):
- OidcLinkConflictError: thrown when iss+sub already belongs to a different user
- linkOidcToUser(userId, iss, sub): preflight SELECT for conflict, then db.transaction
  (UPDATE users SET oidc_iss/sub/claimed + DELETE local_credentials); atomic, no email (D-10)
- 88 lines; no email in source (D-10/T-19-08 assertion passes)

apps/api/src/routes/me.ts:
- POST /api/me/link-oidc: resolveUserId (401 if null), sign state JWT
  ({ linkUserId, nonce, iat, exp } HS256 with LOCAL_SESSION_SECRET, 10-min window)
- Returns { signedState, authorizationUrl } — 19-03 /callback reads linkUserId from state
- authorizationUrl constructed from OIDC env vars when configured, null otherwise
- T-19-09: per-request nonce in state prevents CSRF/replay
2026-06-17 16:38:14 -04:00
Lucas Berger c88f7d41e5 feat(19-02): self-change password and hasLocalCredential on GET /api/me
- POST /api/me/password: verifyPassword(current) gate before hashPassword(new) update
- 401 on wrong current password, 404 if no local_credentials row, 200 on success
- meNoEchoHook on /password route (T-19-06, never echo submitted password)
- resolveAdminAndSetupStatus extended with hasLocalCredential (AUTH-LOCAL-17)
- GET /api/me response includes hasLocalCredential alongside isAdmin/needsProviderSetup
2026-06-17 16:34:10 -04:00
Lucas Berger 6232aa0d68 feat(19-02): admin create-member, reset-password, hasLocalCredential on GET /members
- POST /api/admin/members: atomic tx (users + local_credentials), 409 on dup username
- POST /api/admin/members/:id/password: admin reset (no current-pwd required), 404 if no local cred
- GET /api/admin/members: LEFT JOIN local_credentials, hasLocalCredential in each member row
- noEchoHook on both POST routes (T-19-06); requireAdmin via router.use('*') remains first statement
- Dup-entry detection via error message string match (Drizzle wraps mysql2 ER_DUP_ENTRY)
2026-06-17 16:31:37 -04:00
Lucas Berger 96f0991605 feat(19-01): add local_credentials schema, 0003 migration, generate-secrets LOCAL_SESSION_SECRET, .dockerignore D-15
- schema.ts: export localCredentials = mysqlTable('local_credentials', {...})
  - user_id FK->users(cascade), username, password_hash, createdAt, updatedAt
  - UNIQUE(user_id), UNIQUE(username), INDEX(user_id)
- 0003_warm_deathstrike.sql: purely additive CREATE TABLE (no ALTER/DROP/TRUNCATE on existing tables)
  - Applied to dev DB: pnpm --filter @familysync/api db:migrate exits 0
- test/setup.ts: add localCredentials to afterEach delete cleanup (FK-safe ordering)
- generate-secrets.mjs: emit LOCAL_SESSION_SECRET (base64 32-byte, >=32 chars, D-05)
- .dockerignore: add apps/api/scripts/ exclusion (D-15/IMG-02) — entire break-glass dir excluded
2026-06-17 16:17:04 -04:00
Lucas Berger 7d61148415 feat(19-01): implement localSession JWT cookie helpers and assertLocalSessionSecretSet boot guard
- localSession.ts: issueLocalSessionCookie/verifyLocalSessionCookie/clearLocalSessionCookie
  - Jwt namespace import from hono/utils/jwt (Pitfall 8 — not named sign/verify)
  - Cookie name: 'local-session' (distinct from 'oidc-auth', Pitfall 4)
  - httpOnly, sameSite=Lax, secure in production; try/catch on Jwt.verify (Pitfall 9)
  - verifyLocalSessionCookie returns null (never throws) on any error
- bootGuards.ts: assertLocalSessionSecretSet — exit(1) if secret missing/<32 chars
  - Exempt when DEV_AUTH_BYPASS=true (bypass doesn't issue local-session cookies)
- index.ts: wire assertLocalSessionSecretSet() after assertNotDevBypassInProduction()
- All 5 unit tests pass; typecheck exits 0
2026-06-17 16:14:48 -04:00
Lucas Berger 85b01b5c26 feat(19-01): implement hashPassword/verifyPassword with scrypt + timingSafeEqual
- node:crypto scrypt (N=16384, r=8, p=1, 32-byte output) — zero new dependencies (D-08)
- 16-byte random salt per hash; PHC-encoded format: scrypt$N$r$p$salt_b64url$hash_b64url
- timingSafeEqual for constant-time comparison (prevents timing oracle attacks, T-19-01)
- verifyPassword returns false on any error (never throws); passwords never logged
- All 5 unit tests pass (round-trip, wrong-pw, unique-salt, malformed-hash, PHC-shape)
2026-06-17 16:12:59 -04:00
Lucas BergerandClaude Opus 4.8 a193bc8236 fix(12): satisfy CI fast-checks — lint unused vars, typed contract-test body, prettier
CI / changes (pull_request) Successful in 3s
CI / fast-checks (pull_request) Successful in 2m16s
CI / api (pull_request) Failing after 1m37s
CI / harness (pull_request) Failing after 1h3m45s
CI / security (pull_request) Failing after 11s
CI / gate (pull_request) Failing after 1s
- Remove unused 'res'/'container' assignments (no-unused-vars)
- setupClient.contract.test.ts: typed parseSentBody helper + non-async json mock
  (no-unsafe-*/require-await)
- Prettier format 7 setup files

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-16 16:53:14 -04:00
Lucas Berger fbd3b77bde feat(12-06): expose non-secret DB name via GET /api/setup/status (gap 3)
- status returns { setupComplete, dbName } from process.env.DB_NAME (null fallback)
- only the DB name; never DB_HOST/DB_USER/DB_PASSWORD
- SetupStatusResponse carries dbName?: string | null for the PWA read-only field
2026-06-15 21:13:47 -04:00
Lucas Berger e46e80a15c feat(12-06): validate/vapid asserts submitted key matches env public key (gap 2)
- read app_config.vapid_public_key and compare to process.env.VAPID_PUBLIC_KEY
- mismatched/absent submitted key → 400 before the structural check
- VAPID_PRIVATE_KEY still env-only, never compared or returned (T-12-06)
2026-06-15 21:12:35 -04:00
Lucas BergerandClaude Sonnet 4.6 687f9dc9fa fix(12): WR-01 narrow TOCTOU guard and set claimed=true for OIDC inserts
- apps/api/src/auth/user.ts: upsertUser step-5 insert now sets claimed=true
  for all OIDC-created users. An identity-bound OIDC user is never a pending
  wizard bootstrap user; explicit claimed=true prevents ambiguity with the
  (oidcIss IS NULL AND claimed=false) sentinel used by the TOCTOU guard and
  isSetupLocked. First-login-claims path is unaffected (it updates a
  pre-existing oidcIss=null row; this change only touches the fresh insert).

- apps/api/src/routes/setup.ts: TOCTOU guard in POST /credential now queries
  WHERE oidc_iss IS NULL AND claimed = false FOR UPDATE, matching the exact
  definition of a pending wizard bootstrap user. This provides defense-in-depth
  against any future path that could produce claimed=false OIDC rows.

- apps/api/tests/auth/user.test.ts: new WR-01 test asserts that the fresh
  OIDC insert sets claimed=true in the values passed to db.insert().

- apps/api/tests/routes/setup.test.ts: new WR-01 integration test seeds an
  OIDC user with claimed=false (oidcIss NOT NULL) and verifies POST /credential
  still succeeds (guard ignores the OIDC row, only counts local wizard rows).

All 402 API tests, 253 PWA tests, and typecheck pass.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:46:41 -04:00
Lucas BergerandClaude Sonnet 4.6 22d1581484 fix(12): IN-01 require https:// on appExternalUrl in configSchema
appExternalUrl is injected as OIDC_AUTH_EXTERNAL_URL (the redirect URI
base); Authelia rejects non-https redirect URIs in production. Added
.refine() guard matching the existing oidcIssuer pattern. Added test
that verifies http:// appExternalUrl is rejected with 400.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:37:03 -04:00
Lucas BergerandClaude Sonnet 4.6 61a869ca7d fix(12): CR-01 guard effective-config branch during wizard in-progress
isSetupLocked() now checks for an unclaimed local wizard user
(oidcIss IS NULL, claimed=false) before firing the effective-config
branch. During the credential→complete window, this sentinel prevents
a production container with VAPID env set from blocking POST /complete
with 423. The explicit setup_complete flag (Check 1) still locks
unconditionally once written. Adds regression test that sets VAPID env
explicitly (no beforeEach clearing) to reproduce the production scenario.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:36:52 -04:00
Lucas BergerandClaude Sonnet 4.6 ed4e64a06a fix(12): WR-02 fix TypeScript type annotation for execute() count result
Drizzle mysql2 execute() returns [rows, fields] for SELECT queries; the
generic type parameter alone does not correctly type the result. Use
unknown cast pattern consistent with admin.ts to access the count row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:25:05 -04:00
Lucas BergerandClaude Sonnet 4.6 3babbfa20e fix(12): IN-02 guard /setup/complete against skipping the credential step
Without a prerequisite check, an operator could call POST /api/setup/complete
directly, setting setup_complete=true with no admin user or credential row,
leaving no recovery path without manual DB surgery.

Add an inner join check for an unclaimed user with an associated credential;
return 422 if absent. Update /complete tests to seed the prerequisite for
the success path and add an explicit 422 regression test.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:20:55 -04:00
Lucas BergerandClaude Sonnet 4.6 d9dfe72aab fix(12): IN-01 return generic error from /validate/oidc instead of raw network detail
The catch block previously echoed err.message (which may contain internal
network addresses like ECONNREFUSED 192.168.1.50:9091) to the pre-auth
caller. Log the raw message server-side only and return a generic user-
facing string with no internal network detail.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:18:10 -04:00
Lucas BergerandClaude Sonnet 4.6 7a512a9726 fix(12): WR-03 document and log single-write process.env semantics in oidcFallback
The oidcConfigFallbackMiddleware permanently mutates process.env on first
request then never re-reads from DB. Log an explicit info message when each
value is written so operators can see when a restart is required to pick up
config changes, and add inline documentation of the single-write semantics
to prevent silent misconfiguration after a re-run of the wizard.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:17:41 -04:00
Lucas BergerandClaude Sonnet 4.6 a4e0ea4f14 fix(12): WR-02 serialise concurrent /credential calls with FOR UPDATE transaction
Two concurrent POST /api/setup/credential requests could both pass
isSetupLocked(), observe no unclaimed row, and both insert — leaving two
unclaimed admin rows with no recovery path. Wrap the count-check + user
insert in a transaction with SELECT COUNT(*) ... FOR UPDATE to acquire a
row/gap lock, ensuring at most one unclaimed admin row is created.
Returns 409 when a concurrent request already holds an unclaimed row.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:17:12 -04:00
Lucas BergerandClaude Sonnet 4.6 3bc38bf1a6 fix(12): WR-01 delete orphaned user row on re-select 503 path in /credential
After $returningId() insert, if the re-select returns null the handler
returned 503 without deleting the just-inserted user row, leaving an
unclaimed admin row with no credential. Delete before returning 503 to
mirror the cleanup already present in the catch block.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 16:14:39 -04:00
Lucas BergerandClaude Sonnet 4.6 c8894adc3f feat(12-03): upsertUser first-login-claims branch (D-08)
- Add isNull import from drizzle-orm; add appConfig to schema imports
- After identity lookup, read app_config.setup_complete per call (D-10 freshness)
- When setup_complete='true' and unclaimed user exists (isNull(oidcIss) AND claimed=false):
  claim it via db.update() — binds oidcIss/oidcSub, sets claimed=true, preserves is_admin
- shouldBeAdmin gated: flagRow?.value !== 'true' AND admin COUNT === 0 (T-12-11)
- No email keying in claim branch — isNull(oidcIss) AND claimed=false only (D-10/T-12-12)
- 399 tests pass; typecheck clean

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-15 14:14:48 -04:00
Lucas Berger 67a9d29dc1 feat(12-02): OIDC boot env-OR-app_config fallback + pre-auth mount verification
- A2 CONFIRMED: @hono/oidc-auth reads OIDC_ISSUER/OIDC_CLIENT_ID/OIDC_AUTH_EXTERNAL_URL
  at per-request call time (env(c) → process.env), NOT at import time — fresh instance
  boots cleanly without OIDC env vars
- Implement oidcConfigFallbackMiddleware in auth/middleware.ts: reads OIDC_ISSUER,
  OIDC_CLIENT_ID, OIDC_AUTH_EXTERNAL_URL from app_config when process.env is absent,
  injects into process.env before oidcAuthMiddleware() reads it (D-02/D-03/Recommendation a)
- Mount oidcConfigFallbackMiddleware before oidcAuthMiddleware() in index.ts so
  wizard-configured instances work before a container restart
- Verify /api/setup mount order: line 49 < devAuthBypass line 54 (T-12-09/Pitfall 1)
- Fix push.test.ts vi.doMock for middleware.js: add oidcConfigFallbackMiddleware stub
- 394 tests pass | 5 todo (D-08 RED scaffolds); typecheck clean
2026-06-15 14:03:15 -04:00
Lucas Berger 20f91e4548 feat(12-02): implement setup router — all 7 pre-auth routes + guard-first pattern
- Fill setupRouter: GET /status, POST /config, POST /validate/{db,oidc,vapid},
  POST /credential, POST /complete (SETUP-01/02)
- isSetupLocked() is FIRST statement in every handler; returns 423 if locked (SETUP-04/D-10)
- /status uses isSetupLocked() directly: covers both explicit + effective-config branches
- /config: zod-validates {oidcIssuer:https, oidcClientId, vapidPublicKey, appExternalUrl};
  upserts oidc_issuer|oidc_client_id|vapid_public_key|app_external_url into app_config
- /validate/db: db.execute(sql`SELECT 1`); 200 ok, 503 on failure
- /validate/oidc: fetches discovery doc with AbortSignal.timeout(5000); reads oidc_issuer
  from app_config; 200 ok, 400 on unreachable/non-2xx
- /validate/vapid: webpush.setVapidDetails() structural check; reads ONLY from process.env
  (VAPID_PRIVATE_KEY never from app_config, never returned; T-12-06/SC-3)
- /credential: inserts local user (oidcIss=null, claimed=false, isAdmin=true) FIRST
  (Pitfall 5 FK), then calls validateEncryptAndStoreCredential(); noEchoHook + error map
- /complete: upserts setup_complete='true'; 200 first call, 423 second (Pitfall 8/D-10)
- Mount setupRouter pre-auth in index.ts BEFORE devAuthBypass() (T-12-09/Pitfall 1)
- All 394 tests pass (5 todo = D-08 RED scaffolds); typecheck clean
2026-06-15 13:58:51 -04:00
Lucas Berger 4748d578e7 test(12-02): isSetupLocked() real impl + RED-first setup route tests
- Implement real isSetupLocked() in setupGuard.ts: reads app_config.setup_complete
  (returns true if value==='true'); else checks member_credentials row + VAPID env
  for effective-config branch (D-10)
- Re-queries DB fresh every call — no module-level cache (D-10/Pitfall 8)
- Convert Wave-0 it.todo() scaffolds into real integration tests (17 tests RED)
- RED-first 423 guard test: POST /complete twice → first 200, second 423 (Pitfall 8)
- D-10 effective-config tests: 423 when credRow AND VAPID env; NOT 423 otherwise
- 2 'does NOT return 423' tests pass (404 ≠ 423); all others RED pending Task 2 router
2026-06-15 13:53:53 -04:00
Lucas Berger 11e8102a71 feat(12-01): stub setupGuard.ts + setup.ts router — Wave-0 import targets
- Add apps/api/src/lib/setupGuard.ts exporting isSetupLocked(): Promise<boolean>
  (Wave-0 stub returns false; real DB impl ships in Plan 02)
  Doc comment enforces D-10: re-evaluate fresh on every call, never module-cache
- Add apps/api/src/routes/setup.ts exporting setupRouter = new Hono()
  (empty router; handlers + index.ts mount added in Plan 02)
  Doc comment notes pre-auth surface position — before /api/* OIDC chain
2026-06-15 13:40:04 -04:00
Lucas Berger 703fad2ca2 feat(12-01): schema nullable oidc identity + claimed marker + 0002 migration
- Remove .notNull() from users.oidc_iss and users.oidc_sub (wizard creates
  local rows before OIDC identity is known; first-login-claims binds later)
- Add users.claimed boolean (default false NOT NULL) to distinguish pending
  wizard rows from OIDC-bound rows (D-07)
- Add Phase 12 app_config key documentation + prohibition comment (D-01/SC-3)
- Generate migration 0002_lethal_millenium_guard.sql via drizzle-kit generate
  (MODIFY COLUMN for nullable, ADD COLUMN claimed — no DROP/recreate)
- Append backfill: UPDATE users SET claimed=true WHERE oidc_iss IS NOT NULL
  so existing OIDC users cannot be matched by first-login-claims (D-08)
- Apply migration via drizzle-kit migrate — users.claimed column verified in dev DB
2026-06-15 13:38:46 -04:00
Lucas BergerandClaude Opus 4.8 1f6ad076c1 style(18): prettier-format household timezone accessor + outbox test
CI / changes (pull_request) Successful in 4s
CI / fast-checks (pull_request) Successful in 1m31s
CI / api (pull_request) Successful in 1m5s
CI / harness (pull_request) Failing after 6m48s
CI / security (pull_request) Successful in 40s
CI / gate (pull_request) Failing after 1s
These two files (from the WR-01 / IN-03 review fixes) had formatting that
failed `pnpm format:check`. No logic change — whitespace/wrapping only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 08:36:49 -04:00
Lucas BergerandClaude Opus 4.8 1fb431e8da refactor(18): IN-03 memoize household timezone per outbox drain cycle
The UPDATE and CREATE all-day branches each called getHouseholdTimezone(db)
independently, so a drain processing both an all-day create row and an
all-day update row issued two identical app_config SELECTs. Add a lazy
per-cycle TimezoneResolver (mirroring the existing clientCache thread-through)
created in runOutboxDrain and passed into dispatchRow. The read stays lazy —
cycles with no all-day work never touch the DB — but is shared across all
all-day rows in a cycle. Behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:43:01 -04:00
Lucas BergerandClaude Opus 4.8 93217b58fe fix(18): WR-02 derive seed flag from DB write, not a stale pre-flight SELECT
The seed handler computed seeded from a pre-flight SELECT then returned
seeded:!alreadySet. Under a genuine concurrent race both requests can
SELECT the empty table, both enter the insert branch, and both return
seeded:true though only one row was actually written. Replace the
SELECT + conditional onDuplicateKeyUpdate with a single INSERT IGNORE
and derive seeded from affectedRows (1 = inserted, 0 = ignored/existing
row preserved, D-03). On MariaDB onDuplicateKeyUpdate(value=value)
reports affectedRows 1 for both insert and no-op, so it cannot
distinguish them; INSERT IGNORE can. timezone is bound via a
parameterized sql template and is already IANA-validated by zod. Adds a
test asserting seeded:false for a directly-pre-inserted row.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:41:52 -04:00
Lucas BergerandClaude Opus 4.8 692fe2ad9a refactor(18): IN-01/IN-02 reuse fetched row for GET timezone fallback
The GET /config/timezone handler SELECTed app_config then, on the unset
path, called getHouseholdTimezone(db) which re-issued the identical
SELECT before falling back (IN-01). The fallback decision also lived in
two places (IN-02). Route the handler through the centralized
resolveHouseholdTimezone(row?.value) added for WR-01: no redundant
round-trip, single source for the D-06 policy. Behavior unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:38:45 -04:00
Lucas BergerandClaude Opus 4.8 d168da71cf fix(18): WR-01 treat empty/blank TZ as unset in household timezone fallback
The D-06 fallback used row?.value ?? process.env.TZ ?? Intl..., but ??
only short-circuits on null/undefined. A set-but-empty TZ ('' or '   ')
leaked through and yielded an invalid IANA zone that throws inside
Intl.DateTimeFormat({ timeZone }) downstream, silently dropping the
all-day reminder. Extract resolveHouseholdTimezone() which trims and
treats empty/whitespace candidate values (stored value and TZ) as
absent so they fall through to the Intl resolved zone. Adds RED->GREEN
unit tests for empty and whitespace-only TZ.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-15 07:38:39 -04:00
Lucas Berger bda31a33bd fix(18): make timezone seed idempotent under concurrent race (WR-02)
- Import sql from drizzle-orm in admin.ts
- Add onDuplicateKeyUpdate({ set: { value: sql\`value\` } }) to the
  conditional INSERT in POST /config/timezone/seed so a concurrent seed
  (or seed racing a PUT) cannot 500 on the app_config.key PK constraint
- Existing value is preserved per D-03 no-overwrite (no-op ODKU)
- seeded flag still reflects the pre-flight SELECT (winner: true, loser: false)
- Add tests: 403 access control, seeded:true on first seed, seeded:false
  on second seed without throw (WR-02 idempotent race)
2026-06-14 22:56:18 -04:00
Lucas Berger c80845cdba feat(18-03): route all-day reminder TZ through stored household_timezone
- reminderScheduler.ts: add import { getHouseholdTimezone } from '../lib/householdTimezone.js'
  and replace bare process.env.TZ ?? Intl... at line 247 with await getHouseholdTimezone(db)
- outboxWorker.ts: add same import and replace BOTH bare tz lookups at the update-branch
  (~line 501) and create-branch (~line 607) with await getHouseholdTimezone(db)
- D-05 satisfied: all three all-day sites now read from the single stored accessor
- D-06 satisfied: getHouseholdTimezone falls back to process.env.TZ → Intl when unset;
  existing process.env.TZ-pinned tests pass unchanged
- D-07 satisfied: eventDateTime.ts and hydrateEvents.ts are not modified
- outboxWorker.test.ts: update wireMockChain() to handle app_config table with where().limit()
  chain returning empty rows (D-06 fallback), so existing CAL-13 all-day test stays green
- reminderScheduler.test.ts: update mockTwoQueries to mock the new third db.select() call
  (getHouseholdTimezone) returning no row (D-06 fallback), keeping all 37 existing tests green
- All 76 broker tests pass; tsc --noEmit clean
2026-06-14 22:31:37 -04:00
Lucas Berger 3bd6a5d97e feat(18-02): admin timezone GET/PUT/seed endpoints
- Add appConfig + getHouseholdTimezone/isValidIanaTimezone imports to admin.ts
- Add timezoneSchema: z.object({ timezone: z.string().min(1).max(64).refine(isValidIanaTimezone) })
  No noEchoHook — timezone strings are non-sensitive (T-18-06)
- GET /api/admin/config/timezone: returns { timezone, isExplicitlySet } using D-06 fallback
- PUT /api/admin/config/timezone: validates via timezoneSchema + upserts via onDuplicateKeyUpdate
- POST /api/admin/config/timezone/seed: SELECT-then-INSERT (no onDuplicateKeyUpdate) to enforce D-03 no-overwrite
- All three routes appended AFTER existing routes so line-41 requireAdmin covers them (T-18-03)
- All 25 admin.test.ts tests pass; 366/366 full API suite green; tsc --noEmit clean
2026-06-14 22:12:35 -04:00
Lucas Berger eaceff0295 feat(18-01): implement household timezone accessor + IANA validator
- getHouseholdTimezone(db): selects household_timezone from app_config
- D-06 fallback chain: stored value → process.env.TZ → Intl.DateTimeFormat().resolvedOptions().timeZone
- isValidIanaTimezone: try/catch Intl.DateTimeFormat (no Intl.supportedValuesOf per RESEARCH Pitfall 2)
- Exports match D-05 single-accessor contract for reminderScheduler + outboxWorker
- All 11 unit tests pass; 358/358 total suite green; tsc --noEmit clean
2026-06-14 22:07:16 -04:00
Lucas Berger 7d94afb2d8 fix(11-05): WR-02 add .max(10080) to reminderLeadMinutes in both Zod schemas
- eventFieldsSchema (events.ts): rejects reminderLeadMinutes > 10080 with 400
- outboxPayloadSchema (outboxWorker.ts): hard-fails row when value exceeds 1-week cap
- 10080 = 1 week in minutes; matches UI select maximum
2026-06-14 08:22:13 -04:00
Lucas Berger bc605e6a42 fix(11-05): WR-01 positive-duration TRIGGER classifies as custom (no Math.abs)
classifyValarms: check sign of dur.toSeconds() before preset lookup.
Positive value = alarm fires after event (RFC 5545 TRIGGER:+PT15M or
TRIGGER;RELATED=END:PTNm) → return {kind:'custom'} for preserve path.
Compute leadMinutes as -seconds/60 (was Math.abs) for negative triggers.

Prevents alarm direction inversion: +PT15M was being stored as 15-min-before
lead and re-fired at dtstartUtc-15min — the opposite of the original intent.
2026-06-14 08:15:31 -04:00
Lucas Berger 16ac235476 fix(11-05): CR-02 all-day-aware push body (no "Starts in 0 min")
- humanizeLeadMinutes: add isAllDay=false param; all-day branch returns
  "Today" (lead=0), "Tomorrow" (1440), "In 1 week" (10080), "In N days" (other)
- byKey map: store isAllDay flag (false for timed, true for all-day)
- dispatch loop: pass event.isAllDay to humanizeLeadMinutes

All-day same-day reminder push now reads "Today" instead of "Starts in 0 min".
Timed event wording unchanged (isAllDay defaults to false).
2026-06-14 08:14:08 -04:00
Lucas Berger f6b47ebf1e fix(11-05): CR-01 surface reminderIsCustom to preserve custom VALARMs on edit
- expand.ts: add reminderIsCustom:boolean to CalendarOccurrence interface;
  derived from classifyValarms kind==='custom'; propagated to both
  non-recurring and recurring occurrence branches
- client.ts: mirror reminderIsCustom on CalendarOccurrence (atomic mirror)
- EventForm.tsx: extend deriveReminderValue to accept isCustom flag;
  returns '__custom__' when true, making the existing D-08 preserve branch
  live — editing a custom-alarm event now omits reminderLeadMinutes from
  the payload so outboxWorker extractValarms keeps the original VALARM
- Fix existing test fixtures (EventForm.test.tsx, EventDetailPopover.test.tsx)
  to include reminderIsCustom:false on all CalendarOccurrence literals

Fixes CAL-14 Pitfall 1: Apple Calendar absolute DATE-TIME / multi-VALARM
alarms no longer silently stripped on any edit round-trip from the PWA.
2026-06-14 08:11:46 -04:00
Lucas Berger b9b3191b5b style(11-04): apply prettier to phase-11 modified files
- apps/pwa/src/components/EventForm.tsx (Task 2)
- apps/api/src/broker/{expand,reminderScheduler,sync,vevent}.ts (Plans 11-01/11-03)
- apps/api/tests/broker/{reminderScheduler,sync}.test.ts (Plans 11-01/11-03)
2026-06-14 06:54:11 -04:00
Lucas Berger adf5d13e28 merge(phase-11): wave 2 plan 11-03 backend plumbing 2026-06-13 22:27:54 -04:00
Lucas Berger 0dc227a863 feat(11-02): Task 3 — all-day 9 AM-local fire branch + dedup prune fix (NOTIF-06)
- Add all-day 9 AM tests: 0-lead fires at EDT alert UTC, not midnight
- Add 1440-lead (day-before) and 10080-lead (7-day-before) tests
- Add all-day dedup test: same uid:dtstartMs fires once across ticks
- Fix all-day prune bug: store start-of-next-day as pruneMs instead of
  UTC midnight (which was always <= now by fire time, causing immediate prune)
- Separate dtstartMs (dedup key component) from pruneMs (map cleanup value)
- 28/28 tests GREEN; full API suite 314/314; tsc --noEmit clean
2026-06-13 22:25:54 -04:00
Lucas Berger e1714316be feat(11-03): surface reminderLeadMinutes on CalendarOccurrence + GET select (D-10)
- Add reminderLeadMinutes: number | null to CalendarOccurrence interface (D-06)
- Import classifyValarms in expand.ts; derive series-level value once per VEVENT
- Add reminderLeadMinutes to both non-recurring and recurring occurrence construction
- Add reminderLeadMinutes to GET /api/events select for edit-mode pre-population
2026-06-13 22:21:42 -04:00
Lucas Berger 62d3f58684 feat(11-02): GREEN Task 1 — variable-lead window, uid:dtstartMs dedup, drop isShared restriction
- Replace fixed 16-min window with per-event variable-lead fire-time check
- Two separate DB queries: timed (allDay=false) + all-day (allDay=true)
- Remove eq(calendars.isShared, true) — personal events now dispatch (NOTIF-05)
- Remove eq(calendarEvents.allDay, false) — all-day handled in separate query
- Add reminder_lead_minutes IS NOT NULL WHERE predicate (NOTIF-05)
- Skip timed events with reminderLeadMinutes===0 in JS (D-06: 0 on timed = None)
- Change dedup key from bare uid to uid:dtstartMs compound key (NOTIF-06)
- Update prune loop to use compound key
- Import computeAlertInstantUtc from vevent.js (Plan 11-01, wave 2 dep)
- Add humanizeLeadMinutes export (Task 2 body formatter, used in dispatch)
- Update test helper mockTwoQueries() to handle two-query dispatch pattern
- All 14 tests GREEN; tsc --noEmit clean; setInterval retained, no node-cron
2026-06-13 22:20:51 -04:00
Lucas Berger 1cc0d7278a feat(11-03): sync.ts derives reminderLeadMinutes from VALARM classification (D-07/NOTIF-05)
- Import classifyValarms from vevent.ts
- Derive reminderLeadMinutesValue: preset/offlist → leadMinutes; custom/none → null
- Add reminderLeadMinutes to .values() and .onDuplicateKeyUpdate({ set: {} })
- Scheduler now has ground truth for native-client VALARMs (T-11-07 mitigated)
2026-06-13 22:18:46 -04:00
Lucas Berger 4f42b7535b feat(11-03): reminderLeadMinutes schema + VALARM wiring in outbox worker (CAL-13/CAL-14)
- Add reminderLeadMinutes to eventFieldsSchema (ingress validation, T-11-06)
- Add reminderLeadMinutes to outboxPayloadSchema (drain re-validation, IN-03 defense-in-depth)
- Import extractValarms + computeAlertInstantUtc from vevent.ts
- UPDATE branch: hasExplicitReminder gate mirrors hasExplicitRecurrence (WR-01 pattern)
  - absent field → extractValarms(rawVevent) preserved verbatim (CAL-14, D-08)
  - explicit null → clear VALARM (no valarmsToPreserve, null passed to buildVeventString)
  - explicit value + allDay → computeAlertInstantUtc at 9 AM local (D-04)
  - explicit value + timed → passed through to buildTimedValarm via buildVeventString
- CREATE branch: always explicit picker value; compute allDayAlertInstantUtc when allDay
2026-06-13 22:16:42 -04:00
Lucas Berger d9eb5c1875 feat(11-01): implement VALARM builders, classifier, extractor, computeAlertInstantUtc
Task 1 — buildTimedValarm, buildAllDayValarm, VALARM emission in buildVeventString:
- buildTimedValarm(leadMinutes): relative DURATION trigger via resetType('duration') +
  ICAL.Duration.fromSeconds to prevent VALUE=TEXT (Pitfall 2)
- buildAllDayValarm(alertInstantUtc): absolute DATE-TIME trigger via resetType('date-time') +
  ICAL.Time.fromJSDate(utc, true); ensures VALUE=DATE-TIME, no DURATION
- NewEventParams extended with reminderLeadMinutes, valarms, allDayAlertInstantUtc
- buildVeventString: preserve path (valarms[] wins) → all-day absolute → timed relative;
  timed 0 = None per D-06; no emission on null/undefined (CAL-13/D-08)

Task 2 — classifyValarms, extractValarms (CAL-14):
- AlarmClassification type: none | preset | offlist | custom
- PRESET_MINUTES set: 0,5,10,15,30,60,120,1440,2880,10080
- classifyValarms: ICAL.parse try/catch → none/custom/preset/offlist via instanceof ICAL.Time
- extractValarms: returns live ICAL.Component[] for re-attachment; safe on parse failure

Task 3 — computeAlertInstantUtc DST-correct 9 AM local→UTC (NOTIF-06):
- Probes UTC offset at 9 AM (not midnight) so spring-forward/fall-back DST transitions
  before 9 AM resolve with the post-transition offset
- Pure Intl.DateTimeFormat arithmetic, no timezone library; verified at 4 DST boundaries
2026-06-13 22:04:18 -04:00
Lucas Berger 2f347cbd98 fix(10): guard shared-calendar designation against non-existent target (CR-01)
PUT /api/admin/calendars/:id/shared cleared the current shared calendar then
set the target in two non-transactional UPDATEs without checking the target
exists — a bad/stale id wiped the family shared lane and still returned ok.
Verify the target inside a transaction; return 404 when absent. Adds a
regression test (RED→GREEN).
2026-06-13 15:38:35 -04:00
Lucas Berger 79fe3e0e04 fix(10-04): prettier format + remove unnecessary type assertions
- Run prettier on all new/modified PWA files (CredentialSheet, SetupBanner, AdminPage, admin.spec.ts)
- Remove unnecessary 'as React.RefObject<HTMLElement | null>' casts flagged by @typescript-eslint/no-unnecessary-type-assertion
- Format pre-existing API files from Plans 02/03 (me.ts, user.test.ts, requireAdmin.test.ts, me.test.ts)
- All 270 API tests + 191 PWA vitest tests pass; lint/typecheck/build clean
2026-06-13 15:22:50 -04:00