feat: calendar subscription backend / ICS feed (Slice 4A of calendar sync payload) #246

Merged
owlburtoe merged 4 commits from feat/calendar-sync-slice-4a-ics-feed into main 2026-05-28 18:50:26 -04:00
Owner

Slice 4A of the Calendar Sync Payload enrichment (spec: docs/superpowers/specs/2026-05-28-calendar-sync-payload-design.md, Section C). Token-authenticated ICS subscription feed plus the dept-scoped endpoints that mint/rotate the token. Builds on merged Slices 1 (#225 facility address), 2 (#242 tags), 3 (#244 iOS). Backend + docs only; no migration-ordering clash with concurrent work (0055 is the only new migration).

What it adds

  • Schema: staff.calendar_feed_token varchar(64) UNIQUE NULL + migration 0055.
  • Token model (repositories.ts): getOrCreateCalendarFeedToken (atomic lazy mint — concurrent first reads converge on one token via WHERE … IS NULL + row lock), rotateCalendarFeedToken (immediate invalidation), resolveCalendarFeed (staff+dept+facility join, 404 on miss/inactive/broken).
  • Subscription endpoints (dept-scoped, self-service): GET /api/d/{deptSlug}/staff/me/calendar-subscription (lazy mint → { url }) and POST …/regenerate (rotate, audited calendar_feed.token_rotated with no token value).
  • Public feed: GET /api/calendar/:token.ics — mounted before auth middleware, resolves everything from the token, builds today..+180 (dept tz) excluding declined, reuses Slice 2's getUpcomingAssignmentsForStaff (tags + frozen timing + status) and Slice 1's facility address for LOCATION. Headers per spec; rate-limited (misses by IP via skipSuccessfulRequests, hits by token).
  • Renderer (lib/calendar/ics.ts, pure): RFC 5545 escaping + 75-octet folding, formatCalendarTitle/Notes parity-locked to the shared fixture (same file the iOS formatters use), VEVENT with overnight roll and all-day VALUE=DATE.
  • URL helper buildCalendarFeedUrl (no hand-concat; URL guard passes). OpenAPI + SECURITY.md.

VTIMEZONE decision (spec Risk 1, resolved)

Hand-rolled ICS with floating local time (DTSTART:YYYYMMDDTHHMMSS, no Z/TZID). No new dependency; preserves the facility-authored wall-clock for the common in-facility subscriber and avoids the UTC→wrong-wall-clock failure mode. A hand-rolled VTIMEZONE was avoided as a footgun; absolute-instant semantics for cross-tz travel are out of v1 scope. Documented in SECURITY.md + the spec.

Test plan

  • Full backend DB suite: 1502/1502 passing (136 files, 0 failures) — +32 vs pre-slice
  • ICS renderer units + shared-fixture parity: 23
  • Subscription + feed integration: 6 (lazy-mint idempotency, rotate invalidation, audit-has-no-token, valid VCALENDAR, status mapping, declined exclusion, 404 cases, deactivated-staff 404)
  • tsc --noEmit, check:runtime-contracts, check:migration-ordering, check:url-generation, smoke:backend-runtime all pass

Security

Token is a bearer credential replacing auth; URL/proxy-log exposure accepted v1 (GitHub/GitLab ICS model), never logged by the app, never in audit metadata. 404 (no detail) on all misses. Feed carries only shift/schedule/tags/time/address — no coworker or contact PII.

Follow-ups

4B (web subscription UI) and 4C (iOS subscription UI) depend on this.

Slice 4A of the Calendar Sync Payload enrichment (spec: docs/superpowers/specs/2026-05-28-calendar-sync-payload-design.md, Section C). Token-authenticated ICS subscription feed plus the dept-scoped endpoints that mint/rotate the token. Builds on merged Slices 1 (#225 facility address), 2 (#242 tags), 3 (#244 iOS). Backend + docs only; no migration-ordering clash with concurrent work (0055 is the only new migration). ## What it adds - **Schema**: `staff.calendar_feed_token varchar(64) UNIQUE NULL` + migration `0055`. - **Token model** (`repositories.ts`): `getOrCreateCalendarFeedToken` (atomic lazy mint — concurrent first reads converge on one token via `WHERE … IS NULL` + row lock), `rotateCalendarFeedToken` (immediate invalidation), `resolveCalendarFeed` (staff+dept+facility join, 404 on miss/inactive/broken). - **Subscription endpoints** (dept-scoped, self-service): `GET /api/d/{deptSlug}/staff/me/calendar-subscription` (lazy mint → `{ url }`) and `POST …/regenerate` (rotate, audited `calendar_feed.token_rotated` with **no token value**). - **Public feed**: `GET /api/calendar/:token.ics` — mounted before auth middleware, resolves everything from the token, builds today..+180 (dept tz) excluding declined, reuses Slice 2's `getUpcomingAssignmentsForStaff` (tags + frozen timing + status) and Slice 1's facility address for `LOCATION`. Headers per spec; rate-limited (misses by IP via `skipSuccessfulRequests`, hits by token). - **Renderer** (`lib/calendar/ics.ts`, pure): RFC 5545 escaping + 75-octet folding, `formatCalendarTitle/Notes` parity-locked to the shared fixture (same file the iOS formatters use), VEVENT with overnight roll and all-day `VALUE=DATE`. - **URL helper** `buildCalendarFeedUrl` (no hand-concat; URL guard passes). OpenAPI + SECURITY.md. ## VTIMEZONE decision (spec Risk 1, resolved) Hand-rolled ICS with **floating local time** (`DTSTART:YYYYMMDDTHHMMSS`, no `Z`/`TZID`). No new dependency; preserves the facility-authored wall-clock for the common in-facility subscriber and avoids the UTC→wrong-wall-clock failure mode. A hand-rolled VTIMEZONE was avoided as a footgun; absolute-instant semantics for cross-tz travel are out of v1 scope. Documented in SECURITY.md + the spec. ## Test plan - [x] Full backend DB suite: **1502/1502** passing (136 files, 0 failures) — +32 vs pre-slice - [x] ICS renderer units + shared-fixture parity: 23 - [x] Subscription + feed integration: 6 (lazy-mint idempotency, rotate invalidation, audit-has-no-token, valid VCALENDAR, status mapping, declined exclusion, 404 cases, deactivated-staff 404) - [x] `tsc --noEmit`, `check:runtime-contracts`, `check:migration-ordering`, `check:url-generation`, `smoke:backend-runtime` all pass ## Security Token is a bearer credential replacing auth; URL/proxy-log exposure accepted v1 (GitHub/GitLab ICS model), never logged by the app, never in audit metadata. 404 (no detail) on all misses. Feed carries only shift/schedule/tags/time/address — no coworker or contact PII. ## Follow-ups 4B (web subscription UI) and 4C (iOS subscription UI) depend on this.
Nullable unique varchar(64) on staff for the ICS subscription bearer token.
formatCalendarTitle/Notes (parity-locked to the shared fixture), RFC 5545
escaping + 75-octet folding, and renderCalendarFeed with floating local-time
VEVENTs (overnight roll, all-day VALUE=DATE). VTIMEZONE deliberately avoided
(spec Risk 1) — floating preserves facility wall-clock without the UTC footgun.
Atomic lazy token mint + rotate (repositories), dept-scoped GET/regenerate
subscription endpoints (audited as calendar_feed.token_rotated, no token value),
and public GET /api/calendar/:token.ics (404 on any miss, today..+180, declined
excluded, facility LOCATION, rate-limited misses-by-IP/hits-by-token). Adds
buildCalendarFeedUrl helper. Slice 4A of the calendar sync payload spec.
docs(openapi,security): calendar subscription + ICS feed surface
All checks were successful
Code Scanning / Gitleaks secret scan (pull_request) Successful in 6s
Code Scanning / Semgrep OSS source scan (pull_request) Successful in 35s
Security, Type Check & Runtime / Dependency Audit (pull_request) Successful in 9m38s
Security, Type Check & Runtime / Type Check (pull_request) Successful in 10m11s
Security, Type Check & Runtime / Backend Runtime Smoke (pull_request) Successful in 10m9s
Release Artifacts / Validate release candidate (pull_request) Successful in 10m49s
Release Artifacts / Build and push Docker release images (pull_request) Has been skipped
E2E Tests / e2e (pull_request) Successful in 14m27s
Security, Type Check & Runtime / Migration Guardrails (pull_request) Successful in 9m35s
5912e90b2f
Three operations + CalendarSubscription schema; SECURITY.md section on the
feed token threat model (URL/log exposure accepted v1, no token in audit) and
the floating-time decision.
Sign in to join this conversation.
No description provided.