Files
familysync/.planning/research/ARCHITECTURE.md
T
Lucas BergerandClaude Opus 4.8 6e1c9ca924 docs: complete v1.2 research (stack, features, architecture, pitfalls)
- STACK.md: google-auth-library@10.7.0 + @googleapis/calendar@15.0.0 scoped packages (vs monolithic googleapis), OAuth2 flow, token storage, Google Calendar API event/reminder model
- FEATURES.md: 6 feature categories (multi-provider, self-service onboarding, multiple reminders, dark mode, zero-setup DB, dev/CI stub), dependency graph, feature prioritization
- ARCHITECTURE.md: CalendarProvider interface, provider factory, CalDavProvider wrapper, GoogleCalendarProvider, MockProvider, provider_tokens table schema, multi-reminder JSON column, OAuth callback routing, 7-component data flows
- PITFALLS.md: 11 critical/medium pitfalls (refresh token 7-day expiry in testing status, Google recurrence mismatch, syncToken 410, timezone handling, provider abstraction regression, VALARM dedup key, auto-migrate failures, dark mode FOWT, OAuth callback through tunnel, token encryption, ESLint 10 breaking changes)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-19 09:27:35 -04:00

32 KiB

Architecture Research

Domain: v1.2 Integration Architecture — Multi-Provider, Theming & Zero-Setup Researched: 2026-06-19 Confidence: HIGH (based on direct codebase inspection)


Context: What This Document Covers

This file is scoped to how the v1.2 features integrate with the existing v1.1 codebase. It does not re-describe the existing architecture (see docs/ARCHITECTURE.md). Every component listed here is either NEW or MODIFIED; existing components that are untouched are mentioned only as integration anchor points.


Feature Integration Map

1. Provider Abstraction

Goal: Refactor the existing Fastmail/CalDAV broker behind a CalendarProvider interface so Google Calendar (and future providers) plug in without touching the poller, outbox worker, or reminder scheduler.

New Component: broker/provider.ts (NEW)

This is the central seam. Define a TypeScript interface that both the CalDAV broker and the Google provider implement:

export interface CalendarProvider {
  /** Discover and return all calendars this credential can access */
  listCalendars(): Promise<ProviderCalendar[]>;

  /** Sync events for one calendar into the DB (upsert). Returns detected changes. */
  syncCalendar(
    calendar: ProviderCalendar,
    onChanges?: (changes: CalendarChange[]) => void
  ): Promise<void>;

  /** Write back one pending outbox row. Returns HTTP-like status (200=success, 412=conflict, etc.) */
  dispatchOutboxRow(row: OutboxRow): Promise<DispatchResult>;

  /** Serialize N reminder leads into the provider's native format (VALARMs or Google overrides) */
  serializeReminders(leads: number[], allDay: boolean): unknown;

  /** Parse the provider's reminder representation back into lead-minutes array */
  parseReminders(nativePayload: unknown): number[];
}

export interface ProviderCalendar {
  url: string;          // CalDAV URL or Google calendarId -- the DB key
  displayName: string | null;
  color: string | null;
  changeKey: string | null; // ctag (CalDAV) or syncToken (Google)
}

Key constraint: The dispatchOutboxRow method signature must absorb all the logic currently split across outboxWorker.ts dispatchRow() + write.ts. The outbox worker becomes provider-agnostic: it calls provider.dispatchOutboxRow(row) and handles the result status uniformly.

Where Provider Selection Lives

Provider selection is per-member-credential, not per-calendar. The existing member_credentials.provider_type column ('caldav' default) is already the discriminator. The poller and outbox worker load all member_credentials rows, read provider_type, and instantiate the right provider implementation.

A factory function in broker/providerFactory.ts (NEW) maps provider_type to a CalendarProvider:

export function createProvider(cred: MemberCredential): CalendarProvider {
  switch (cred.providerType) {
    case 'caldav': return new CalDavProvider(cred);
    case 'google': return new GoogleCalendarProvider(cred);
    case 'mock':   return new MockCalendarProvider(); // dev-bypass / tests
    default: throw new Error(`Unknown provider type: ${cred.providerType}`);
  }
}

