Files
familysync/.planning/phases/01-foundation-broker-spike/01-RESEARCH.md
T

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_sub composite key, never email.
  • D-11: Skip Authelia groups claim.
  • 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 version returns 4.4.3. CLAUDE.md specifies 3.24.x (stale). @hono/zod-validator@0.8.0 peer deps accept ^3.25.0 || ^4.0.0. Pin zod@3.25.x for 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.

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 signing
  • OIDC_ISSUERhttps://auth.yourdomain.com (Authelia's base URL; middleware fetches /.well-known/openid-configuration)
  • OIDC_CLIENT_ID — registered client ID in Authelia
  • OIDC_CLIENT_SECRET — client secret (plain, not hashed — hashing is only in Authelia YAML)
  • OIDC_REDIRECT_URIhttps://familysync.yourdomain.com/callback
  • OIDC_AUTH_EXTERNAL_URLhttps://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 = DATE column, all_day = true — never coerce DATE to DATETIME (Pitfall #3)
  • Timed events: dtstart_utc = TIMESTAMP (MariaDB stores in UTC), dtstart_date = NULL
  • uid is the CalDAV/iCalendar UID; use as idempotency key on upsert
  • rawVevent stores 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.com
  • credentials.username: Fastmail email
  • credentials.password: app password (decrypted at runtime)
  • authMethod: 'Basic'
  • defaultAccountType: 'caldav'

Pitfall #1: Do NOT use bare https://caldav.fastmail.com as the only URL. tsdav's service discovery will call /.well-known/caldav which redirects to the principal. Confirm the redirected principal URL matches https://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 returned davCal.url and davCal.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:

  1. Deploy stack through Pangolin tunnel
  2. curl -N https://familysync.yourdomain.com/api/sse/heartbeat from an external network (not LAN)
  3. Observe heartbeat events arriving every 10s
  4. Keep alive for 5+ minutes to confirm no proxy timeout
  5. 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 serveStatic from @hono/node-server/serve-static to avoid a second nginx container in Phase 1. Add nginx when needed.

Anti-Patterns to Avoid

  • Using email as identity key — Use oidc_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 groups from 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 DATE column + all_day boolean. (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; set app.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-ws deprecated: Hono docs note this is deprecated; use upgradeWebSocket from @hono/node-server directly. 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 (DEFERRED TO SPIKE — resolved empirically in Plan 01-04)

  1. Fastmail ctag/syncToken field presence on DAVCalendar

    • What we know: DAVCalendar type has both ctag and syncToken fields
    • What's unclear: Whether Fastmail's Cyrus IMAP returns these in the PROPFIND response or returns null
    • Recommendation: Log davCal.ctag and davCal.syncToken in the CAL-08 spike; build code to handle either null
  2. 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-configuration in browser first; use the issuer field from that JSON as the value for OIDC_ISSUER
  3. 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.
  4. 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: ES2023 covers both.

Missing dependencies with fallback:

  • Local MariaDB client: use docker exec -it familysync-mariadb-1 mariadb -u familysync -p familysync for 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 shape
  • apps/api/tests/broker/crypto.test.ts — encrypt/decrypt roundtrip, IV uniqueness
  • apps/api/tests/broker/sync.test.ts — allDay handling, dtstart_date vs dtstart_utc, UID upsert
  • apps/api/tests/broker/poller.test.ts — ctag change detection, skip on unchanged
  • apps/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> version 2026-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 ctag field in tsdav DAVCalendar response — must confirm in CAL-08 spike
  • [ASSUMED] ical.js ICAL.Time.isDate correctly 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)