Commit Graph
75 Commits
Author SHA1 Message Date
Lucas BergerandClaude Opus 4.8 b6490feff4 fix(19): satisfy CI fast-checks + secret scan
CI / changes (pull_request) Successful in 9s
CI / api (pull_request) Successful in 3m2s
CI / fast-checks (pull_request) Successful in 4m20s
CI / security (pull_request) Successful in 1m14s
CI / harness (pull_request) Successful in 6m56s
CI / gate (pull_request) Successful in 2s
Lint (eslint --max-warnings 0):
- index.ts: disable no-unsafe-argument on the type-only Context mismatch when
  delegating to the OIDC handler inside the local-session skip wrapper
- localAuth.ts: handleLogout is sync (no await) — drop async (require-await)
- devBypass.ts: disable detect-possible-timing-attacks on the public well-known
  dev-placeholder string compare (not a secret comparison)
- remove dead code / unused bindings flagged by no-unused-vars: makeTestApp
  (localSession.test), makeUnauthContext + BrowserContext import (login.spec),
  unused memberId (admin.test), unused txSelectCount counter (me.test)
- localAuthMiddleware.test / me.test: fix unused + reflow-detached
  eslint-disable directives

Format: prettier --write across the 20 Phase-19 files that were never formatted.

Secret scan (gitleaks): allowlist two false positives — the synthetic >=32-char
TEST_SECRET in localSession.test.ts, and .planning/ design prose (a generic-api-key
regex hit on "credential atomically, 409-equivalent"). Neither is a real secret.

Verified locally: format:check, lint, typecheck, md:lint, gitleaks (no leaks),
PWA 266/266, API 452/452.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-17 23:05:15 -04:00
Lucas Berger 2691dd0f95 fix(19): IN-04 enforce single-use OIDC-link nonce to prevent state replay 2026-06-17 20:37:09 -04:00
Lucas Berger f02521dd02 fix(19): IN-03 evict stale loginAttempts entries to bound in-memory map growth 2026-06-17 20:35:43 -04:00
Lucas Berger 32bdd1e92d fix(19): WR-07 reject route ids with trailing garbage via strict integer parse 2026-06-17 20:32:26 -04:00
Lucas Berger 4cf2ad4bff fix(19): WR-06 stop sliding the rate-limit cooldown window on rejected attempts 2026-06-17 20:31:07 -04:00
Lucas Berger 30ad25c026 fix(19): WR-03 make scrypt hashing async (threadpool) to avoid event-loop starvation DoS 2026-06-17 20:30:28 -04:00
Lucas Berger 322929aebe fix(19): WR-02+WR-04 centralize OIDC config (env-or-app_config) and discover auth endpoint 2026-06-17 20:26:47 -04:00
Lucas Berger b083cb7193 fix(19): CR-04 scope login lockout to username, add TTL auto-expiry + admin-reset unlock 2026-06-17 20:20:43 -04:00
Lucas Berger 6ef8e03f8c fix(19): CR-03 return 403 for wrong current password so change-password does not log user out 2026-06-17 20:17:32 -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 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 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 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 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 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 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 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 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 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 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 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 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
Lucas Berger d2f6d5d77b feat(10-03): implement credentialSync helper, adminRouter, and self-service /api/me/credential
credentialSync.ts:
- validateEncryptAndStoreCredential(userId, email, appPassword, providerType) — single
  shared validate→encrypt→store→initial-sync path used by BOTH admin and self-service
- createFastmailClient + fetchCalendars wrapped in ONE try/catch: any failure throws
  CredentialValidationError (routes map to { error: 'Invalid request' } 400)
- appPassword never logged or echoed (T-10-10)
- encryptPassword (AES-256-GCM) applied before DB write (T-10-11)
- fire-and-forget initial sync via loadClientForUser + syncCalendar (Pitfall 5)

admin.ts:
- adminRouter.use('*', requireAdmin) FIRST (Pitfall 9 / T-10-08)
- GET /members: users LEFT JOIN member_credentials → hasCredential boolean
- POST /credentials: noEchoHook + validateEncryptAndStoreCredential (T-10-09)
- GET /calendars: calendar list (UI-SPEC Surface 5)
- PUT /calendars/:id/shared: exclusive is_shared update (ADMIN-02, D-06)

index.ts:
- app.route('/api/admin', adminRouter) mounted in route block