Modified: broker/poller.ts

Currently calls createFastmailClient + syncCalendar directly. Must change to:

  1. Load each memberCredentials row with its provider_type.
  2. Call createProvider(cred) to get a CalendarProvider.
  3. Call provider.listCalendars() and compare changeKey (was ctag).
  4. On change, call provider.syncCalendar(calendar, onChanges).

The existing ctag/syncToken logic in runPoll moves into each provider's listCalendars() implementation. The poller loop structure stays identical.

Risk mitigation for Fastmail path: Wrap the new CalDavProvider around the existing client.ts + sync.ts + write.ts logic verbatim -- do not refactor the internals of those files in the same phase. The provider interface is a thin delegation layer. Existing tests for runPoll can be updated to mock createProvider instead of createFastmailClient.

Modified: broker/outboxWorker.ts

loadClientForUser becomes loadProviderForUser (renamed, same shape). dispatchRow delegates to provider.dispatchOutboxRow(row) for the actual CalDAV/Google write. The retry/backoff/dead-letter state machine and the isDraining concurrency guard stay in the outbox worker -- those are provider-agnostic concerns.

The buildVeventString + RRULE/VALARM logic currently inlined in dispatchRow moves into CalDavProvider.dispatchOutboxRow. The outbox worker only knows about DispatchResult.

Modified: broker/reminderScheduler.ts

The reminder scheduler queries calendarEvents from MariaDB and calls dispatchPush -- it is already provider-agnostic. No structural change needed. The only change: the reminderLeadMinutes column on calendar_events becomes JSON (see Feature 4 below) and the scheduler query updates accordingly.

Normalized Internal Event Model

The calendarEvents table row is the internal canonical form that all providers must write to. Providers translate their native format into this DB shape on sync:

Provider field Internal DB column
VEVENT SUMMARY title
DTSTART (UTC) dtstart_utc or dtstart_date + all_day
RRULE has_rrule, raw_vevent (full blob)
VALARM DURATION (single) reminder_leads JSON array
Google summary title
Google start.dateTime dtstart_utc
Google recurrence[RRULE] has_rrule, stored in raw_vevent as provider blob
Google reminder overrides reminder_leads JSON array

The raw_vevent column contains the full native blob for CalDAV (used for RRULE/VALARM preservation on edit). For Google, this column stores the full Google event JSON blob. Providers know their own blob format; the DB treats it as opaque text.


2. Google Calendar Provider

OAuth2 Token Storage

New table: provider_tokens -- separate from member_credentials because Google issues two separate secrets (access_token + refresh_token) with different TTLs, and refresh is async.

CREATE TABLE provider_tokens (
  id            INT AUTO_INCREMENT PRIMARY KEY,
  user_id       INT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
  provider_type VARCHAR(64) NOT NULL,           -- 'google'
  access_token  TEXT NOT NULL,                  -- AES-256-GCM encrypted
  refresh_token TEXT,                           -- AES-256-GCM encrypted; nullable
  expires_at    TIMESTAMP NOT NULL,
  scope         VARCHAR(1024),
  created_at    TIMESTAMP DEFAULT NOW() NOT NULL,
  updated_at    TIMESTAMP DEFAULT NOW() ON UPDATE NOW(),
  UNIQUE KEY uniq_provider_token_user (user_id, provider_type)
);

member_credentials already has UNIQUE(user_id) enforcing one credential per member, which breaks multi-provider. For v1.2 (one member, one provider at a time), this constraint is fine -- a member has either CalDAV or Google, not both simultaneously. If multi-provider-per-member is ever needed (v1.3+), the unique constraint needs relaxation and the schema revisit is a separate migration.

Recommendation: Use member_credentials as the "this member has provider X connected" flag (providerType + a harmless encrypted sentinel), and provider_tokens for the actual Google OAuth token lifecycle. This avoids a schema change to member_credentials while cleanly separating concerns.

Token Refresh Worker

