1094 lines
54 KiB
Markdown
1094 lines
54 KiB
Markdown
# 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
|
|
|
|
```bash
|
|
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
|
|
|
|
```bash
|
|
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 signing
|
|
- `OIDC_ISSUER` — `https://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_URI` — `https://familysync.yourdomain.com/callback`
|
|
- `OIDC_AUTH_EXTERNAL_URL` — `https://familysync.yourdomain.com` (critical behind Pangolin)
|
|
|
|
**Code pattern:**
|
|
```typescript
|
|
// 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`):
|
|
```yaml
|
|
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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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:**
|
|
```bash
|
|
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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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):**
|
|
```typescript
|
|
// 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.
|
|
|
|
```typescript
|
|
// 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`):
|
|
```bash
|
|
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]
|
|
|
|
```typescript
|
|
// 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.
|
|
|
|
```yaml
|
|
# 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):**
|
|
```dockerfile
|
|
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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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
|
|
|
|
```typescript
|
|
// 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:**
|
|
```typescript
|
|
// 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)
|