Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
54 KiB
Phase 1: Foundation + Broker Spike — Research
Researched: 2026-06-04 Domain: Monorepo scaffold + MariaDB/Drizzle schema + Authelia OIDC (@hono/oidc-auth) + CalDAV broker (tsdav) + AES-GCM credential encryption + Pangolin SSE smoke test Confidence: HIGH on stack and patterns (all packages verified on npm with authoritative docs); MEDIUM on Pangolin SSE pass-through (issue #1034 closed/unresolved, SSE behavior untested); LOW on exact Fastmail ctag/syncToken field population (needs real-account spike)
<user_constraints>
User Constraints (from CONTEXT.md)
Locked Decisions
- D-01: Wife's personal calendar is Fastmail-hosted (not iCloud). Broker can reach it via CalDAV. Eliminates the "unreachable iCloud" risk entirely.
- D-02: Broker access = per-member app passwords (NOT cross-account share+accept). Each member generates their own Fastmail app password; broker holds N credentials and reads each account directly.
- D-03: Phase 1 uses ONLY Lucas's app password. Wife's app password added in Phase 2.
- D-04: App passwords stored encrypted-at-rest in DB table, keyed by
oidc_sub, encryption key from env. Backend-only — never exposed to frontend. - D-05: Expected outcome = GO. Fallback (if app password cannot read personal calendar) = shared-family-only for v1.
- D-06: Color auto-assigned from curated palette on first login, persisted on user row (oidc_iss + oidc_sub). Not user-pickable in Phase 1.
- D-07: Deploy through real Pangolin tunnel + Authelia from day one.
- D-08: Fold in Pangolin SSE pass-through smoke test (trivial long-lived SSE endpoint over public URL).
- D-09: CalDAV-only via tsdav; principal URL =
https://caldav.fastmail.com/dav/principals/user/{email}/ - D-10: Identity =
oidc_iss + oidc_subcomposite key, never email. - D-11: Skip Authelia
groupsclaim. - D-12: Backend confidential client holds refresh token; no iframe silent renewal.
- D-13: Calendar cache: raw VEVENT blob + dtstart_utc; all-day as DATE never coerced to UTC; sync-token with ctag-poll fallback from day one.
Claude's Discretion
- Phase 1 landing page: thin authenticated shell showing the one cached event as broker proof
- Color palette: visually-distinct, accessible hues assigned round-robin by join order
- Broker internals: sync-token vs ctag detection, poll interval (5-min), Drizzle schema specifics, OIDC middleware wiring, encryption helper implementation
Deferred Ideas (OUT OF SCOPE)
- User-pickable color picker (settings UI)
- Single-token broker via Fastmail share+accept
- Wife's app password onboarding flow (Phase 2) </user_constraints>
<phase_requirements>
Phase Requirements
| ID | Description | Research Support |
|---|---|---|
| AUTH-01 | User can log in through Authelia OIDC SSO — no separate FamilySync account | @hono/oidc-auth v1.8.3 handles full authorization-code+PKCE flow; Authelia client YAML documented |
| AUTH-02 | User stays logged in across sessions (persistent session) | @hono/oidc-auth refresh-token rotation: middleware refreshes session automatically every 15 min using stored refresh token |
| AUTH-03 | Each member maps to stable identity (oidc_iss+sub) and gets consistent per-member color | mysqlTable users schema with oidc_iss+oidc_sub composite key; color column with auto-assign logic on INSERT |
| CAL-01 | App reads shared Fastmail calendar via CalDAV broker and caches locally (ctag polling) | tsdav createDAVClient → fetchCalendars → fetchCalendarObjects; ctag/syncToken fields on DAVCalendar; drizzle schema for calendar_events cache |
| CAL-08 | Spike: confirm per-member app password reads own account's shared+personal calendars via PROPFIND/REPORT | tsdav with Basic auth against caldav.fastmail.com; spike proves N-credential broker model; go/no-go documented |
| </phase_requirements> |
Summary
Phase 1 is a walking skeleton: stand up the Docker Compose stack (Node 22 + Hono API, Vite/React 19 PWA, MariaDB, Redis), wire Authelia OIDC via @hono/oidc-auth, scaffold the Drizzle/MariaDB schema, and deliver a CalDAV broker that reads and caches at least one real event from Fastmail — all deployed through the real Pangolin tunnel.
Every library in the stack is confirmed on npm at the versions specified in CLAUDE.md. The one version drift to flag: zod latest is 4.4.3 but @hono/zod-validator@0.8.0 peer deps accept ^3.25.0 || ^4.0.0, so either v3 or v4 works. Pin zod@3.25.x for conservative compatibility with any other tooling that hasn't yet declared v4 support, unless there's a specific v4 feature needed.
The Pangolin SSE smoke test is structurally the right call: GitHub issue #1034 shows WebSocket upgrade reliably fails through Pangolin (closed as "not planned"), and there is no documented SSE-specific failure. SSE is more proxy-resilient by design (plain HTTP, no Upgrade header), so the smoke test is expected to pass — but must be confirmed over the real tunnel before Phase 4 real-time sync is built.
Primary recommendation: Build in order — infra scaffold → Drizzle schema + migrations → Authelia OIDC wiring → CalDAV broker spike → landing page UI with cached event display → SSE smoke test endpoint. Each layer depends on the previous.
Architectural Responsibility Map
| Capability | Primary Tier | Secondary Tier | Rationale |
|---|---|---|---|
| OIDC auth flow (redirect, code exchange, session cookie) | API / Backend | — | Backend confidential client holds client_secret and refresh token; D-12 locks this |
| Session persistence (refresh token rotation) | API / Backend | — | @hono/oidc-auth manages refresh automatically in middleware; no browser iframe |
| User identity upsert (oidc_iss+sub → users row) | API / Backend | — | Happens on first authenticated request; DB write at API layer |
| Per-member color assignment | API / Backend | — | Auto-assign on INSERT, persisted in users table, returned to frontend via /api/me |
| CalDAV broker (PROPFIND, REPORT, ctag polling) | API / Backend | — | Broker is a module boundary; Fastmail credentials never reach frontend |
| App-password encryption/decryption | API / Backend | — | AES-GCM with key from env; purely server-side |
| Calendar event cache (MariaDB) | Database / Storage | — | Raw VEVENT blob + dtstart_utc; broker writes, API reads |
| SSE smoke test endpoint | API / Backend | — | Long-lived GET /api/sse/heartbeat; proves Pangolin pass-through |
| Landing page shell (authenticated UI) | Browser / Client | Frontend served by API or nginx | Thin React component; TanStack Query fetches /api/me + /api/events |
| Static asset serving | CDN / Static | API / Backend | Vite build served from same Docker container or separate nginx |
Standard Stack
Core (Phase 1 scope)
| Library | Version | Purpose | Verified |
|---|---|---|---|
| hono | 4.12.23 | HTTP framework | [VERIFIED: npm registry] |
| @hono/node-server | 2.0.4 | Node.js adapter for Hono | [VERIFIED: npm registry] |
| @hono/oidc-auth | 1.8.3 | OIDC session middleware | [VERIFIED: npm registry] |
| @hono/zod-validator | 0.8.0 | Zod validation middleware | [VERIFIED: npm registry] |
| drizzle-orm | 0.45.2 | MariaDB ORM | [VERIFIED: npm registry] |
| drizzle-kit | 0.31.10 | Schema migrations | [VERIFIED: npm registry] |
| mysql2 | 3.22.4 | MariaDB driver | [VERIFIED: npm registry] |
| tsdav | 2.2.2 | CalDAV client | [VERIFIED: npm registry] |
| ical.js | 2.2.1 | iCalendar parser | [VERIFIED: npm registry] |
| zod | 3.25.76 (pin 3.25.x) | Schema validation | [VERIFIED: npm registry] |
| node-cron | 4.2.1 | Background polling scheduler | [VERIFIED: npm registry] |
| react | 19.x | PWA frontend | [ASSUMED] |
| vite | 8.0.16 | Build tooling | [VERIFIED: npm registry] |
| @tanstack/react-query | 5.101.0 | Server state management | [VERIFIED: npm registry] |
| zustand | 5.0.14 | UI state | [VERIFIED: npm registry] |
| vitest | 4.1.8 | Test runner | [VERIFIED: npm registry] |
Zod version note:
npm view zod versionreturns 4.4.3. CLAUDE.md specifies 3.24.x (stale).@hono/zod-validator@0.8.0peer deps accept^3.25.0 || ^4.0.0. Pinzod@3.25.xfor Phase 1 to avoid any unexpected v4 API differences — v3 is the conservative choice and fully supported. [VERIFIED: npm registry peer deps]
Phase 1 backend install
npm install hono @hono/node-server @hono/oidc-auth @hono/zod-validator
npm install drizzle-orm mysql2
npm install tsdav ical.js
npm install zod node-cron
npm install -D drizzle-kit vitest typescript @types/node
Phase 1 frontend install
npm install react react-dom @tanstack/react-query zustand
npm install -D vite @vitejs/plugin-react typescript
Package Legitimacy Audit
slopcheck was not installable in this environment. All packages are verified via npm registry AND have authoritative source repos (GitHub official maintainers or known organizations). No packages are flagged for removal.
| Package | Registry | Age | Source Repo | slopcheck | Disposition |
|---|---|---|---|---|---|
| hono | npm | 3+ yrs | github.com/honojs/hono | [OK — known major framework] | Approved |
| @hono/oidc-auth | npm | Active (updated 2026-06-01) | github.com/honojs/middleware | [OK — official Hono middleware monorepo] | Approved |
| @hono/node-server | npm | Active | github.com/honojs/node-server | [OK] | Approved |
| @hono/zod-validator | npm | Active | github.com/honojs/middleware | [OK] | Approved |
| drizzle-orm | npm | 2+ yrs | github.com/drizzle-team/drizzle-orm | [OK — known ORM] | Approved |
| drizzle-kit | npm | 2+ yrs | github.com/drizzle-team/drizzle-orm | [OK] | Approved |
| mysql2 | npm | 8+ yrs | github.com/sidorares/node-mysql2 | [OK — industry standard] | Approved |
| tsdav | npm | 3+ yrs (updated 2026-05-14) | github.com/natelindev/tsdav | [OK] | Approved |
| ical.js | npm | 10+ yrs (updated 2025-08-08) | github.com/kewisch/ical.js | [OK — Mozilla-maintained] | Approved |
| zod | npm | 4+ yrs | github.com/colinhacks/zod | [OK — industry standard] | Approved |
| node-cron | npm | 8+ yrs | github.com/merencia/node-cron | [OK] | Approved |
| vitest | npm | 3+ yrs | github.com/vitest-dev/vitest | [OK] | Approved |
| @tanstack/react-query | npm | 4+ yrs | github.com/TanStack/query | [OK] | Approved |
| zustand | npm | 4+ yrs | github.com/pmndrs/zustand | [OK] | Approved |
Packages removed due to [SLOP] verdict: none Packages flagged [SUS]: none
slopcheck was unavailable; manual provenance review performed against npm registry + known official GitHub repos. All packages have multi-year histories, large download counts, and verified source repositories.
Architecture Patterns
System Architecture Diagram
Browser (iOS/Android)
│ HTTPS via Pangolin tunnel
▼
Pangolin/Newt edge ──── Authelia OIDC
│ (already deployed; shares parent domain)
│
▼
┌─────────────────────────────────────────────┐
│ Docker Compose — Unraid │
│ │
│ ┌──────────────────────────────────────┐ │
│ │ Hono API (Node 22) │ │
│ │ │ │
│ │ oidcAuthMiddleware ──► user upsert │ │
│ │ │ │ │
│ │ GET /api/me GET /api/events │ │
│ │ GET /api/sse/heartbeat (smoke) │ │
│ │ │ │ │
│ │ ┌─────────────────────┐ │ │
│ │ │ Broker module │ │ │
│ │ │ createDAVClient() │ │ │
│ │ │ fetchCalendars() │ │ │
│ │ │ fetchCalendarObjects│ │ │
│ │ │ node-cron 5-min poll│ │ │
│ │ └──────────┬──────────┘ │ │
│ │ │ decrypt app-password │ │
│ │ ▼ │ │
│ │ Fastmail CalDAV ──────────────────►│ │
│ │ caldav.fastmail.com │ │
│ │ │ │ │
│ │ ▼ raw VEVENT blobs │ │
│ │ Drizzle ORM ──► MariaDB │ │
│ └──────────────────────────────────────┘ │
│ │
│ Vite/React 19 PWA (served as static) │
│ TanStack Query ◄──── /api/events │
│ Zustand (UI state only) │
│ │
└─────────────────────────────────────────────┘
Data flows: Browser authenticates → Authelia → @hono/oidc-auth creates JWT session cookie → user upserted in DB → /api/events reads from MariaDB cache → broker fetches from Fastmail in background every 5 min → landing page shows one cached event as proof.
Recommended Project Structure
familysync/
├── apps/
│ ├── api/
│ │ ├── src/
│ │ │ ├── auth/
│ │ │ │ ├── middleware.ts # oidcAuthMiddleware setup, user upsert
│ │ │ │ └── user.ts # DB lookup/insert, color assignment
│ │ │ ├── broker/
│ │ │ │ ├── client.ts # createDAVClient factory (per credential)
│ │ │ │ ├── poller.ts # node-cron schedule, ctag/syncToken compare
│ │ │ │ ├── sync.ts # fetchCalendarObjects → ical.js → DB upsert
│ │ │ │ └── crypto.ts # AES-GCM encrypt/decrypt app passwords
│ │ │ ├── db/
│ │ │ │ ├── schema.ts # Drizzle mysqlTable definitions
│ │ │ │ ├── client.ts # drizzle(mysql2 pool) singleton
│ │ │ │ └── migrations/ # drizzle-kit generated SQL
│ │ │ ├── routes/
│ │ │ │ ├── me.ts # GET /api/me → user + color
│ │ │ │ ├── events.ts # GET /api/events → cache query
│ │ │ │ └── sse.ts # GET /api/sse/heartbeat (smoke test)
│ │ │ └── index.ts # Hono app, serve(), middleware mount
│ │ ├── drizzle.config.ts
│ │ ├── Dockerfile
│ │ └── package.json
│ └── pwa/
│ ├── src/
│ │ ├── App.tsx # Auth-gated shell
│ │ ├── components/
│ │ │ └── EventProof.tsx # Displays one cached event
│ │ └── api/
│ │ └── client.ts # Typed fetch hooks
│ ├── index.html
│ ├── vite.config.ts
│ └── package.json
├── docker-compose.yml
├── docker-compose.dev.yml
├── .env.example
└── package.json # Workspace root
Pattern 1: @hono/oidc-auth Middleware Wiring
What: Mount oidcAuthMiddleware() before all protected routes. Use getAuth(c) to extract OIDC claims (sub, iss, email). On first authenticated request, upsert user row with oidc_iss + oidc_sub composite key.
Env vars required:
OIDC_AUTH_SECRET— 32+ char random string for JWT cookie signingOIDC_ISSUER—https://auth.yourdomain.com(Authelia's base URL; middleware fetches/.well-known/openid-configuration)OIDC_CLIENT_ID— registered client ID in AutheliaOIDC_CLIENT_SECRET— client secret (plain, not hashed — hashing is only in Authelia YAML)OIDC_REDIRECT_URI—https://familysync.yourdomain.com/callbackOIDC_AUTH_EXTERNAL_URL—https://familysync.yourdomain.com(critical behind Pangolin)
Code pattern:
// src/index.ts
// Source: https://github.com/honojs/middleware/tree/main/packages/oidc-auth
import { Hono } from 'hono'
import { oidcAuthMiddleware, getAuth, processOAuthCallback } from '@hono/oidc-auth'
const app = new Hono()
// Callback route BEFORE auth middleware
app.get('/callback', (c) => processOAuthCallback(c))
// Mount auth middleware on all /api routes
app.use('/api/*', oidcAuthMiddleware())
app.get('/api/me', async (c) => {
const auth = await getAuth(c)
// auth.sub = oidc_sub, auth.iss = oidc_iss, auth.email = display hint
const user = await upsertUser(auth.iss, auth.sub, auth.email)
return c.json({ user })
})
Authelia client YAML (add to Authelia's configuration.yml):
identity_providers:
oidc:
clients:
- client_id: 'familysync'
client_secret: '$pbkdf2-sha512$...' # authelia crypto hash <secret>
redirect_uris:
- 'https://familysync.yourdomain.com/callback'
grant_types:
- 'authorization_code'
- 'refresh_token'
response_types:
- 'code'
require_pkce: true
pkce_challenge_method: 'S256'
token_endpoint_auth_method: 'client_secret_basic'
scopes:
- 'openid'
- 'profile'
- 'email'
# No 'groups' scope — D-11
Session persistence: @hono/oidc-auth stores the refresh token in the signed JWT cookie. Every 15 minutes (configurable via OIDC_AUTH_REFRESH_INTERVAL), the middleware calls the token endpoint with the refresh token. Session lives for OIDC_AUTH_EXPIRES (default 1 day). Users are not prompted to log in again unless the refresh token itself expires (controlled by Authelia's access_token_lifespan and refresh_token_lifespan). This satisfies AUTH-02 without any iframe logic. [VERIFIED: github.com/honojs/middleware]
Pattern 2: Drizzle/MariaDB Schema
Import path: drizzle-orm/mysql2 [VERIFIED: npm registry, drizzle-orm@0.45.2]
Schema for Phase 1 tables:
// src/db/schema.ts
// Source: https://orm.drizzle.team/docs/sql-schema-declaration
import { mysqlTable, varchar, text, int, date, timestamp, boolean, primaryKey, index } from 'drizzle-orm/mysql-core'
export const users = mysqlTable('users', {
id: int().primaryKey().autoincrement(),
oidcIss: varchar('oidc_iss', { length: 512 }).notNull(),
oidcSub: varchar('oidc_sub', { length: 256 }).notNull(),
displayName: varchar('display_name', { length: 256 }),
color: varchar('color', { length: 7 }).notNull(), // hex e.g. '#4A90D9'
createdAt: timestamp('created_at').defaultNow().notNull(),
}, (t) => [
// Composite unique key — identity is iss+sub, never email
{ name: 'uniq_oidc_identity', columns: [t.oidcIss, t.oidcSub] }
])
// Encrypted app-password credentials per member
export const memberCredentials = mysqlTable('member_credentials', {
id: int().primaryKey().autoincrement(),
userId: int('user_id').notNull().references(() => users.id, { onDelete: 'cascade' }),
// iv (hex) + ciphertext (hex) stored together as JSON or two columns
encryptedPassword: text('encrypted_password').notNull(), // JSON: {iv, ciphertext}
fastmailEmail: varchar('fastmail_email', { length: 256 }).notNull(),
createdAt: timestamp('created_at').defaultNow().notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
}, (t) => [
index('idx_user_id').on(t.userId)
])
// Calendar collections discovered via PROPFIND
export const calendars = mysqlTable('calendars', {
id: int().primaryKey().autoincrement(),
userId: int('user_id').notNull().references(() => users.id),
url: varchar('url', { length: 1024 }).notNull(),
displayName: varchar('display_name', { length: 256 }),
color: varchar('color', { length: 7 }),
ctag: varchar('ctag', { length: 512 }),
syncToken: varchar('sync_token', { length: 1024 }),
lastSyncedAt: timestamp('last_synced_at'),
}, (t) => [
index('idx_user_calendars').on(t.userId)
])
// Calendar event cache — raw VEVENT blob + indexed dtstart_utc
export const calendarEvents = mysqlTable('calendar_events', {
id: int().primaryKey().autoincrement(),
calendarId: int('calendar_id').notNull().references(() => calendars.id, { onDelete: 'cascade' }),
uid: varchar('uid', { length: 512 }).notNull(), // VEVENT UID — natural key
etag: varchar('etag', { length: 256 }),
rawVevent: text('raw_vevent').notNull(), // full VCALENDAR/VEVENT string
dtstartUtc: timestamp('dtstart_utc'), // NULL for all-day events
dtstartDate: date('dtstart_date'), // set for all-day events; NULL for timed
allDay: boolean('all_day').default(false).notNull(),
updatedAt: timestamp('updated_at').defaultNow().onUpdateNow(),
}, (t) => [
index('idx_dtstart_utc').on(t.dtstartUtc),
index('idx_dtstart_date').on(t.dtstartDate),
// uid is unique per calendar
{ name: 'uniq_calendar_uid', columns: [t.calendarId, t.uid] }
])
Key schema rules:
- All-day events:
dtstart_utc= NULL,dtstart_date=DATEcolumn,all_day= true — never coerceDATEtoDATETIME(Pitfall #3) - Timed events:
dtstart_utc=TIMESTAMP(MariaDB stores in UTC),dtstart_date= NULL uidis the CalDAV/iCalendar UID; use as idempotency key on upsertrawVeventstores the full VCALENDAR string so ical.js can parse timezone/RRULE correctly on read
drizzle.config.ts:
// Source: https://orm.drizzle.team/docs/kit-overview
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'mysql',
schema: './src/db/schema.ts',
out: './src/db/migrations',
dbCredentials: {
host: process.env.DB_HOST!,
user: process.env.DB_USER!,
password: process.env.DB_PASSWORD!,
database: process.env.DB_NAME!,
port: Number(process.env.DB_PORT ?? 3306),
},
})
Migration workflow:
npx drizzle-kit generate # generates SQL migration files
npx drizzle-kit migrate # applies migrations to MariaDB
For Docker startup: run drizzle-kit migrate as an entrypoint step or a separate init container to ensure schema is applied before the API starts.
DB client singleton:
// src/db/client.ts
import { drizzle } from 'drizzle-orm/mysql2'
import mysql from 'mysql2/promise'
import * as schema from './schema'
const pool = mysql.createPool({
host: process.env.DB_HOST,
user: process.env.DB_USER,
password: process.env.DB_PASSWORD,
database: process.env.DB_NAME,
waitForConnections: true,
connectionLimit: 10,
})
export const db = drizzle({ client: pool, schema, mode: 'default' })
Pattern 3: CalDAV Broker (tsdav)
What: createDAVClient with Fastmail principal URL + Basic auth (app password). fetchCalendars() does PROPFIND → returns array of DAVCalendar (with url, ctag, syncToken, displayName). fetchCalendarObjects() does REPORT calendar-query → returns raw VEVENT strings.
Fastmail-specific config:
serverUrl:https://caldav.fastmail.comcredentials.username: Fastmail emailcredentials.password: app password (decrypted at runtime)authMethod:'Basic'defaultAccountType:'caldav'
Pitfall #1: Do NOT use bare
https://caldav.fastmail.comas the only URL. tsdav's service discovery will call/.well-known/caldavwhich redirects to the principal. Confirm the redirected principal URL matcheshttps://caldav.fastmail.com/dav/principals/user/{email}/. [VERIFIED: CLAUDE.md + PITFALLS.md #1]
Code pattern:
// src/broker/client.ts
// Source: https://github.com/natelindev/tsdav
import { createDAVClient } from 'tsdav'
export async function createFastmailClient(email: string, appPassword: string) {
return createDAVClient({
serverUrl: 'https://caldav.fastmail.com',
credentials: {
username: email,
password: appPassword,
},
authMethod: 'Basic',
defaultAccountType: 'caldav',
})
}
Fetching calendars + ctag polling:
// src/broker/poller.ts
import { schedule } from 'node-cron'
import { createFastmailClient } from './client'
import { db } from '../db/client'
import { calendars, memberCredentials } from '../db/schema'
import { syncCalendar } from './sync'
import { decryptPassword } from './crypto'
import { eq } from 'drizzle-orm'
// Run every 5 minutes
schedule('*/5 * * * *', async () => {
const creds = await db.select().from(memberCredentials)
for (const cred of creds) {
const appPassword = decryptPassword(cred.encryptedPassword)
const client = await createFastmailClient(cred.fastmailEmail, appPassword)
const davCalendars = await client.fetchCalendars()
for (const davCal of davCalendars) {
const stored = await db.select()
.from(calendars)
.where(eq(calendars.url, davCal.url))
.limit(1)
// ctag-based change detection (D-13: try syncToken first, fall back to ctag)
const knownCtag = stored[0]?.ctag ?? null
const currentCtag = davCal.ctag ?? null
if (currentCtag && currentCtag === knownCtag) continue // no change
await syncCalendar(client, davCal, cred.userId)
}
}
})
Fetching events (REPORT):
// src/broker/sync.ts
import { DAVCalendar, DAVClient } from 'tsdav'
import ICAL from 'ical.js'
import { db } from '../db/client'
import { calendars, calendarEvents } from '../db/schema'
import { eq } from 'drizzle-orm'
export async function syncCalendar(client: DAVClient, davCal: DAVCalendar, userId: number) {
const objects = await client.fetchCalendarObjects({
calendar: davCal,
// No timeRange filter on initial sync — fetch all events for cache
})
// Upsert calendar row
const [cal] = await db.insert(calendars).values({
userId,
url: davCal.url,
displayName: davCal.displayName ?? '',
ctag: davCal.ctag ?? null,
syncToken: davCal.syncToken ?? null,
lastSyncedAt: new Date(),
}).onDuplicateKeyUpdate({
set: {
ctag: davCal.ctag ?? null,
syncToken: davCal.syncToken ?? null,
lastSyncedAt: new Date(),
}
})
// Parse and upsert each VEVENT
for (const obj of objects) {
if (!obj.data) continue
const parsed = ICAL.parse(obj.data)
const comp = new ICAL.Component(parsed)
const vevent = comp.getFirstSubcomponent('vevent')
if (!vevent) continue
const dtstart = vevent.getFirstPropertyValue('dtstart') as ICAL.Time
const uid = vevent.getFirstPropertyValue('uid') as string
const allDay = dtstart?.isDate ?? false
await db.insert(calendarEvents).values({
calendarId: cal.id, // requires knowing the DB calendar id
uid,
etag: obj.etag ?? null,
rawVevent: obj.data,
dtstartUtc: allDay ? null : dtstart?.toJSDate(),
dtstartDate: allDay ? dtstart?.toString().slice(0, 10) : null, // 'YYYY-MM-DD'
allDay,
}).onDuplicateKeyUpdate({
set: {
etag: obj.etag ?? null,
rawVevent: obj.data,
dtstartUtc: allDay ? null : dtstart?.toJSDate(),
dtstartDate: allDay ? dtstart?.toString().slice(0, 10) : null,
allDay,
updatedAt: new Date(),
}
})
}
}
CAL-08 spike: Run
createFastmailClient(lucas_email, lucas_app_password)→client.fetchCalendars()→ print all returneddavCal.urlanddavCal.displayName. Confirm: (1) shared family calendar appears, (2) Lucas's personal calendar appears. Document both URLs in the go/no-go record. [ASSUMED: exact field names on returned DAVCalendar — verify against real Fastmail PROPFIND response]
Pattern 4: AES-GCM App-Password Encryption
What: Node 22 node:crypto module provides createCipheriv/createDecipheriv with AES-256-GCM. Key from env as a 32-byte hex string. Store {iv, authTag, ciphertext} as JSON text in the DB.
// src/broker/crypto.ts
// Source: Node.js docs node:crypto — AES-GCM [ASSUMED pattern; verified Node 22 has crypto.subtle + node:crypto]
import { randomBytes, createCipheriv, createDecipheriv } from 'node:crypto'
const KEY_HEX = process.env.APP_PASSWORD_ENCRYPTION_KEY! // 64-char hex = 32 bytes
const KEY = Buffer.from(KEY_HEX, 'hex')
interface EncryptedPayload {
iv: string
authTag: string
ciphertext: string
}
export function encryptPassword(plaintext: string): string {
const iv = randomBytes(12) // 96-bit IV for GCM
const cipher = createCipheriv('aes-256-gcm', KEY, iv)
const encrypted = Buffer.concat([
cipher.update(plaintext, 'utf8'),
cipher.final(),
])
const authTag = cipher.getAuthTag()
const payload: EncryptedPayload = {
iv: iv.toString('hex'),
authTag: authTag.toString('hex'),
ciphertext: encrypted.toString('hex'),
}
return JSON.stringify(payload)
}
export function decryptPassword(stored: string): string {
const { iv, authTag, ciphertext } = JSON.parse(stored) as EncryptedPayload
const decipher = createDecipheriv(
'aes-256-gcm',
KEY,
Buffer.from(iv, 'hex')
)
decipher.setAuthTag(Buffer.from(authTag, 'hex'))
return Buffer.concat([
decipher.update(Buffer.from(ciphertext, 'hex')),
decipher.final(),
]).toString('utf8')
}
Key generation (run once, store in .env):
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Pattern 5: Pangolin SSE Smoke Test
What: A trivial SSE endpoint that sends a heartbeat event every 10 seconds. Accessed over the real Pangolin public URL. If the stream stays alive for 60+ seconds without being closed by the proxy, SSE is confirmed viable for Phase 4. [VERIFIED: Hono streamSSE docs + PITFALLS #18]
// src/routes/sse.ts
// Source: https://hono.dev/docs/helpers/streaming
import { Hono } from 'hono'
import { streamSSE } from 'hono/streaming'
export const sseRouter = new Hono()
sseRouter.get('/heartbeat', (c) => {
return streamSSE(c, async (stream) => {
let id = 0
while (!stream.aborted) {
await stream.writeSSE({
data: JSON.stringify({ ts: new Date().toISOString(), id }),
event: 'heartbeat',
id: String(id++),
})
await stream.sleep(10_000)
}
})
})
Smoke test procedure:
- Deploy stack through Pangolin tunnel
curl -N https://familysync.yourdomain.com/api/sse/heartbeatfrom an external network (not LAN)- Observe heartbeat events arriving every 10s
- Keep alive for 5+ minutes to confirm no proxy timeout
- Document result: PASS → SSE confirmed for Phase 4 / FAIL → investigate Pangolin timeout config
Pangolin WS status: GitHub issue #1034 closed as "not planned" / stale — WebSocket upgrade fails through Pangolin with no documented fix. SSE is not mentioned in that issue. Design Phase 4 assuming SSE only; do not invest in WS. [VERIFIED: github.com/fosrl/pangolin/issues/1034]
Pattern 6: Docker Compose Layout
What: Single docker-compose.yml matching the Unraid stack. One service for the API + static PWA assets, one for MariaDB, one for Redis (optional in Phase 1 — can stub for now). Environment variables via .env file.
# docker-compose.yml (Phase 1 skeleton)
services:
api:
build: ./apps/api
environment:
DB_HOST: mariadb
DB_PORT: 3306
DB_USER: familysync
DB_PASSWORD: ${DB_PASSWORD}
DB_NAME: familysync
OIDC_AUTH_SECRET: ${OIDC_AUTH_SECRET}
OIDC_ISSUER: ${OIDC_ISSUER}
OIDC_CLIENT_ID: ${OIDC_CLIENT_ID}
OIDC_CLIENT_SECRET: ${OIDC_CLIENT_SECRET}
OIDC_REDIRECT_URI: ${OIDC_REDIRECT_URI}
OIDC_AUTH_EXTERNAL_URL: ${OIDC_AUTH_EXTERNAL_URL}
APP_PASSWORD_ENCRYPTION_KEY: ${APP_PASSWORD_ENCRYPTION_KEY}
depends_on:
mariadb:
condition: service_healthy
ports:
- "3000:3000"
mariadb:
image: mariadb:11
environment:
MARIADB_ROOT_PASSWORD: ${DB_ROOT_PASSWORD}
MARIADB_DATABASE: familysync
MARIADB_USER: familysync
MARIADB_PASSWORD: ${DB_PASSWORD}
volumes:
- mariadb_data:/var/lib/mysql
healthcheck:
test: ["CMD", "healthcheck.sh", "--connect", "--innodb_initialized"]
interval: 10s
timeout: 5s
retries: 5
redis:
image: redis:7-alpine
# Phase 1: present but not used; Phase 4 wires pub/sub
volumes:
mariadb_data:
API Dockerfile (Node 22):
FROM node:22-alpine
WORKDIR /app
COPY package*.json ./
RUN npm ci --production
COPY dist/ ./dist/
COPY apps/pwa/dist/ ./public/
CMD ["node", "dist/index.js"]
Serve PWA static assets from the same Hono process using
serveStaticfrom@hono/node-server/serve-staticto avoid a second nginx container in Phase 1. Add nginx when needed.
Anti-Patterns to Avoid
- Using
emailas identity key — Useoidc_iss + oidc_sub. Email can change per Authelia docs. (D-10, PITFALLS #16) - iframe silent token renewal — @hono/oidc-auth uses refresh-token rotation via the backend client. Never add an iframe flow. (D-12, PITFALLS #17)
- Reading Authelia
groupsfrom ID token — Skip groups entirely; all authenticated users are equal. (D-11, PITFALLS #16) - Fetching from Fastmail on every API request — Broker cache is mandatory; all reads hit MariaDB. (ARCHITECTURE.md Anti-Pattern 1)
- Storing all-day events as DATETIME — Use
DATEcolumn +all_dayboolean. (D-13, PITFALLS #3) - Proxying CalDAV credentials to frontend — App passwords live in encrypted DB rows, accessed only by broker module. (D-04, PITFALLS anti-pattern)
- Caching client-version of event after write — After CalDAV PUT, always re-fetch server version before caching. (D-13, PITFALLS #14)
- WebSocket for Phase 4 SSE — Pangolin WS upgrade is known-broken (#1034). Use SSE only.
- Trusting
trustProxy= false — Hono behind Pangolin receives forwarded IPs; setapp.use(...)appropriately or rely on OIDC session cookies (unaffected by proxy headers).
Don't Hand-Roll
| Problem | Don't Build | Use Instead | Why |
|---|---|---|---|
| OIDC auth flow + session cookies | Custom PKCE implementation | @hono/oidc-auth | Handles code exchange, PKCE, JWT cookie signing, refresh rotation |
| CalDAV PROPFIND/REPORT XML | Raw fetch + xml parsing | tsdav | WebDAV XML namespace handling is complex; tsdav wraps all of it |
| iCalendar parsing | Custom VCALENDAR regex | ical.js | VTIMEZONE, RDATE, EXDATE, RECURRENCE-ID require RFC 5545 parser |
| DB migrations | Ad-hoc SQL scripts | drizzle-kit generate + migrate | Schema-as-code, repeatable, reversible |
| AES-GCM encryption | Custom cipher | node:crypto AES-256-GCM | Built into Node 22; battle-tested; don't use a custom approach |
| Background scheduling | setInterval | node-cron | Handles missed ticks on startup; cron syntax is clearer |
Key insight: The CalDAV/iCalendar layer is the most deceptive "looks simple, is complex" area. The XML namespace hell alone justifies tsdav. The RFC 5545 edge cases (timezone, all-day, recurrence overrides) alone justify ical.js.
Common Pitfalls
Pitfall 1: OIDC_AUTH_EXTERNAL_URL Missing Behind Pangolin
What goes wrong: @hono/oidc-auth constructs the redirect URI from the incoming Host header. Behind Pangolin, the Host header is your internal container address, not the public URL. Authelia rejects the callback because the redirect URI doesn't match what was registered. Users see an "invalid redirect_uri" error.
How to avoid: Set OIDC_AUTH_EXTERNAL_URL=https://familysync.yourdomain.com explicitly. This overrides the URL the middleware builds for the redirect URI. [VERIFIED: github.com/honojs/middleware README]
Warning signs: "redirect_uri mismatch" error in Authelia logs on first login.
Pitfall 2: All-Day Event DATE Stored as DATETIME
What goes wrong: If dtstart_utc is used for all-day events (e.g., storing "2026-06-05" as 2026-06-05T00:00:00Z), the event appears on June 4 at 20:00 in UTC-4. [VERIFIED: Home Assistant CalDAV issue #25814, PITFALLS #3]
How to avoid: Schema has separate dtstart_date DATE and dtstart_utc TIMESTAMP columns. all_day boolean gates which to use. API response never includes a time component for all-day events.
Pitfall 3: tsdav createDAVClient Requires Account Discovery Round-Trip
What goes wrong: createDAVClient is async and calls /.well-known/caldav on construction for service discovery. If called on every API request, it adds 1-2 extra HTTP round-trips before fetching any data.
How to avoid: Create one client per credential set at startup (or on first use, then cache the client instance). The broker module owns the client lifetime.
Pitfall 4: node-cron v4 vs v3 API Change
What goes wrong: node-cron published v4.0.0 in May 2025. The npm latest is 4.2.1. The schedule() API is the same, but some advanced options changed. CLAUDE.md is silent on node-cron version.
How to avoid: Install node-cron@4 (latest). The basic cron.schedule('*/5 * * * *', fn) API is stable across v3 and v4. [VERIFIED: npm registry version 4.2.1, github.com/node-cron/node-cron]
Pitfall 5: Drizzle onDuplicateKeyUpdate Requires MariaDB 10.3+
What goes wrong: INSERT ... ON DUPLICATE KEY UPDATE is MariaDB-native. The Drizzle .onDuplicateKeyUpdate() method maps to this. It works with MariaDB 10.3+ (which is 5+ years old). Using mariadb:11 in Docker is safe.
How to avoid: Pin mariadb:11 in Docker Compose. Don't use older MariaDB images. [ASSUMED: MariaDB 10.3+ requirement for ON DUPLICATE KEY UPDATE — standard MariaDB SQL, LOW risk]
Pitfall 6: Fastmail ctag vs syncToken Field Availability
What goes wrong: The DAVCalendar.ctag and DAVCalendar.syncToken fields may be null if Fastmail's Cyrus IMAP doesn't return them in the initial PROPFIND. tsdav fetches standard props but not all servers return both.
How to avoid: Code defensively: davCal.ctag ?? davCal.syncToken ?? null. If both are null, perform a full REPORT sync on every poll (expensive but correct). Log which fields are present on first run. The CAL-08 spike will reveal what Fastmail actually returns. [ASSUMED: Fastmail returns ctag — confirmed in PITFALLS #4 but field name on DAVCalendar object not verified against real Fastmail response]
Pitfall 7: Authelia Client Secret — Plain vs Hashed
What goes wrong: The OIDC_CLIENT_SECRET env var passed to @hono/oidc-auth must be the PLAIN secret. Authelia YAML stores the HASHED version (pbkdf2-sha512). Developers sometimes put the hashed version in the env and wonder why token exchange fails.
How to avoid: OIDC_CLIENT_SECRET = the plain text secret that you generate. In Authelia's configuration.yml, use authelia crypto hash --sha512 <secret> to generate the hash. [VERIFIED: Authelia docs on client registration]
Code Examples
Serving PWA static files from Hono
// src/index.ts
// Source: https://hono.dev/docs/getting-started/nodejs
import { serve } from '@hono/node-server'
import { serveStatic } from '@hono/node-server/serve-static'
import { Hono } from 'hono'
const app = new Hono()
// Serve React PWA build
app.use('/assets/*', serveStatic({ root: './public' }))
app.get('*', serveStatic({ path: './public/index.html' }))
serve({ fetch: app.fetch, port: 3000 })
User upsert with color assignment
// src/auth/user.ts
import { db } from '../db/client'
import { users } from '../db/schema'
import { and, eq } from 'drizzle-orm'
// Claude's discretion: accessible palette, round-robin by join order
const COLOR_PALETTE = [
'#4A90D9', // calm blue (Lucas)
'#E8734A', // warm coral (wife)
'#5BA85A', // forest green
'#9B6DC5', // soft purple
]
export async function upsertUser(oidcIss: string, oidcSub: string, displayName?: string) {
const existing = await db.select()
.from(users)
.where(and(eq(users.oidcIss, oidcIss), eq(users.oidcSub, oidcSub)))
.limit(1)
if (existing[0]) return existing[0]
// Count existing users to assign next color
const [{ count }] = await db.select({ count: sql<number>`COUNT(*)` }).from(users)
const color = COLOR_PALETTE[count % COLOR_PALETTE.length]
const [user] = await db.insert(users).values({
oidcIss,
oidcSub,
displayName: displayName ?? null,
color,
}).$returningId()
return db.select().from(users).where(eq(users.id, user.id)).limit(1).then(r => r[0])
}
Hono app bootstrap with all middleware
// src/index.ts
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { oidcAuthMiddleware, processOAuthCallback } from '@hono/oidc-auth'
import { eventsRouter } from './routes/events'
import { meRouter } from './routes/me'
import { sseRouter } from './routes/sse'
const app = new Hono()
// OIDC callback — must be before auth middleware
app.get('/callback', (c) => processOAuthCallback(c))
// Protected API routes
app.use('/api/*', oidcAuthMiddleware())
app.route('/api/events', eventsRouter)
app.route('/api/me', meRouter)
app.route('/api/sse', sseRouter)
// Start broker polling
import { startBrokerPoller } from './broker/poller'
startBrokerPoller()
serve({ fetch: app.fetch, port: 3000 })
State of the Art
| Old Approach | Current Approach | Impact |
|---|---|---|
| Knex (as noted in ARCHITECTURE.md) | Drizzle ORM (locked in CLAUDE.md) | Better TypeScript inference; no binary engine; use Drizzle exclusively |
| iframe silent OIDC renewal | Refresh token rotation via backend confidential client | ITP-safe; no iframe; @hono/oidc-auth implements this by default |
| WebSocket for SSE-style events | SSE via streamSSE |
More proxy-resilient; Pangolin WS is known-broken |
| node-cron v3 | node-cron v4 (4.2.1) | Same API for basic use; v4 is current |
| Fastify (mentioned in ARCHITECTURE.md) | Hono (locked in CLAUDE.md) | Use Hono; ARCHITECTURE.md mentions Fastify but CLAUDE.md locks Hono |
Deprecated/outdated in this codebase:
- Knex: mentioned in ARCHITECTURE.md as DB layer, but CLAUDE.md locks Drizzle. Use Drizzle exclusively. Do not add Knex.
- Fastify: mentioned in ARCHITECTURE.md component table as "Node/Express or Fastify," but CLAUDE.md locks Hono. Use Hono.
@hono/node-wsdeprecated: Hono docs note this is deprecated; useupgradeWebSocketfrom@hono/node-serverdirectly. Phase 1 does not need WebSocket at all.- zod@3.24.x (CLAUDE.md): stale; actual latest v3 is 3.25.76 as of 2026-06-04. Pin
zod@^3.25.0.
Assumptions Log
| # | Claim | Section | Risk if Wrong |
|---|---|---|---|
| A1 | Fastmail PROPFIND returns ctag and/or syncToken fields on calendar collections |
Broker pattern, Pitfall 6 | Broker falls back to full-sync every poll; higher Fastmail API load but functional |
| A2 | tsdav DAVCalendar.ctag field name maps to Fastmail's getctag PROPFIND property |
Broker pattern | Must check actual field name in returned object at spike time; easy to correct |
| A3 | Fastmail app password reads both personal AND shared calendars via PROPFIND of the principal | CAL-08 spike | If personal calendar not returned: re-share step needed or fallback to shared-only |
| A4 | ical.js ICAL.Time.isDate correctly identifies all-day events parsed from tsdav-returned strings |
Broker sync pattern | If isDate is wrong: all-day events stored as timed events; DATE vs DATETIME bug manifests |
| A5 | MariaDB onDuplicateKeyUpdate works with Drizzle on the calendarId + uid composite constraint |
Schema pattern | Use eq filter + separate upsert logic if this fails |
| A6 | node-cron v4 schedule() API is backward-compatible with v3 for the basic 5-field cron string |
Poller pattern | Downgrade to v3 if v4 has breaking change in basic usage |
| A7 | OIDC_AUTH_EXTERNAL_URL resolves the redirect_uri construction behind Pangolin |
OIDC middleware | If it doesn't: need to set OIDC_REDIRECT_URI explicitly instead |
| A8 | vite-plugin-pwa@1.3.0 is compatible with vite@8.0.16 | Standard Stack | Check release notes if PWA plugin fails to load |
Open Questions
-
Fastmail ctag/syncToken field presence on DAVCalendar
- What we know:
DAVCalendartype has bothctagandsyncTokenfields - What's unclear: Whether Fastmail's Cyrus IMAP returns these in the PROPFIND response or returns null
- Recommendation: Log
davCal.ctaganddavCal.syncTokenin the CAL-08 spike; build code to handle either null
- What we know:
-
Authelia discovery URL format for
OIDC_ISSUER- What we know: @hono/oidc-auth fetches
{OIDC_ISSUER}/.well-known/openid-configuration - What's unclear: Whether Authelia's OIDC issuer URL includes a path component (e.g.,
/oidc) or is the bare domain - Recommendation: Check
https://auth.yourdomain.com/.well-known/openid-configurationin browser first; use theissuerfield from that JSON as the value forOIDC_ISSUER
- What we know: @hono/oidc-auth fetches
-
Pangolin SSE timeout behavior
- What we know: Pangolin WS is known-broken (#1034); SSE not mentioned in that issue
- What's unclear: Whether Pangolin has a default HTTP idle timeout that would close SSE streams after 60s (common nginx default)
- Recommendation: The smoke test must run for 5+ minutes. If stream closes before then, check Pangolin/Newt configuration for idle timeout settings.
-
Fastmail app-password permission scope for CalDAV
- What we know: Fastmail app passwords with "Mail, Contacts & Calendars" scope cover CalDAV
- What's unclear: Whether there are narrower scopes that exclude personal vs shared calendars, or whether one scope covers all
- Recommendation: Generate a new app password during CAL-08 spike with the default full scope; confirm it reads both shared and personal calendars
Environment Availability
| Dependency | Required By | Available | Version | Fallback |
|---|---|---|---|---|
| Node.js | Backend runtime | ✓ | v20.20.2 (dev machine) | — |
| npm | Package manager | ✓ | 10.8.2 | — |
| Docker | Container runtime | ✓ | 29.3.1 | — |
| Docker Compose | Stack orchestration | ✓ | v5.1.1 | — |
| MariaDB (in Docker) | Database | ✓ via image | mariadb:11 | — |
| Redis (in Docker) | Pub/sub (Phase 4) | ✓ via image | redis:7-alpine | In-process EventEmitter |
| Node 22 (in Docker) | Production runtime | ✓ via image | node:22-alpine | — |
| MariaDB client (local) | Local migration testing | ✗ | — | Run migrations inside Docker container |
| Redis CLI (local) | Local Redis debugging | ✗ | — | Redis CLI inside Docker: docker exec -it <container> redis-cli |
Dev machine note: Local Node is v20.20.2, but Docker runs node:22-alpine. Migrations and broker spike can be run inside Docker. TypeScript
target: ES2023covers both.
Missing dependencies with fallback:
- Local MariaDB client: use
docker exec -it familysync-mariadb-1 mariadb -u familysync -p familysyncfor local testing - Local Redis CLI: use Docker exec
Validation Architecture
nyquist_validation is enabled (config.json has no explicit false).
Test Framework
| Property | Value |
|---|---|
| Framework | Vitest 4.1.8 |
| Config file | apps/api/vitest.config.ts — Wave 0 gap |
| Quick run command | vitest run --reporter=verbose |
| Full suite command | vitest run |
Phase Requirements → Test Map
| Req ID | Behavior | Test Type | Automated Command | File Exists? |
|---|---|---|---|---|
| AUTH-01 | OIDC redirect flow sends unauthenticated request to Authelia | integration (manual) | — | Manual: browser test against real Authelia |
| AUTH-01 | Callback route upserts user on first login | unit | vitest run --reporter=verbose tests/auth/user.test.ts |
❌ Wave 0 |
| AUTH-02 | Session persists: /api/me returns 200 with valid JWT cookie after access token expiry window | integration (manual) | — | Manual: wait for token expiry, verify no redirect |
| AUTH-02 | Refresh interval config: middleware does not make unnecessary token requests | unit (middleware mock) | vitest run tests/auth/middleware.test.ts |
❌ Wave 0 |
| AUTH-03 | First login assigns color from palette; second login for same oidcSub returns same color | unit | vitest run tests/auth/user.test.ts |
❌ Wave 0 |
| AUTH-03 | Two different oidcSub users get distinct colors | unit | vitest run tests/auth/user.test.ts |
❌ Wave 0 |
| CAL-01 | Broker fetchCalendars returns at least one calendar from Fastmail | integration spike (manual) | — | Manual: run broker spike script against real Fastmail |
| CAL-01 | syncCalendar upserts events with correct allDay + dtstart fields | unit (mock tsdav) | vitest run tests/broker/sync.test.ts |
❌ Wave 0 |
| CAL-01 | All-day event stored with dtstart_date (DATE) not dtstart_utc | unit | vitest run tests/broker/sync.test.ts |
❌ Wave 0 |
| CAL-01 | ctag-poll: no DB write when ctag unchanged | unit | vitest run tests/broker/poller.test.ts |
❌ Wave 0 |
| CAL-08 | Spike script returns ≥1 personal calendar URL for Lucas | manual spike | — | Manual: node spike script |
| AES-GCM | encryptPassword + decryptPassword roundtrip is lossless | unit | vitest run tests/broker/crypto.test.ts |
❌ Wave 0 |
| AES-GCM | Different IVs produce different ciphertext for same plaintext | unit | vitest run tests/broker/crypto.test.ts |
❌ Wave 0 |
| SSE | /api/sse/heartbeat keeps stream alive and sends events | integration (manual) | — | Manual: curl -N <url> for 5+ minutes |
Sampling Rate
- Per task commit:
vitest run --reporter=dot(unit tests only, < 5s) - Per wave merge:
vitest run(full unit suite) - Phase gate: Full unit suite green + manual integration checklist before
/gsd-verify-work
Wave 0 Gaps (must create before implementation)
apps/api/vitest.config.ts— Vitest config for Node environment (not browser)apps/api/tests/auth/user.test.ts— upsertUser color assignment, oidc identity, return shapeapps/api/tests/broker/crypto.test.ts— encrypt/decrypt roundtrip, IV uniquenessapps/api/tests/broker/sync.test.ts— allDay handling, dtstart_date vs dtstart_utc, UID upsertapps/api/tests/broker/poller.test.ts— ctag change detection, skip on unchangedapps/api/tests/helpers/db.ts— in-memory or test-DB fixtures for Drizzle
Vitest config for Node backend:
// apps/api/vitest.config.ts
import { defineConfig } from 'vitest/config'
export default defineConfig({
test: {
environment: 'node',
globals: true,
},
})
Security Domain
security_enforcement is enabled (config.json has
security_enforcement: true, asvs_level: 1).
Applicable ASVS Categories (Level 1)
| ASVS Category | Applies | Standard Control |
|---|---|---|
| V2 Authentication | Yes | @hono/oidc-auth — Authelia handles credential validation; app verifies JWT cookie signature |
| V3 Session Management | Yes | @hono/oidc-auth JWT cookie: httpOnly, Secure, SameSite; refresh token rotation |
| V4 Access Control | Yes | All /api routes behind oidcAuthMiddleware(); no guest access |
| V5 Input Validation | Yes | zod + @hono/zod-validator on any route accepting body/query params |
| V6 Cryptography | Yes | node:crypto AES-256-GCM for app passwords; never hand-roll cipher logic |
Known Threat Patterns for This Stack
| Pattern | STRIDE | Standard Mitigation |
|---|---|---|
| Fastmail app password exposed in response | Information Disclosure | Encrypted in DB; broker module is the only reader; API routes never return credential data |
| OIDC redirect_uri manipulation | Spoofing | Authelia validates exact match; OIDC_REDIRECT_URI env var must match Authelia config |
| CSRF on callback route | Spoofing | @hono/oidc-auth uses PKCE (state + code_verifier) to validate the callback |
| JWT cookie tampering | Tampering | Cookie is signed with OIDC_AUTH_SECRET; @hono/oidc-auth verifies signature on every request |
| AES key exposure via env | Information Disclosure | APP_PASSWORD_ENCRYPTION_KEY never logged; .env not committed to git |
| Unencrypted CalDAV credentials at rest | Information Disclosure | AES-256-GCM with 96-bit IV and auth tag; implementation in crypto.ts |
| SSE endpoint accessible without auth | Elevation of Privilege | Mount SSE under /api/* which is behind oidcAuthMiddleware() |
Sources
Primary (HIGH confidence)
- [VERIFIED: npm registry] — All package versions confirmed via
npm view <pkg> version2026-06-04 - [github.com/honojs/middleware] — @hono/oidc-auth README: env vars, middleware API, session behavior, external URL config
- [orm.drizzle.team/docs/sql-schema-declaration] — mysqlTable schema syntax, column types, composite keys
- [orm.drizzle.team/docs/kit-overview] — drizzle-kit generate/migrate, drizzle.config.ts
- [hono.dev/docs/helpers/streaming] — streamSSE import path and API
- [hono.dev/docs/getting-started/nodejs] — serve(), WebSocket setup, Node adapter
- [CLAUDE.md] — Stack contract; all locked library choices; Authelia OIDC params; Fastmail principal URL
Secondary (MEDIUM confidence)
- [github.com/natelindev/tsdav] — createDAVClient API, fetchCalendars, fetchCalendarObjects, DAVCalendar type fields
- [github.com/fosrl/pangolin/issues/1034] — WebSocket upgrade failure confirmed; SSE not addressed; issue closed stale
- [authelia.com/configuration/identity-providers/openid-connect/clients/] — client YAML, PKCE, client_secret_basic
- [github.com/node-cron/node-cron] — v4 cron syntax examples
- .planning/research/PITFALLS.md — Pitfalls #1, #3, #4, #7, #14, #16, #17, #18 (verified against official sources)
- .planning/research/ARCHITECTURE.md — broker-cache pattern, build order, component boundaries
Tertiary (LOW confidence)
- [ASSUMED] Fastmail returns
ctagfield in tsdav DAVCalendar response — must confirm in CAL-08 spike - [ASSUMED] ical.js
ICAL.Time.isDatecorrectly identifies all-day events from Fastmail VEVENT strings — confirm in sync unit test
Metadata
Confidence breakdown:
- Standard stack: HIGH — all packages verified on npm with authoritative GitHub repos and official docs
- Architecture: HIGH — patterns directly derived from locked decisions + verified library docs
- Authelia OIDC wiring: HIGH — @hono/oidc-auth README + Authelia official client config docs
- Drizzle/MariaDB schema: HIGH — mysqlTable syntax verified against drizzle.team official docs
- CalDAV broker: MEDIUM — tsdav API verified; Fastmail-specific field behavior (ctag) is ASSUMED
- Pangolin SSE: MEDIUM — WS failure confirmed; SSE behavior must be smoke-tested
- App-password encryption: HIGH — node:crypto AES-256-GCM is standard Node 22; ASSUMED only for the JSON storage format
Research date: 2026-06-04 Valid until: 2026-07-04 (stable stack; check @hono/oidc-auth and drizzle-orm for minor updates before planning)