Rather than a dedicated worker, piggyback on the CalendarProvider contract: GoogleCalendarProvider checks expires_at before every API call and refreshes inline if the token is expired or within 5 minutes of expiry. This is the standard pattern for single-process deployments. No separate refresh setInterval is needed at this scale (2 users).

If a Google API call returns 401, the provider refreshes the token, retries once, then propagates the error as a hardFail dispatch result.

Google Read-Sync and Write-Back Mapping

CalDAV outbox model Google Calendar API equivalent
PUT .ics to collection URL POST/PATCH /calendar/v3/calendars/{calId}/events
DELETE object URL DELETE /calendar/v3/calendars/{calId}/events/{eventId}
ctag PROPFIND GET /calendar/v3/calendars/{calId} -> nextSyncToken
REPORT (fetch all) GET /calendar/v3/calendars/{calId}/events?syncToken=...
VEVENT uid Google event.id (stored as uid in calendar_outbox)
VEVENT etag Google event.etag (used for conditional update)

The outbox calendarUrl column stores either a CalDAV collection URL (CalDAV provider) or a Google calendarId string (Google provider). The column name stays calendarUrl; the GoogleCalendarProvider interprets it as a calendar ID.

Google OAuth Callback Routes (MODIFIED: routes/ + index.ts)

Google OAuth for calendar access is not the same as app login. The flow:

  1. Member clicks "Connect Google Calendar" on the self-service onboarding page.
  2. PWA calls GET /api/calendar-connect/google/authorize (NEW route, auth-guarded).
  3. The route generates an OAuth authorization URL with scope=https://www.googleapis.com/auth/calendar + access_type=offline + state=<signed-jwt-with-userId>.
  4. PWA redirects the browser to that URL.
  5. Google redirects back to /api/calendar-connect/google/callback (NEW route, pre-auth -- same pattern as /callback for Authelia OIDC).
  6. The callback exchanges the code for tokens, decrypts the state JWT to identify the member, and upserts provider_tokens + member_credentials.

Routing concern: The Google OAuth callback must be mounted before the OIDC auth guard in index.ts (same as /callback is mounted before oidcAuthMiddleware). Unlike the Authelia callback which establishes app login, the Google callback is for calendar access only -- the member is already logged into the app. The callback must still verify the signed state JWT to confirm the initiating user and prevent CSRF. After token storage, it redirects back to the onboarding page.

New routes file: routes/calendarConnect.ts (NEW) -- handles /api/calendar-connect/google/authorize (auth-guarded, GET) and /api/calendar-connect/google/callback (pre-auth, GET). Mount pre-auth in index.ts:

// Must be before OIDC guard, like /callback
app.get('/api/calendar-connect/google/callback', googleOAuthCallbackHandler);
// Auth-guarded
app.route('/api/calendar-connect', calendarConnectRouter);

The Google client_id and client_secret are env vars (GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET) -- never in the DB (same SC-3 rule as VAPID_PRIVATE_KEY).


3. Self-Service Per-Provider Onboarding

UI Changes (MODIFIED: PWA)

A new "Connect Calendar" page/sheet in the PWA, accessible from Settings or the setup flow. Conditionally renders:

  • Fastmail path: Password input field -> POST /api/me/credential (already exists). UI copies from CredentialSheet.tsx -- this component already implements the self-service flow.
  • Google path: "Connect Google Calendar" button -> calls GET /api/calendar-connect/google/authorize -> browser redirect to Google.

New PWA component: components/ProviderConnectSheet.tsx (NEW) -- wraps both flows with provider-aware branching. CredentialSheet.tsx can be refactored to delegate to this, or kept as-is for the Fastmail path (lower risk).

API Changes

GET /api/me/providers (NEW) -- returns which providers the current member has connected ({ providers: [{ type: 'caldav', connected: true }, { type: 'google', connected: false }] }). PWA uses this to render the onboarding state.

DELETE /api/me/providers/:type (NEW) -- disconnect a provider (deletes member_credentials + provider_tokens rows, removes synced calendars + events from DB).

The existing POST /api/me/credential handles the Fastmail/CalDAV path unchanged. The admin POST /api/admin/credentials path also stays as-is for admin-initiated credential rotation.

Auth Guard Coexistence

