docs: correct rrule drift (code uses ical.js RecurExpansion) + minor fixes
Publish / publish (push) Successful in 11s

Recurrence expansion uses ical.js ICAL.RecurExpansion, not the rrule
library (never installed/imported). Also fix playwright-cli path
(/usr/bin), prod-compose service count (two, post-Redis-removal), and
CI job count (six). Found via /gsd-docs-update --verify-only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Lucas Berger
2026-06-18 22:29:03 -04:00
co-authored by Claude Opus 4.8
parent c7955a46b9
commit 6ade8d59bf
6 changed files with 31 additions and 31 deletions
+7 -7
View File
@@ -42,10 +42,10 @@ FamilySync is a self-hosted, Dockerized family organization hub for a two-person
### Supporting Libraries ### Supporting Libraries
| Library | Version | Purpose | When to Use | | Library | Version | Purpose | When to Use |
| --------------------- | ------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | --------------------- | ------- | -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| tsdav | 2.2.2 | CalDAV client for Node.js | All calendar reads and writes against Fastmail CalDAV endpoint; handles PROPFIND, REPORT, PUT, DELETE | | tsdav | 2.2.2 | CalDAV client for Node.js | All calendar reads and writes against Fastmail CalDAV endpoint; handles PROPFIND, REPORT, PUT, DELETE |
| ical.js | 2.2.1 | iCalendar (.ics) parsing | Parse raw VCALENDAR/VEVENT payloads returned by tsdav; handles VTIMEZONE, RDATE, EXDATE | | ical.js | 2.2.1 | iCalendar (.ics) parsing | Parse raw VCALENDAR/VEVENT payloads returned by tsdav; handles VTIMEZONE, RDATE, EXDATE |
| rrule | 2.8.1 | Recurrence rule expansion | Expand RRULE strings into concrete event occurrences for the calendar view; ical.js's built-in expansion is less ergonomic for UI consumption | | ~~rrule~~ (not used) | — | Recurrence rule expansion | **Evaluated but not adopted.** `ical.js`'s built-in `ICAL.RecurExpansion` (in `apps/api/src/broker/expand.ts`) handles RRULE/RDATE/EXDATE expansion server-side; no separate `rrule` dependency is installed |
| web-push | 3.6.7 | Server-side VAPID push | Generate VAPID keys, sign and dispatch push messages to browser push services (APNs for iOS, FCM for Android) | | web-push | 3.6.7 | Server-side VAPID push | Generate VAPID keys, sign and dispatch push messages to browser push services (APNs for iOS, FCM for Android) |
| @hono/oidc-auth | 1.8.3 | OIDC session middleware for Hono | Storage-less JWT session cookies; authorization-code + PKCE flow; works with any RFC-compliant OIDC provider including Authelia | | @hono/oidc-auth | 1.8.3 | OIDC session middleware for Hono | Storage-less JWT session cookies; authorization-code + PKCE flow; works with any RFC-compliant OIDC provider including Authelia |
| openid-client | 6.8.4 | Low-level OIDC primitives | If `@hono/oidc-auth` proves insufficient (e.g., custom token introspection), use this as the lower-level escape hatch | | openid-client | 6.8.4 | Low-level OIDC primitives | If `@hono/oidc-auth` proves insufficient (e.g., custom token introspection), use this as the lower-level escape hatch |
@@ -77,7 +77,7 @@ FamilySync is a self-hosted, Dockerized family organization hub for a two-person
- Principal URL: `https://caldav.fastmail.com/dav/principals/user/broker@fastmail.com/` - Principal URL: `https://caldav.fastmail.com/dav/principals/user/broker@fastmail.com/`
- `tsdav` performs `PROPFIND` on the principal to discover all calendar collections, then fetches each collection's events via `REPORT` (calendar-query or calendar-multiget). - `tsdav` performs `PROPFIND` on the principal to discover all calendar collections, then fetches each collection's events via `REPORT` (calendar-query or calendar-multiget).
- One app password covers all calendars owned by that account under the default "Mail, Contacts & Calendars" scope. - One app password covers all calendars owned by that account under the default "Mail, Contacts & Calendars" scope.
- `tsdav` returns raw iCalendar strings. Pass each to `ical.js` for parsing into event objects, then use `rrule` for RRULE expansion into the date range the UI needs. - `tsdav` returns raw iCalendar strings. Pass each to `ical.js` for parsing into event objects, then use `ical.js`'s `ICAL.RecurExpansion` for RRULE/RDATE/EXDATE expansion into the date range the UI needs.
- Write-back (create/edit/delete): PUT a new `.ics` to the collection URL; DELETE by UID. - Write-back (create/edit/delete): PUT a new `.ics` to the collection URL; DELETE by UID.
- `node-ical`: older fork with weaker RRULE support; ical.js is maintained by Mozilla and is the reference implementation - `node-ical`: older fork with weaker RRULE support; ical.js is maintained by Mozilla and is the reference implementation
- Direct `fetch`/`axios` against CalDAV: re-inventing XML namespace handling and PROPFIND parsing; tsdav exists specifically to avoid this - Direct `fetch`/`axios` against CalDAV: re-inventing XML namespace handling and PROPFIND parsing; tsdav exists specifically to avoid this
@@ -146,11 +146,11 @@ FamilySync is a self-hosted, Dockerized family organization hub for a two-person
## Version Compatibility ## Version Compatibility
| Package | Compatible With | Notes | | Package | Compatible With | Notes |
| --------------------- | ---------------------- | ------------------------------------------------------------------------------------------ | | --------------------- | ---------------------- | ------------------------------------------------------------------------------------------- |
| drizzle-orm@0.45.x | mysql2@3.x | Use `drizzle-orm/mysql2` import path; mysql2@3.x uses Promises API by default | | drizzle-orm@0.45.x | mysql2@3.x | Use `drizzle-orm/mysql2` import path; mysql2@3.x uses Promises API by default |
| vite-plugin-pwa@1.3.x | Vite@8.x, Workbox@7.x | vite-plugin-pwa 0.16+ requires Node 16+; 1.x tracks Vite 6+ | | vite-plugin-pwa@1.3.x | Vite@8.x, Workbox@7.x | vite-plugin-pwa 0.16+ requires Node 16+; 1.x tracks Vite 6+ |
| @hono/oidc-auth@1.8.x | hono@4.x, oauth4webapi | Peer-depends on hono 4.x | | @hono/oidc-auth@1.8.x | hono@4.x, oauth4webapi | Peer-depends on hono 4.x |
| ical.js@2.x | rrule@2.8.x | Use together: ical.js parses the RRULE string, pass to `new RRule(RRule.parseString(...))` | | ical.js@2.x | — | Handles RRULE/RDATE/EXDATE expansion alone via `ICAL.RecurExpansion`; no `rrule` dependency |
| web-push@3.6.x | Node.js 18+ | VAPID uses Web Crypto; works in Node.js 18+ natively | | web-push@3.6.x | Node.js 18+ | VAPID uses Web Crypto; works in Node.js 18+ natively |
## Open Questions Flagged for Phase Research ## Open Questions Flagged for Phase Research
@@ -183,7 +183,7 @@ 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/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 -->
@@ -195,7 +195,7 @@ FamilySync is a pnpm monorepo with two apps: `apps/api` (Hono 4.x on Node 22 LTS
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. 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. Calendar data lives exclusively in Fastmail CalDAV. The broker layer (`apps/api/src/broker/`) uses `tsdav` for PROPFIND/REPORT/PUT/DELETE and `ical.js` for VCALENDAR parsing plus server-side recurrence expansion (`ICAL.RecurExpansion`). 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. 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.
+1 -1
View File
@@ -385,7 +385,7 @@ Calendar data is read from a MariaDB cache populated by the CalDAV broker poller
### `GET /api/events?start=YYYY-MM-DD&end=YYYY-MM-DD` ### `GET /api/events?start=YYYY-MM-DD&end=YYYY-MM-DD`
Returns a flat array of concrete event occurrences for the given date window. Recurring events are expanded server-side via `rrule`. The window is capped at 90 days. Returns events from calendars the member owns plus any shared (family) calendars. Returns a flat array of concrete event occurrences for the given date window. Recurring events are expanded server-side via `ical.js` (`ICAL.RecurExpansion`). The window is capped at 90 days. Returns events from calendars the member owns plus any shared (family) calendars.
**Query parameters** **Query parameters**
+4 -4
View File
@@ -20,7 +20,7 @@ graph TD
subgraph "API (apps/api — Hono on Node 22)" subgraph "API (apps/api — Hono on Node 22)"
AUTH["Auth Layer\n(local session + OIDC middleware)"] AUTH["Auth Layer\n(local session + OIDC middleware)"]
ROUTES["API Routes\n/events /lists /me /push /sse\n/admin /setup /auth"] 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)"]
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)"]
REMINDER["Reminder Scheduler\n(1-min setInterval)"] REMINDER["Reminder Scheduler\n(1-min setInterval)"]
@@ -108,7 +108,7 @@ familysync/
| `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`, `localCredentials`, `calendars`, `calendarEvents`, `calendarOutbox`, `lists`, `listShares`, `listItems`, `pushSubscriptions`, `appConfig`) | | 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` (`ICAL.RecurExpansion`); 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 |
@@ -132,7 +132,7 @@ familysync/
1. On mount, the PWA's `CalendarShell` computes a date window and fires `fetchEvents(start, end)` via TanStack Query. 1. On mount, the PWA's `CalendarShell` computes a date window and fires `fetchEvents(start, end)` via TanStack Query.
2. `GET /api/events?start=&end=` hits the Hono `eventsRouter`. 2. `GET /api/events?start=&end=` hits the Hono `eventsRouter`.
3. The route queries `calendarEvents` + `calendars` from MariaDB, filtering by the authenticated user's accessible calendars. 3. The route queries `calendarEvents` + `calendars` from MariaDB, filtering by the authenticated user's accessible calendars.
4. Raw `rawVevent` blobs are passed through `expandOccurrences()` (ical.js + rrule) to produce concrete `CalendarOccurrence` objects for the window. 4. Raw `rawVevent` blobs are passed through `expandOccurrences()` (ical.js `ICAL.RecurExpansion`) to produce concrete `CalendarOccurrence` objects for the window.
5. The JSON response is cached by TanStack Query; Schedule-X renders the events. 5. The JSON response is cached by TanStack Query; Schedule-X renders the events.
### Calendar write (create/edit/delete) ### Calendar write (create/edit/delete)
@@ -243,7 +243,7 @@ routes/setup.ts ──→ db (app_config)
| Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE; optional when local auth is enabled | | Auth IdP | Authelia (pre-deployed, external) — OIDC authorization code + PKCE; optional when local auth is enabled |
| Session middleware | `@hono/oidc-auth` (OIDC session — storage-less signed JWT cookies) + custom `localSession.ts` (local-auth HS256 JWT cookie) | | 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 expansion via `ICAL.RecurExpansion`) |
| 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 | | 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) |
+1 -1
View File
@@ -203,7 +203,7 @@ Every PR to `main` runs through `.gitea/workflows/ci.yml`. A `changes` path-filt
| `security` | Every PR | Gitleaks secret scan (PR diff); `pnpm audit` (High+Critical blocking) + outdated report on code-change PRs | | `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 | | `gate` | Always | Final aggregator — requires `fast-checks` and `security` to succeed; `api` and `harness` may be skipped |
All five jobs must pass (or be legitimately skipped) before a PR can merge. See [docs/TESTING.md](TESTING.md) for test suite details. All six jobs must pass (or be legitimately skipped) before a PR can merge. See [docs/TESTING.md](TESTING.md) for test suite details.
### CI dependency caches ### CI dependency caches
+1 -1
View File
@@ -199,7 +199,7 @@ No coverage thresholds are configured in either `vitest.config.ts`. There is no
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. 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. Six jobs run in total — `changes`, `fast-checks`, `security`, and `gate` always run; `api` and `harness` run conditionally (skipped on doc-only PRs).
### `fast-checks` ### `fast-checks`
+1 -1
View File
@@ -14,7 +14,7 @@ Self-hosted Docker deployment on Unraid behind Authelia OIDC and a Pangolin/Newt
| Docker Compose (dev override) | `docker-compose.dev.yml` | | Docker Compose (dev override) | `docker-compose.dev.yml` |
| Container image | `apps/api/Dockerfile` (multi-stage, built from repo root) | | Container image | `apps/api/Dockerfile` (multi-stage, built from repo root) |
The production compose file brings up three services: The production compose file brings up two services:
| Service | Image | Purpose | | Service | Image | Purpose |
| --------- | ---------------------------------------------------- | --------------------------------------------------- | | --------- | ---------------------------------------------------- | --------------------------------------------------- |