248 lines
19 KiB
Markdown
248 lines
19 KiB
Markdown
---
|
|
phase: 02-calendar-display
|
|
plan: 01
|
|
type: execute
|
|
wave: 1
|
|
depends_on: []
|
|
files_modified:
|
|
- apps/api/src/db/schema.ts
|
|
- apps/api/src/auth/devBypass.ts
|
|
- apps/api/src/index.ts
|
|
- apps/api/tests/fixtures/weekly-dst.ics
|
|
- apps/api/tests/fixtures/allday-birthday.ics
|
|
- apps/api/tests/fixtures/exdate-series.ics
|
|
- apps/api/tests/broker/expand.test.ts
|
|
- apps/api/tests/routes/events.test.ts
|
|
- apps/api/tests/auth/devBypass.test.ts
|
|
- apps/pwa/vitest.config.ts
|
|
- apps/pwa/package.json
|
|
- apps/pwa/src/lib/hydrateEvents.test.ts
|
|
- apps/pwa/src/lib/calendarConfig.test.ts
|
|
- .env.example
|
|
- docs/deployment.md
|
|
autonomous: true
|
|
requirements: [CAL-02, CAL-03, CAL-07]
|
|
user_setup: []
|
|
|
|
must_haves:
|
|
truths:
|
|
- "Drizzle schema has an indexed hasRrule boolean on calendar_events and an isShared boolean on calendars, both pushed to the live MariaDB"
|
|
- "PWA test runner (vitest + jsdom + @testing-library/react) executes and a smoke test passes"
|
|
- "Dev-auth bypass middleware injects a fixed dev user only when DEV_AUTH_BYPASS=true AND NODE_ENV!=production"
|
|
- "Failing-but-present test stubs exist for expand, events route, hydrateEvents, and calendarConfig (Wave 0 RED state) with concrete behavioral assertions, not bare failing imports"
|
|
artifacts:
|
|
- path: "apps/api/src/db/schema.ts"
|
|
provides: "hasRrule + isShared columns + idx_calendar_events_has_rrule index"
|
|
contains: "has_rrule"
|
|
- path: "apps/api/src/auth/devBypass.ts"
|
|
provides: "devAuthBypass() middleware with hard production guard"
|
|
exports: ["devAuthBypass"]
|
|
- path: "apps/pwa/vitest.config.ts"
|
|
provides: "jsdom-environment vitest config for PWA"
|
|
contains: "jsdom"
|
|
- path: "apps/api/tests/fixtures/weekly-dst.ics"
|
|
provides: "DST-spanning weekly RRULE fixture for CAL-07 tests"
|
|
min_lines: 10
|
|
key_links:
|
|
- from: "apps/api/src/index.ts"
|
|
to: "apps/api/src/auth/devBypass.ts"
|
|
via: "app.use('/api/*', devAuthBypass()) before oidcAuthMiddleware"
|
|
pattern: "devAuthBypass"
|
|
- from: "apps/pwa/package.json"
|
|
to: "vitest"
|
|
via: "test script + devDependencies"
|
|
pattern: "\"test\".*vitest"
|
|
---
|
|
|
|
<objective>
|
|
Lay the verifiable foundation for the Phase 2 calendar slice: add the two schema columns the
|
|
display pipeline depends on (`calendar_events.hasRrule`, `calendars.isShared`), push them to the
|
|
live MariaDB, stand up the PWA test runner, write the dev-auth bypass so the UI can be built
|
|
without live Authelia (D-14), and create the failing test stubs + ICS fixtures that all later
|
|
waves turn green.
|
|
|
|
Purpose: Every later plan (expansion engine, windowed route, hydration, calendar render) needs
|
|
these columns, the test harness, and the dev-auth bypass to exist first. This is the only
|
|
horizontal-foundation plan in the phase — kept minimal so the next plan delivers a real slice.
|
|
Output: Migrated schema (pushed), PWA vitest harness, dev-auth bypass middleware, ICS fixtures,
|
|
RED test stubs with concrete behavioral contracts.
|
|
</objective>
|
|
|
|
<execution_context>
|
|
@$HOME/.claude/get-shit-done/workflows/execute-plan.md
|
|
@$HOME/.claude/get-shit-done/templates/summary.md
|
|
</execution_context>
|
|
|
|
<context>
|
|
@.planning/PROJECT.md
|
|
@.planning/ROADMAP.md
|
|
@.planning/STATE.md
|
|
@.planning/phases/02-calendar-display/02-RESEARCH.md
|
|
@.planning/phases/02-calendar-display/02-PATTERNS.md
|
|
@.planning/phases/02-calendar-display/02-CONTEXT.md
|
|
</context>
|
|
|
|
<tasks>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 1: Add schema columns + PWA test harness + ICS fixtures + RED test stubs</name>
|
|
<files>apps/api/src/db/schema.ts, apps/pwa/vitest.config.ts, apps/pwa/package.json, apps/api/tests/fixtures/weekly-dst.ics, apps/api/tests/fixtures/allday-birthday.ics, apps/api/tests/fixtures/exdate-series.ics, apps/api/tests/broker/expand.test.ts, apps/api/tests/routes/events.test.ts, apps/pwa/src/lib/hydrateEvents.test.ts, apps/pwa/src/lib/calendarConfig.test.ts</files>
|
|
<read_first>
|
|
- apps/api/src/db/schema.ts (current calendarEvents + calendars table definitions; column + index style to copy)
|
|
- apps/api/vitest.config.ts (analog for PWA config — change environment node→jsdom)
|
|
- apps/pwa/package.json (current scripts + devDependencies block)
|
|
- apps/api/tests/broker/poller.test.ts (test file structure: describe/it/expect, vi.mock hoisting)
|
|
- apps/api/tests/health.test.ts (Hono app.request() route-test pattern)
|
|
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Validation Architecture" (fixture corpus + Wave 0 gaps table) + §"Pattern 1" (CalendarOccurrence carries calendarId, ownerUserId, isShared) + §"Pattern 2" (hydrateEvents calendarId routing)
|
|
- .planning/phases/02-calendar-display/02-PATTERNS.md §"apps/pwa/vitest.config.ts" and §"apps/api/tests/broker/expand.test.ts"
|
|
</read_first>
|
|
<behavior>
|
|
- expand.test.ts (CONCRETE — not a bare failing import): load weekly-dst.ics, call expandOccurrences for a window spanning the March 2026 America/New_York EST→EDT transition (e.g. 2026-03-01..2026-03-31), and assert that every returned occurrence's local wall-clock time is 10:00 America/New_York on BOTH sides of the DST boundary (i.e. the pre-transition and post-transition occurrences share the same 10:00 local hour — NOT shifted ±1h by a UTC fallback). The stub must encode this exact assertion so Plan 02's GREEN path has a real contract; do NOT settle for asserting only that ICAL.parse() succeeds.
|
|
- expand.test.ts: all-day birthday fixture returns allDay:true with start as 'YYYY-MM-DD' (e.g. '2026-06-15') and no time component
|
|
- expand.test.ts: exdate-series fixture omits the single EXDATE-excluded occurrence (returned array length is one fewer than an un-excluded expansion)
|
|
- events.test.ts: GET /api/events?start=&end= returns occurrences each carrying a color field and isShared flag; missing/malformed start or end → 400 (RED — route not evolved yet)
|
|
- hydrateEvents.test.ts: all-day occurrence (allDay:true, start '2026-06-15') → start is Temporal.PlainDate; timed occurrence → Temporal.ZonedDateTime
|
|
- hydrateEvents.test.ts (CONCRETE routing contract): a shared occurrence (isShared:true) → Schedule-X calendarId === 'shared'; a personal occurrence (isShared:false, ownerUserId:7, calendarId:99) → Schedule-X calendarId === '7' (String(ownerUserId)), explicitly NOT '99' (String(calendarId)). This assertion locks the Plan 03 routing fix.
|
|
- calendarConfig.test.ts: WEEK_START_DAY=0 translates to Schedule-X firstDayOfWeek 7
|
|
</behavior>
|
|
<action>
|
|
Add to `calendarEvents` in schema.ts: `hasRrule: boolean('has_rrule').default(false).notNull()`, and a new index `index('idx_calendar_events_has_rrule').on(t.hasRrule)` in the table's index array (copy the exact style of `idx_calendar_events_dtstart_utc`). Add to `calendars`: `isShared: boolean('is_shared').default(false).notNull()`. `boolean` and `index` are already imported.
|
|
|
|
Create `apps/pwa/vitest.config.ts` mirroring `apps/api/vitest.config.ts` but with `environment: 'jsdom'` and `globals: true`. In `apps/pwa/package.json` add `"test": "vitest run"` to scripts and add devDependencies `vitest`, `@testing-library/react`, `@testing-library/jest-dom`, `jsdom` (use versions compatible with the workspace's existing vitest major; match the version in apps/api). Install via `pnpm install` at repo root.
|
|
|
|
Create three ICS fixtures under `apps/api/tests/fixtures/`: `weekly-dst.ics` (VEVENT with `DTSTART;TZID=America/New_York:20260301T100000`, `RRULE:FREQ=WEEKLY`, and a full `VTIMEZONE` block for America/New_York with both STANDARD and DAYLIGHT subcomponents so DST rules are present), `allday-birthday.ics` (VEVENT with `DTSTART;VALUE=DATE:20260615`, yearly RRULE, no DTEND), `exdate-series.ics` (weekly VEVENT with one `EXDATE` line removing a single occurrence). These must be valid VCALENDAR strings parseable by ICAL.parse.
|
|
|
|
Create the four RED test stubs with the CONCRETE behavioral assertions described in <behavior> above — each must encode its real contract (the DST wall-clock assertion in expand.test.ts; the 'shared'/String(ownerUserId) calendarId routing assertion in hydrateEvents.test.ts), not merely a failing import. Each test imports the not-yet-existing module (`../../src/broker/expand.js`, etc.) so the file fails to resolve / the assertion fails — that is the intended RED state. Per the Nyquist rule, mark each `<automated>` for the modules they cover as satisfied here. Use the describe/it patterns from poller.test.ts and health.test.ts. Load fixtures with `readFileSync` relative to the test file. Do NOT implement expand.ts, the route changes, hydrateEvents.ts, or calendarConfig.ts in this task — only the stubs that later plans turn green.
|
|
</action>
|
|
<verify>
|
|
<automated>cd apps/api && grep -q "has_rrule" src/db/schema.ts && grep -q "is_shared" src/db/schema.ts && grep -q "idx_calendar_events_has_rrule" src/db/schema.ts && echo SCHEMA_OK</automated>
|
|
<automated>cd /home/luc/Projects/familysync && grep -q jsdom apps/pwa/vitest.config.ts && grep -q '"test": "vitest run"' apps/pwa/package.json && echo PWA_HARNESS_OK</automated>
|
|
<automated>cd apps/api && node -e "const I=require('ical.js');for(const f of ['weekly-dst','allday-birthday','exdate-series']){I.parse(require('fs').readFileSync('tests/fixtures/'+f+'.ics','utf8'))};console.log('FIXTURES_PARSE_OK')"</automated>
|
|
<automated>cd /home/luc/Projects/familysync && grep -q "10:00" apps/api/tests/broker/expand.test.ts && grep -q "shared" apps/pwa/src/lib/hydrateEvents.test.ts && grep -q "ownerUserId" apps/pwa/src/lib/hydrateEvents.test.ts && echo STUB_CONTRACTS_PRESENT</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- apps/api/src/db/schema.ts contains `hasRrule: boolean('has_rrule')` and `idx_calendar_events_has_rrule`
|
|
- apps/api/src/db/schema.ts contains `isShared: boolean('is_shared')` on the calendars table
|
|
- apps/pwa/vitest.config.ts contains `environment: 'jsdom'`
|
|
- apps/pwa/package.json scripts contains `"test": "vitest run"` and devDependencies include vitest, @testing-library/react, jsdom
|
|
- All three fixture .ics files parse via ICAL.parse without throwing
|
|
- weekly-dst.ics contains a VTIMEZONE block with both STANDARD and DAYLIGHT subcomponents
|
|
- expand.test.ts asserts 10:00 local wall-clock on both sides of the March 2026 DST boundary (concrete contract, not just ICAL.parse success)
|
|
- hydrateEvents.test.ts asserts shared→'shared' and personal→String(ownerUserId) (NOT String(calendarId)) for the Schedule-X calendarId
|
|
- expand.test.ts, events.test.ts, hydrateEvents.test.ts, calendarConfig.test.ts exist and reference their target modules (RED is expected — modules not yet built)
|
|
</acceptance_criteria>
|
|
<done>Schema columns + index added; PWA vitest/jsdom harness installed and runnable; three ICS fixtures parse; four RED test stubs exist with concrete behavioral assertions (DST wall-clock + calendarId routing) referencing not-yet-built modules.</done>
|
|
</task>
|
|
|
|
<task type="auto" tdd="true">
|
|
<name>Task 2: Dev-auth bypass middleware + production guard + env docs</name>
|
|
<files>apps/api/src/auth/devBypass.ts, apps/api/src/index.ts, apps/api/tests/auth/devBypass.test.ts, .env.example, docs/deployment.md</files>
|
|
<read_first>
|
|
- apps/api/src/auth/middleware.ts (re-export pattern; getAuth/oidcAuthMiddleware surface)
|
|
- apps/api/src/index.ts (current middleware mount order: callback → /health → oidcAuthMiddleware on /api/* → routes)
|
|
- apps/api/src/routes/me.ts (how getAuth(c) is consumed downstream — the injected user must satisfy it)
|
|
- apps/api/src/auth/user.ts (DEV_USER shape: id, displayName, color; COLOR_PALETTE[0])
|
|
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Pattern 5: Dev-Auth Bypass Middleware" and §"Pitfall 7"
|
|
- docs/deployment.md (existing dev-auth-bypass / Gate 2 context to extend)
|
|
</read_first>
|
|
<behavior>
|
|
- devBypass.test.ts: with NODE_ENV='production' the middleware is a pure passthrough and never sets a user, even if DEV_AUTH_BYPASS='true'
|
|
- devBypass.test.ts: with NODE_ENV='test' and DEV_AUTH_BYPASS unset, middleware is passthrough (no user injected)
|
|
- devBypass.test.ts: with NODE_ENV!='production' and DEV_AUTH_BYPASS='true', a fixed dev user is injected into the Hono context
|
|
</behavior>
|
|
<action>
|
|
Create `apps/api/src/auth/devBypass.ts` exporting `devAuthBypass(): MiddlewareHandler`. FIRST check `process.env.NODE_ENV === 'production'` and return a no-op passthrough (`async (_c, next) => next()`) before reading any other env var — this hard guard is mandatory (Pitfall 7). Then if `process.env.DEV_AUTH_BYPASS !== 'true'`, also return passthrough. Otherwise return a handler that calls `c.set('user', DEV_USER)` then `await next()`. DEV_USER = a fixed object `{ id, oidcIss: 'dev', oidcSub: 'dev-user', displayName: 'Dev User', color }` where color is `COLOR_PALETTE[0]` ('#4A90D9'). Match the context key (`'user'`) and shape that `getAuth(c)` consumers in me.ts expect — read me.ts to confirm whether downstream reads `getAuth(c)` or `c.get('user')`; if me.ts uses `getAuth(c)` from @hono/oidc-auth, set both the auth claim and `c.set('user', DEV_USER)` so the events route (which will read the resolved user) works. Document the exact mechanism in a top-of-file comment.
|
|
|
|
In `apps/api/src/index.ts`, mount `app.use('/api/*', devAuthBypass())` on the line immediately BEFORE the existing `app.use('/api/*', oidcAuthMiddleware())`. The bypass is a no-op when inactive, so production behavior is unchanged.
|
|
|
|
Add `DEV_AUTH_BYPASS` to `.env.example` with a comment: `# DEV ONLY — injects a fixed dev user, skips Authelia. Hard-disabled when NODE_ENV=production. NEVER set in prod.` Extend `docs/deployment.md` dev-auth-bypass section to note the NODE_ENV production hard guard and that the production Docker Compose must not set DEV_AUTH_BYPASS.
|
|
</action>
|
|
<verify>
|
|
<automated>cd apps/api && pnpm test -- tests/auth/devBypass.test.ts</automated>
|
|
<automated>cd apps/api && grep -q "NODE_ENV === 'production'" src/auth/devBypass.ts && grep -q "devAuthBypass()" src/index.ts && echo BYPASS_WIRED</automated>
|
|
<automated>cd /home/luc/Projects/familysync && grep -q "DEV_AUTH_BYPASS" .env.example && echo ENV_DOCUMENTED</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- apps/api/src/auth/devBypass.ts exports devAuthBypass and the FIRST conditional checks NODE_ENV === 'production'
|
|
- apps/api/src/index.ts calls devAuthBypass() on /api/* immediately before oidcAuthMiddleware()
|
|
- tests/auth/devBypass.test.ts passes: production guard, unset-flag passthrough, and active-injection cases all green
|
|
- .env.example documents DEV_AUTH_BYPASS with the production warning comment
|
|
- docs/deployment.md notes the production hard guard and prod-compose prohibition
|
|
</acceptance_criteria>
|
|
<done>devAuthBypass middleware exists with production hard guard, is mounted before oidcAuthMiddleware, all three behavior tests pass, env + deployment docs updated.</done>
|
|
</task>
|
|
|
|
<task type="auto" gate="blocking">
|
|
<name>Task 3: [BLOCKING] Push Drizzle schema to live MariaDB</name>
|
|
<files>apps/api (drizzle-kit push — no source file)</files>
|
|
<read_first>
|
|
- apps/api/src/db/schema.ts (the columns added in Task 1 that must reach the live DB)
|
|
- apps/api/drizzle.config.ts (push target / credentials config)
|
|
- .planning/phases/02-calendar-display/02-RESEARCH.md §"Environment Availability" (MariaDB Docker confirmed up)
|
|
</read_first>
|
|
<action>
|
|
Run `npx drizzle-kit push` in `apps/api` to apply the `has_rrule`, `idx_calendar_events_has_rrule`, and `calendars.is_shared` schema changes to the live MariaDB. This is MANDATORY and BLOCKING: type checks and builds pass without it (types come from the Drizzle config, not the live DB), so skipping it produces a false-positive verification state where the windowed query in Plan 02 fails at runtime with "unknown column has_rrule". The MariaDB container must be running (Phase 1 confirmed it up with 503 cached events). If drizzle-kit push emits an interactive confirmation prompt that cannot be auto-confirmed, stop and surface it — do not guess answers to destructive prompts.
|
|
</action>
|
|
<verify>
|
|
<automated>cd apps/api && node -e "const m=require('mysql2/promise');(async()=>{const c=await m.createConnection(process.env.DATABASE_URL);const [r]=await c.query('SHOW COLUMNS FROM calendar_events LIKE \'has_rrule\'');const [s]=await c.query('SHOW COLUMNS FROM calendars LIKE \'is_shared\'');if(r.length&&s.length){console.log('PUSH_OK')}else{process.exit(1)};await c.end()})()"</automated>
|
|
</verify>
|
|
<acceptance_criteria>
|
|
- `SHOW COLUMNS FROM calendar_events LIKE 'has_rrule'` returns one row against the live MariaDB
|
|
- `SHOW COLUMNS FROM calendars LIKE 'is_shared'` returns one row against the live MariaDB
|
|
- drizzle-kit push completed without destructive data loss on the existing 503-event cache
|
|
</acceptance_criteria>
|
|
<done>has_rrule (+ index) and calendars.is_shared exist in the live MariaDB schema; existing event cache intact.</done>
|
|
</task>
|
|
|
|
</tasks>
|
|
|
|
<threat_model>
|
|
## Trust Boundaries
|
|
|
|
| Boundary | Description |
|
|
|----------|-------------|
|
|
| browser → /api/* | OIDC-gated; dev-auth bypass replaces the gate in dev only |
|
|
| CI/prod env → app config | DEV_AUTH_BYPASS env var could leak into production |
|
|
|
|
## STRIDE Threat Register
|
|
|
|
| Threat ID | Category | Component | Disposition | Mitigation Plan |
|
|
|-----------|----------|-----------|-------------|-----------------|
|
|
| T-02-01 | Elevation of privilege | devAuthBypass() | mitigate | Hard `NODE_ENV === 'production'` guard as the FIRST conditional, before reading DEV_AUTH_BYPASS; .env.example warning; prod compose must not set the flag (Pitfall 7) |
|
|
| T-02-02 | Tampering | drizzle-kit push | accept | Local dev DB; push reviewed; no untrusted input. Operator runs push against own MariaDB |
|
|
| T-02-SC | Tampering | pnpm installs (vitest, @testing-library/*, jsdom) | mitigate | All packages are mainstream, audited in RESEARCH §Package Legitimacy (Approved); no [ASSUMED]/[SUS] packages in this plan |
|
|
</threat_model>
|
|
|
|
<verification>
|
|
- `pnpm --filter @familysync/api test` runs (devBypass test green; expand/events stubs RED as designed)
|
|
- `pnpm --filter @familysync/pwa test` runner executes under jsdom
|
|
- Live MariaDB has has_rrule + is_shared columns
|
|
- `tsc --noEmit` clean in apps/api after schema edits
|
|
</verification>
|
|
|
|
<success_criteria>
|
|
- Schema columns added, pushed, and verified against the live DB
|
|
- PWA test runner operational
|
|
- Dev-auth bypass green with production hard guard
|
|
- ICS fixtures parse; RED stubs in place for later waves with concrete DST + calendarId-routing contracts
|
|
</success_criteria>
|
|
|
|
<artifacts_produced>
|
|
## Artifacts this phase produces (Plan 01)
|
|
|
|
New symbols/files created here (exclude from drift verification):
|
|
- `calendar_events.hasRrule` Drizzle column + `idx_calendar_events_has_rrule` index
|
|
- `calendars.isShared` Drizzle column
|
|
- `devAuthBypass` (function) — apps/api/src/auth/devBypass.ts
|
|
- `DEV_USER` (const, internal to devBypass.ts)
|
|
- apps/pwa/vitest.config.ts (new)
|
|
- apps/pwa `test` npm script + vitest/@testing-library/jsdom devDependencies
|
|
- apps/api/tests/fixtures/{weekly-dst,allday-birthday,exdate-series}.ics
|
|
- apps/api/tests/broker/expand.test.ts, apps/api/tests/routes/events.test.ts, apps/api/tests/auth/devBypass.test.ts (new test files)
|
|
- apps/pwa/src/lib/hydrateEvents.test.ts, apps/pwa/src/lib/calendarConfig.test.ts (new test files)
|
|
</artifacts_produced>
|
|
|
|
<output>
|
|
Create `.planning/phases/02-calendar-display/02-01-SUMMARY.md` when done
|
|
</output>
|