me.ts:
- POST /credential: member self-service, always currentUserId (Pitfall 6 / T-10-12)
- meCredentialSchema (no userId field), meNoEchoHook, calls shared helper
- All 17 new admin tests pass; 270 total pass; tsc --noEmit clean
2026-06-13 14:54:23 -04:00
Lucas Berger 1adff61cec feat(10-02): extend /api/me with isAdmin + needsProviderSetup (D-03)
- dev-bypass path: DB lookup for users.isAdmin (T-10-05 bypass skips OIDC not DB)
- OIDC path: same resolveAdminAndSetupStatus helper after upsertUser
- needsProviderSetup: true when no member_credentials row, false when one exists
- no /api/me/credential POST added here (Plan 03)
2026-06-13 14:37:38 -04:00
Lucas Berger b8bb6e7671 fix(09): IN-01 add ordering comments cementing signalOutboxDrain() stays after the enqueue commit 2026-06-12 20:50:26 -04:00
Lucas Berger 30eff3dcdb feat(09-02): add signalOutboxDrain() after each of four enqueue sites in events.ts
- import signalOutboxDrain from '../lib/outboxTrigger.js'
- Site 1 (POST /create): fire-and-forget signal after outbox insert, before 202
- Site 2 (PATCH edit-as-move): signal after await db.transaction() resolves, not inside callback (D-03)
- Site 3 (PATCH same-calendar update): signal after outbox insert, before 202
- Site 4 (DELETE): signal after outbox insert, before 202
2026-06-12 16:57:21 -04:00
Lucas Berger 982438dc10 style(13-03): apply Prettier formatting across repo
Mechanical reformat — no logic changes. 398 files changed, 19125
insertions(+), 16457 deletions(-). Prettier 3.8.4 with .prettierrc
(singleQuote:true, semi:true, tabWidth:2, trailingComma:all,
printWidth:100). Isolated per D-13-08 for reviewability.
2026-06-11 20:35:18 -04:00
Lucas Berger 03e953158a fix(13-02): eliminate all ESLint violations — pnpm lint exits 0
- eslint.config.js: disable React Compiler rules (v7 flat.recommended enables
  them; codebase does not use the Compiler); add e2e/ to disableTypeChecked
  block; promote exhaustive-deps to error
- API broker: remove redundant as-casts (outboxWorker, poller, reminderScheduler,
  expand, sync, vevent, spike); add targeted ical.js no-unsafe-assignment/argument
  disables with justifying comments inside try blocks
- API routes/sse.ts: fix no-misused-promises on async writeSSE callback with
  void+IIFE+catch pattern
- API routes/lists.ts: let → const for updateValues
- API tests: remove unused imports (beforeEach, eq, vi); rename unused vars
  with _ prefix; remove unused lastActiveId assignment
- PWA components: void navigate() and void queryClient.invalidateQueries() on
  all fire-and-forget call sites; fix CalendarShell explicit-type-casts;
  Couldn't → HTML entity
- PWA test files: as unknown as Response for partial mock objects; string | null
  type annotation on mockLastSyncedUid; remove async from test callbacks without
  await; act(() => {}) not await act(async () => {}) for sync ops
- sw.ts: restructure Notification.data?.url access as let+if so disable
  comments land on the exact violation lines; void self.skipWaiting()