The Google OAuth callback is the only new pre-auth route. All other onboarding routes are auth-guarded (/api/me/* pattern). No changes to the existing auth middleware stack.


4. Multiple Reminders Per Event

This is an end-to-end schema and data-flow change.

Schema Change

Replace calendar_events.reminder_lead_minutes INT with calendar_events.reminder_leads JSON (array of integers). A NULL value means no reminders. An empty array [] is equivalent to null (no reminders set). This is a breaking migration for the existing single-reminder column.

Migration strategy:

  • Add reminder_leads JSON column.
  • Migrate existing data: UPDATE calendar_events SET reminder_leads = JSON_ARRAY(reminder_lead_minutes) WHERE reminder_lead_minutes IS NOT NULL.
  • Drop reminder_lead_minutes column in the same migration.
  • Update Drizzle schema: reminderLeads: json('reminder_leads').$type<number[] | null>().

The calendarOutbox payload schema (outboxPayloadSchema) changes reminderLeadMinutes: z.number().int()... to reminderLeads: z.array(z.number().int().min(0).max(10080)).nullable().optional().

Data Flow Changes

EventForm -> API (MODIFIED: PWA + routes/events.ts):

  • EventForm renders a list of reminder pickers (add/remove). Each picker = one lead time.
  • Payload changes: reminderLeadMinutes: number | null -> reminderLeads: number[] | null.

outboxWorker.ts (MODIFIED):

  • buildVeventString call changes: reminderLeadMinutes parameter -> reminderLeads: number[].
  • vevent.ts must emit multiple VALARM sub-components for each lead.
  • The preserve-on-no-change path: extractValarms already returns ICAL.Component[]; this still works verbatim.

vevent.ts (MODIFIED):

  • buildVeventString params: reminderLeadMinutes?: number | null -> reminderLeads?: number[] | null.
  • Build one VALARM per entry in the array.
  • buildTimedValarm and buildAllDayValarm helpers are called in a loop.

reminderScheduler.ts (MODIFIED):

  • Query changes: instead of WHERE reminder_lead_minutes IS NOT NULL, query WHERE reminder_leads IS NOT NULL AND JSON_LENGTH(reminder_leads) > 0.
  • reminder_leads is a JSON array; the scheduler must iterate all leads for an event and compute one fire time per lead.
  • Dedup key: ${uid}:${dtstartMs}:${leadMinutes} -- per-lead dedup so each reminder fires independently.

Google provider serialization:

  • serializeReminders(leads, allDay) -> { overrides: leads.map(m => ({ method: 'popup', minutes: m })), useDefault: false }.
  • parseReminders(googleEvent.reminders) -> googleEvent.reminders.overrides?.map(o => o.minutes) ?? [].

sync.ts (MODIFIED for CalDAV path):

  • When syncing a VEVENT, extract all VALARMs using extractValarms, classify each, and store the array of lead minutes as reminder_leads JSON.

5. PWA Dark Mode / Theming

Where Theme State Lives

Theme preference: localStorage key 'theme' with values 'light' | 'dark' | 'system'. Resolved theme (what's actually applied): derived from preference + window.matchMedia('(prefers-color-scheme: dark)').

New Zustand store: store/themeStore.ts (NEW) -- holds { preference, resolvedTheme } and a setPreference() action that writes to localStorage and applies data-theme to document.documentElement.

An effect in the store (or in main.tsx before React hydration) sets data-theme on <html> immediately from localStorage to prevent FOUC. The token file already has the [data-theme='light'] selector; adding [data-theme='dark'] with dark token values is the only CSS change.

Avoiding FOUC

The FOUC problem with theme switching is that React hydration runs after the browser paints. Solution: inject an inline <script> in index.html before any stylesheet or React bundle that reads localStorage['theme'] and sets document.documentElement.setAttribute('data-theme', resolved). This runs synchronously at parse time, before CSS is applied.

<script>
  (function(){
    var p = localStorage.getItem('theme') || 'system';
    var resolved = p === 'system'
      ? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
      : p;
    document.documentElement.setAttribute('data-theme', resolved);
  })();
</script>

This script goes in apps/pwa/index.html (MODIFIED) before the CSS <link> tags.

tokens.css Changes (MODIFIED)

Add [data-theme='dark'] rule with dark palette values. The stub comment already exists in tokens.css. The --sx-color-* Schedule-X overrides must also be present inside the dark rule. The calendarStore color palette (--color-member-0 through --color-member-5) must be defined in both light and dark rules -- same hue, adjusted brightness.

Theme Toggle UI

New component: components/ThemeToggle.tsx (NEW) -- renders a light/dark/system switch. Placed in SettingsSheet.tsx (MODIFIED).


6. Zero-Manual-Setup DB Bootstrap

Where Migrate-on-Boot Runs

In index.ts inside the isMainModule() guard, before startBrokerPoller() and before serve() -- but after the boot guards (assertNotDevBypassInProduction, assertLocalSessionSecretSet).

if (isMainModule()) {
  assertNotDevBypassInProduction();
  assertLocalSessionSecretSet();
  await runMigrationsIfNeeded();  // NEW -- runs drizzle migrate programmatically
  // ... VAPID setup, startBrokerPoller, startOutboxWorker, serve ...
}

Use drizzle-orm's programmatic migrate(db, { migrationsFolder: './src/db/migrations' }). This is already the established pattern for drizzle-kit; the change is calling it automatically at boot instead of requiring a manual docker exec step.

Interplay with Setup Wizard

The setup wizard (/api/setup/*) is mounted pre-auth and checks isSetupLocked() on every invocation. Migrate-on-boot runs BEFORE the server starts accepting requests, so by the time the wizard receives its first request, the schema is guaranteed to exist. No race condition.

If DB_HOST/DB_NAME/DB_USER/DB_PASSWORD env vars are set but the database is empty (first boot of a fresh MariaDB instance), migrate-on-boot creates all tables. The setup wizard then runs to configure OIDC/VAPID/admin account. This is the intended "bring-your-own MariaDB" flow.

Boot Failure Behavior

If migration fails (DB unreachable, bad creds), runMigrationsIfNeeded must throw -- the server should not start with a potentially partial schema. The MariaDB healthcheck in docker-compose.yml already gates API startup on DB readiness, so typically this won't be reached with an unreachable DB, but the defense-in-depth catch is still correct.


7. Dev-User Full-App Exercise (Mock Provider)

New Component: broker/mockProvider.ts (NEW)

Implements CalendarProvider with an in-memory store -- a Map<string, ProviderCalendar[]> for calendars and Map<string, CalendarEvent[]> for events. Pre-seeded with a set of realistic test events (recurring, all-day, timed, with reminders) that cover the scheduler's test surface.

Activation: When DEV_AUTH_BYPASS=true AND provider_type for the dev user is 'mock' (or when no credential row exists for the dev user), createProvider returns MockCalendarProvider. The dev bypass already injects a DEV_USER -- the mock provider boots alongside it with no DB credential row required.

Playwright harness integration: The existing devBypass.ts already gates test-only behavior on DEV_AUTH_BYPASS. The mock provider is the same gate. Playwright tests that exercise calendar CRUD will hit the mock provider, which returns success responses and updates its in-memory state. The outbox worker calls provider.dispatchOutboxRow() which the mock handles without network I/O.

Seed data bootstrapping: On first call to listCalendars(), the mock creates:

  • One personal calendar for DEV_USER.
  • One shared calendar.
  • ~5 pre-seeded events (one recurring weekly, one all-day, one with reminder, one past, one future).

This covers all code paths the scheduler, outbox worker, and event expansion touch.


Component Map: New vs Modified

New Components

Component Type Purpose
broker/provider.ts Interface + types CalendarProvider interface, ProviderCalendar, DispatchResult
broker/providerFactory.ts Factory createProvider(cred) -> CalendarProvider
broker/calDavProvider.ts Class Wraps existing client.ts + sync.ts + write.ts
broker/googleProvider.ts Class Google Calendar API v3 read/write + token refresh
broker/mockProvider.ts Class In-memory provider for dev-bypass + Playwright tests
routes/calendarConnect.ts Route file Google OAuth authorize + callback handlers
db/migrations/XXXX_v1.2_multi_provider.sql Migration provider_tokens table, reminder_leads JSON column, drop reminder_lead_minutes
pwa/src/store/themeStore.ts Zustand store Theme preference + resolved theme
pwa/src/components/ThemeToggle.tsx React component Light/dark/system switch
pwa/src/components/ProviderConnectSheet.tsx React component Provider onboarding UI (Fastmail + Google paths)

Modified Components

Component Change
broker/poller.ts createFastmailClient -> createProvider(cred); syncCalendar -> provider.syncCalendar()
broker/outboxWorker.ts loadClientForUser -> loadProviderForUser; dispatchRow -> provider.dispatchOutboxRow(row)
broker/reminderScheduler.ts Query + dedup logic for multi-lead JSON; fire one push per lead per event
broker/vevent.ts buildVeventString accepts reminderLeads: number[] instead of single reminderLeadMinutes
broker/credentialSync.ts validateEncryptAndStoreCredential becomes provider-aware (CalDAV path unchanged)
broker/sync.ts Parse multiple VALARMs into reminder_leads array on sync
db/schema.ts calendarEvents.reminderLeadMinutes -> calendarEvents.reminderLeads; new providerTokens table
routes/events.ts Payload schema: reminderLeadMinutes -> reminderLeads array
routes/me.ts New /api/me/providers GET + DELETE endpoints
index.ts Add runMigrationsIfNeeded() call; mount /api/calendar-connect/google/callback pre-auth
pwa/src/components/EventForm.tsx Multi-reminder picker (add/remove); payload field rename
pwa/src/components/SettingsSheet.tsx Add ThemeToggle; add provider connection status
pwa/src/styles/tokens.css Add [data-theme='dark'] rule with dark palette
pwa/src/index.html FOUC-prevention inline script before CSS link tags

Data Flow Changes

Multi-Provider Poller

startBrokerPoller()
  -> runPoll()
    -> SELECT * FROM member_credentials
    -> for each cred:
        createProvider(cred)           <- NEW dispatch via providerFactory
          -> provider.listCalendars()  <- CalDAV: PROPFIND / Google: GET /calendars
          -> compare changeKey vs stored ctag/syncToken
          -> on change: provider.syncCalendar(cal, onChanges)
            -> writes calendarEvents rows (provider-specific parse -> common DB shape)
            -> calls dispatchEventChange for push notifications

Multi-Provider Outbox Drain

runOutboxDrain()
  -> SELECT pending rows FROM calendar_outbox
  -> for each row:
      loadProviderForUser(row.userId)  <- replaces loadClientForUser
        -> SELECT member_credentials WHERE userId = row.userId
        -> createProvider(cred)
      -> provider.dispatchOutboxRow(row)
        CalDAV: buildVeventString + PUT/DELETE via tsdav
        Google: build Google event JSON + POST/PATCH/DELETE via googleapis
      -> handle DispatchResult (success/conflict/hardFail/transient)
      -> triggerTargetedResync via provider

Multiple Reminders: Form -> DB -> Scheduler -> Push

EventForm
  reminderLeads: number[]            <- user adds/removes reminders
  -> POST /api/events/create { reminderLeads: [30, 1440] }
    -> routes/events.ts validates array
    -> INSERT calendar_outbox { payload: JSON.stringify({ reminderLeads: [30, 1440] }) }
    -> 202 Accepted
  -> outboxWorker picks up row
    -> provider.dispatchOutboxRow
      CalDAV: buildVeventString({ reminderLeads: [30, 1440] })
               -> two VALARM sub-components in VCALENDAR
      Google:  { reminders: { useDefault: false, overrides: [{ method: 'popup', minutes: 30 }, ...] } }
    -> on success: syncCalendar -> parses VALARMs/overrides back -> stores [30, 1440] in reminder_leads

reminderScheduler (every 1 min)
  -> SELECT uid, title, dtstart_utc, all_day, reminder_leads FROM calendar_events
     CROSS JOIN push_subscriptions
     WHERE reminder_leads IS NOT NULL AND JSON_LENGTH(reminder_leads) > 0
  -> for each event:
      leads = JSON.parse(reminder_leads)   // e.g. [30, 1440]
      for each lead in leads:
        compute fireTime = dtstartUtc - lead minutes
        dedup key: `${uid}:${dtstartMs}:${lead}`
        if fireTime in (catchUpStart, now] and not in sentReminders:
          dispatchPush(sub, notification)
          sentReminders.set(dedupKey, dtstartMs)

Google OAuth + Calendar Connect

PWA (ProviderConnectSheet, logged-in user)
  -> GET /api/calendar-connect/google/authorize (auth-guarded)
    -> generate state JWT signed with LOCAL_SESSION_SECRET { userId, nonce }
    -> store nonce in linkNonceStore (same pattern as OIDC link flow)
    -> return { authorizationUrl: 'https://accounts.google.com/...' }
  -> PWA redirects browser to authorizationUrl

Google -> /api/calendar-connect/google/callback (pre-auth, before OIDC guard)
  -> verify state JWT
  -> consumeLinkNonce(nonce)
  -> exchange code for { access_token, refresh_token, expires_in }
  -> encrypt tokens (AES-256-GCM)
  -> upsert provider_tokens (user_id, 'google', tokens)
  -> upsert member_credentials (user_id, provider_type='google', placeholder)
  -> trigger initial sync via GoogleCalendarProvider
  -> redirect to /settings?provider=google&status=connected

Migrate-on-Boot Sequence

isMainModule() guard:
  1. assertNotDevBypassInProduction()
  2. assertLocalSessionSecretSet()
  3. await runMigrationsIfNeeded()     <- NEW: drizzle migrate(db, { migrationsFolder })
     -> connects to DB
     -> runs all pending migrations in order
     -> no-op on subsequent boots when schema is current
  4. webpush.setVapidDetails(...)
  5. startBrokerPoller()
  6. startOutboxWorker() + initOutboxTrigger()
  7. startReminderScheduler()
  8. serve({ fetch: app.fetch, port: 3000 })

Dependency-Ordered Build Sequence

The phases below are ordered by dependency. Each phase is safe to start only after the listed predecessors are complete.

Phase 21: Zero-Setup DB Bootstrap
  Deliverable: runMigrationsIfNeeded() in index.ts isMainModule() guard
  Dependencies: none (touches only index.ts startup + db/migrations)
  Unlocks: all subsequent phases can assume schema bootstraps automatically
  Risk: low

Phase 22: Provider Abstraction (CalDAV only, Fastmail path unchanged)
  Deliverable: broker/provider.ts interface, providerFactory.ts, calDavProvider.ts,
               poller.ts + outboxWorker.ts updated to use createProvider
  Dependencies: Phase 21 (schema must auto-migrate before integration tests run)
  Unlocks: Phase 23 (vevent.ts depends on interface), Phase 25 (Google needs interface),
           Phase 24 (mock needs factory)
  Risk: HIGH -- must not regress the working Fastmail path; existing integration tests
        must pass with CalDavProvider wrapper in place

Phase 23: Multiple Reminders Per Event
  Deliverable: DB migration (reminder_leads JSON), vevent.ts multi-VALARM,
               outboxWorker.ts payload schema, reminderScheduler.ts multi-lead scan,
               sync.ts multi-VALARM parse, routes/events.ts payload, EventForm.tsx
  Dependencies: Phase 22 (CalDavProvider.dispatchOutboxRow must accept reminderLeads)
  Note: Google reminder serialization/parsing is implemented in Phase 25
  Risk: MEDIUM -- breaking migration; existing data must be migrated

Phase 24: Dev-User Full-App Exercise (Mock Provider)
  Deliverable: broker/mockProvider.ts with seed data, providerFactory.ts 'mock' case,
               devBypass.ts sets provider_type='mock' for DEV_USER,
               Playwright harness updated to exercise calendar CRUD
  Dependencies: Phase 22 (CalendarProvider interface + factory)
  Unlocks: Phase 25 (unit tests can mock GoogleCalendarProvider)
  Risk: low -- dev-only path

Phase 25: Google Calendar Provider
  Deliverable: db/schema.ts provider_tokens table (migration), broker/googleProvider.ts,
               routes/calendarConnect.ts, index.ts pre-auth callback mount
  Dependencies: Phase 22 (CalendarProvider interface), Phase 23 (reminder serialization API),
                Phase 21 (provider_tokens table exists after migrate-on-boot)
  Risk: MEDIUM -- Google API OAuth flow has edge cases; token refresh inline logic

Phase 26: Self-Service Provider Onboarding UI
  Deliverable: GET/DELETE /api/me/providers routes, ProviderConnectSheet.tsx,
               SettingsSheet.tsx wiring
  Dependencies: Phase 25 (Google OAuth callback must exist before UI redirects to it)
  Note: Fastmail/CalDAV path (CredentialSheet.tsx) already works; this phase adds Google
  Risk: low

Phase 27: PWA Dark Mode / Theming
  Deliverable: index.html FOUC-prevention script, tokens.css dark rule,
               themeStore.ts, ThemeToggle.tsx, SettingsSheet.tsx
  Dependencies: none (pure frontend, no API changes)
  Can run in parallel with Phases 22-26 if desired
  Risk: low

Integration Points Summary

Feature Integration Point Risk Level
Provider abstraction broker/poller.ts runPoll loop Medium
Provider abstraction broker/outboxWorker.ts dispatchRow High
Google OAuth callback index.ts pre-auth route registration Low
Google tokens New provider_tokens table Low
Multi-reminder calendar_events schema (column rename to JSON) High
Multi-reminder reminderScheduler.ts query + dedup key Medium
Multi-reminder EventForm.tsx UI + routes/events.ts payload Medium
Migrate-on-boot index.ts isMainModule() guard Low
Dark mode FOUC index.html inline script Low
Mock provider devBypass.ts + providerFactory.ts Low

Anti-Patterns to Avoid

Anti-Pattern 1: Refactoring CalDAV Internals While Adding the Interface

Do not inline-refactor sync.ts, write.ts, vevent.ts internals as part of the provider abstraction phase. Wrap them as-is in CalDavProvider. Refactoring internals and adding the interface in the same phase doubles the risk surface. Internal cleanup is a separate concern if ever needed.

Anti-Pattern 2: Storing Google OAuth Tokens in app_config

The app_config table is a global key/value store for non-sensitive config. Google OAuth tokens are per-member secrets with a refresh lifecycle. They belong in provider_tokens with AES-256-GCM encryption and cascade-delete on user removal. Never put them in app_config.

Anti-Pattern 3: Using the Google OAuth Callback to Establish App Login

The Google OAuth callback (/api/calendar-connect/google/callback) is for calendar access only. The app login session is established separately via Authelia OIDC or local auth. Do not attempt to merge these two OAuth flows -- they serve different purposes and must remain independent auth paths.

Anti-Pattern 4: Schema Migration Without Data Migration

The rename from reminder_lead_minutes (INT) to reminder_leads (JSON) is a breaking change. The migration MUST include a UPDATE calendar_events SET reminder_leads = JSON_ARRAY(reminder_lead_minutes) WHERE reminder_lead_minutes IS NOT NULL step before dropping the old column. Without this, existing event reminders are silently lost.

Anti-Pattern 5: Per-Provider isDraining Flags

The outbox worker's isDraining module-level flag is documented as single-process-only. This remains valid for v1.2 (still single-process). Do not introduce per-provider drain locks -- the existing guard covers all rows from all providers in the same drain cycle.


Sources

  • Direct codebase inspection: apps/api/src/broker/, apps/api/src/db/schema.ts, apps/api/src/index.ts, apps/api/src/auth/, apps/pwa/src/styles/tokens.css
  • docs/ARCHITECTURE.md -- existing component diagram and data-flow walkthroughs
  • .planning/PROJECT.md -- v1.2 milestone scope and key decisions

Architecture research for: FamilySync v1.2 Multi-Provider integration Researched: 2026-06-19