2026-06-11 20:23:38 -04:00
Lucas Berger eb00ec7dfb fix(06): WR-01 gate all-day SQL branch on hasRrule=0 to stop duplicate occurrences 2026-06-10 16:51:19 -04:00
Lucas Berger d101aa899d fix(06): CR-01 validate recurrenceUntil as YYYY-MM-DD to close RRULE injection 2026-06-10 16:51:06 -04:00
Lucas Berger d2abb91bd2 feat(06-02): serialize RRULE UNTIL/COUNT and lock FREQ persistence
- Add assembleRruleString() helper (exported) to outboxWorker.ts (D-06)
- Wire UNTIL/COUNT bound assembly into create + update dispatch branches
- Add recurrenceUntil (max 10) + recurrenceCount (int min 1) to outboxPayloadSchema
- Add recurrenceUntil + recurrenceCount to eventFieldsSchema in events.ts
- Series-edit bound change strips existing UNTIL/COUNT via regex before re-apply (Pitfall 3)
- All 39 broker tests pass (RED->GREEN); existing CR-01 none-wins test preserved
2026-06-10 11:00:24 -04:00
Lucas Berger c69bd30aaa fix(05-review): WR-03 log error.message not raw error in push route catch blocks 2026-06-09 22:20:43 -04:00
Lucas Berger d2ce4e08c7 feat(05-05): hook notifyListChange into list/item mutations (reorder excluded)
- POST /:id/items (item added) → notifyListChange
- PATCH /list-items/:itemId checked/text → notifyListChange; position-only → silent (D-01)
- DELETE /list-items/:itemId → notifyListChange
- PATCH /:id (list rename/share toggle) → notifyListChange
- DELETE /:id (list delete) → notifyListChange
- POST / (list create) → no notification (empty list, D-01 spirit)
- lists.test.ts: 2 new tests prove reorder-silent (position) and check-notifies (NOTIF-02)
- All 59 lists.test.ts assertions GREEN
2026-06-09 21:26:29 -04:00
Lucas Berger f6f1374904 feat(05-04): push subscription API + VAPID startup wiring
- Create apps/api/src/routes/push.ts: GET /vapid-public-key, POST /subscription (upsert), DELETE /subscription (user-scoped)
- Wire pushRouter at /api/push in index.ts
- Call webpush.setVapidDetails() in isMainModule() guard before serve()
- Fix broken vi.getMockImplementation scaffold bug in push.test.ts (Rule 1)
- push.test.ts: all 4 tests GREEN
2026-06-09 21:04:16 -04:00
Lucas Berger c0bd6d732d fix(04-07): add owner-only guard for isShared on PATCH /api/lists/:id (T-04-08)
- Immediately after access check, return 403 if patch.isShared !== undefined
  and !access.isOwner — blocks sharees from mutating list_shares
- Guard message: 'Only the list owner can change sharing settings'
- Sharees may still PATCH { name } (rename test stays green)
- Update stale comment: 'Reconcile list_shares on visibility change (owner only)'
- Closes T-04-08 (elevation of privilege) and T-04-05 (shared root cause)
2026-06-09 14:22:48 -04:00
Lucas Berger 1652a68c51 feat(04-06): implement live-sync SSE vertical slice (LIST-04, D-04/D-10/D-11/D-12)
- Wire publishListEvent fan-out in lists.ts after every write mutation (item:added/updated/deleted, list:updated/deleted)
- Add GET /api/sse/lists scoped endpoint in sse.ts: resolveUserId → 401 on null; getAccessibleListIds → subscribe only to accessible channels; 30s heartbeat; cleanup on disconnect (D-04/T-04-01/T-04-02)
- Create useListSSE.ts: bounded-backoff EventSource wrapper (250ms→500ms→1s→2s→4s→cap 8s); MAX_ATTEMPTS=6; withCredentials:true; close-before-retry prevents reconnect storm (Pitfall 3); invalidates ['list', listId] on open (D-10) and on each event; onStateChange('disconnected') after exhaustion (D-11)
- Create LiveSyncIndicator.tsx: connected=green dot; reconnecting=pulsing muted dot + label; disconnected=red dot + 'Updates paused' (role=alert); correct ARIA per UI-SPEC
- Wire useListSSE + LiveSyncIndicator into ListDetail header; retain refetchInterval:30000 polling fallback (D-12)
- All 8 useListSSE tests pass; all 54 API tests pass; both typechecks pass
- playwright-cli: live update confirmed (eggs item added via API appeared in browser without manual refresh)
2026-06-09 13:33:04 -04:00
Lucas Berger 5e3151416c feat(04-04): implement item CRUD endpoints + fractional rank (LIST-02)
- Add rank.ts: rankForAppend/rankBetween wrapping fractional-indexing (D-13)
- Extend listsRouter: POST /:id/items (fractional rank at active-bottom),
  GET /:id/items (rank ASC, access-gated)
- Add listItemsRouter (mounted /api/list-items): PATCH /:itemId per-field LWW
  (exactly-one-field zod refine D-08/T-04-07), DELETE /:itemId delete-wins (D-09)
- Uncheck recomputes rank to active-bottom in same write (Open Question 2)
- All item handlers: access-gate via checkListAccess (T-04-05)
- Plan 06 SSE seam comments at each mutation handler
- All 48 tests green; typecheck passes
2026-06-09 12:55:32 -04:00