- 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>
826 lines
56 KiB
Markdown
826 lines
56 KiB
Markdown
# Stack Research
|
|
|
|
**Domain:** Self-hosted family calendar + shared-lists PWA on Fastmail
|
|
**Researched:** 2026-06-03 (v1.0) / 2026-06-10 (v1.1 additions) / 2026-06-19 (v1.2 additions)
|
|
**Confidence:** MEDIUM-HIGH (calendar sharing cross-account caveat: LOW; rest HIGH)
|
|
|
|
---
|
|
|
|
## v1.2 Stack Additions — Multi-Provider, Theming & Zero-Setup
|
|
|
|
> Covers ONLY net-new libraries and patterns for v1.2. The existing stack (Hono, Drizzle,
|
|
> mysql2, tsdav, ical.js, web-push, @hono/oidc-auth, TanStack Query, Zustand, vite-plugin-pwa,
|
|
> @playwright/test) is shipped and proven — do not re-evaluate it.
|
|
|
|
### Net-New npm Packages (two only)
|
|
|
|
| Package | Version | Scope | Purpose |
|
|
|---------|---------|-------|---------|
|
|
| `google-auth-library` | 10.7.0 | `apps/api` | OAuth2 authorization-code flow + offline refresh token management |
|
|
| `@googleapis/calendar` | 15.0.0 | `apps/api` | Google Calendar API v3 typed REST client |
|
|
|
|
Everything else (dark mode, multiple VALARMs, programmatic migrate, CI updates) uses existing dependencies.
|
|
|
|
---
|
|
|
|
### 1. Google Calendar Integration
|
|
|
|
#### Library decision: `google-auth-library` + `@googleapis/calendar`
|
|
|
|
Do NOT use the monolithic `googleapis` package. It bundles 170+ API clients (~50 MB in the
|
|
Docker image) for Calendar-only use. The scoped split gives the same typed API surface at a
|
|
fraction of the footprint.
|
|
|
|
| Alternative | Rejection reason |
|
|
|-------------|-----------------|
|
|
| `googleapis` (monolithic) | 170+ bundled clients; bloats Docker image layer with unused code |
|
|
| Raw `fetch` + REST | Must hand-roll token refresh, retry logic, typed request/response schemas |
|
|
| `@microfox/google-calendar` | Third-party wrapper, last publish 9 months ago, adds indirection over official packages |
|
|
|
|
`@googleapis/calendar@15.0.0` depends only on `googleapis-common@^8.0.0` (auto-installed as a
|
|
transitive dep). `google-auth-library@10.7.0` ships its own TypeScript types — no `@types/` package
|
|
needed.
|
|
|
|
#### OAuth2 Authorization-Code Flow (backend-only)
|
|
|
|
The flow is fully backend-driven, matching the existing `@hono/oidc-auth` pattern for Authelia:
|
|
|
|
1. Backend generates the Google consent URL:
|
|
```typescript
|
|
const url = oauth2Client.generateAuthUrl({
|
|
access_type: 'offline',
|
|
scope: ['https://www.googleapis.com/auth/calendar.events'],
|
|
state: signedStateJwt, // CSRF protection, same pattern as OIDC link flow
|
|
});
|
|
```
|
|
2. User clicks "Connect Google" → redirected to Google consent screen.
|
|
3. Google redirects back to `/api/providers/google/callback`.
|
|
4. Backend exchanges the code:
|
|
```typescript
|
|
const { tokens } = await oauth2Client.getToken(code);
|
|
// tokens.refresh_token is ONLY present on first authorization with access_type:'offline'
|
|
// Subsequent exchanges return only access_token. Persist refresh_token immediately.
|
|
```
|
|
5. Store the token object `{ refresh_token, access_token, expiry_date }` encrypted
|
|
(AES-256-GCM, same crypto as Fastmail app passwords) in `member_credentials`
|
|
with `provider_type = 'google'`.
|
|
6. On each API call, hydrate the client:
|
|
```typescript
|
|
oauth2Client.setCredentials({ refresh_token: storedToken });
|
|
// google-auth-library auto-refreshes when access_token is expired
|
|
oauth2Client.on('tokens', (tokens) => {
|
|
// Persist newly issued access_token + expiry_date back to DB
|
|
// to avoid unnecessary refresh calls on next request
|
|
});
|
|
```
|
|
|
|
**Required scopes:**
|
|
- `https://www.googleapis.com/auth/calendar.events` — create/edit/delete events on any calendar
|
|
- `https://www.googleapis.com/auth/calendar.readonly` — read-only if write is not needed per calendar
|
|
|
|
#### Schema change for token storage
|
|
|
|
The existing `member_credentials` table has `fastmail_email` and `encrypted_password` columns.
|
|
For Google, `encrypted_password` stores the JSON token blob and `fastmail_email` stores the
|
|
Google account email. In v1.2, add a `provider_account_id VARCHAR(256)` column (additive migration)
|
|
that stores the account identifier in a provider-neutral name — avoids abusing `fastmail_email`
|
|
for a non-Fastmail email.
|
|
|
|
#### Google Calendar API event model vs. iCalendar
|
|
|
|
**Recurring events**: Google's `recurrence` field is an array of RFC 5545 RRULE strings — the same
|
|
format ical.js already handles for Fastmail:
|
|
|
|
```json
|
|
{ "recurrence": ["RRULE:FREQ=WEEKLY;BYDAY=MO"] }
|
|
```
|
|
|
|
However, Google uses RFC 3339 with explicit `timeZone` for timed events (not UTC-Z like the
|
|
existing CalDAV write path), and `date` fields (`"YYYY-MM-DD"`) for all-day events.
|
|
|
|
| Operation | Google API call |
|
|
|-----------|----------------|
|
|
| List instances (expanded) | `events.list({ singleEvents: true })` |
|
|
| List parent recurring events | `events.list({ singleEvents: false })` (default) |
|
|
| Edit one occurrence | GET instance (has `recurringEventId`), then PATCH |
|
|
| Delete one occurrence | `events.delete({ eventId: instanceId })` — only that instance |
|
|
| Delete whole series | `events.delete({ eventId: recurringEventId })` |
|
|
| Edit this + following | Set UNTIL on RRULE of original → insert new series from that point |
|
|
|
|
**Reminders**: Google uses a flat `reminders.overrides` array — structurally simpler than VALARM:
|
|
|
|
```json
|
|
{
|
|
"reminders": {
|
|
"useDefault": false,
|
|
"overrides": [
|
|
{ "method": "popup", "minutes": 15 },
|
|
{ "method": "popup", "minutes": 60 }
|
|
]
|
|
}
|
|
}
|
|
```
|
|
|
|
| Dimension | Google Calendar | iCalendar VALARM |
|
|
|-----------|----------------|-----------------|
|
|
| Trigger type | Relative minutes only | Relative DURATION or absolute DATE-TIME |
|
|
| All-day trigger | Minutes before midnight of event start | Absolute UTC instant (9 AM local in app) |
|
|
| Multiple reminders | Yes — array of overrides | Yes — multiple VALARM subcomponents |
|
|
| Methods | `popup` + `email` | `DISPLAY`, `AUDIO`, `EMAIL` |
|
|
|
|
Mapping strategy: read `overrides[*].minutes` where `method='popup'` → `reminderLeadMinutes[]`
|
|
array; ignore `email` method (app handles push, not email). On write: map `reminderLeadMinutes[]`
|
|
→ `overrides` array with `method: 'popup'`, set `useDefault: false`.
|
|
|
|
All-day event timing: Google fires at midnight minus lead minutes. The app's "9 AM local" semantic
|
|
from Fastmail cannot be replicated — document this as a provider difference; accept Google's
|
|
midnight-relative behavior for Google events.
|
|
|
|
#### Installation
|
|
|
|
```bash
|
|
pnpm add --filter @familysync/api google-auth-library @googleapis/calendar
|
|
```
|
|
|
|
---
|
|
|
|
### 2. Provider Abstraction — Hand-Rolled TypeScript Interface
|
|
|
|
No cross-provider calendar normalization library exists worth taking as a dependency. The two
|
|
providers have well-understood shapes; a hand-rolled interface in `apps/api/src/broker/` is the
|
|
right call — stays under project control, zero external dep, typed exactly to app needs.
|
|
|
|
```typescript
|
|
// apps/api/src/broker/providerTypes.ts
|
|
|
|
export interface NormalizedEvent {
|
|
uid: string; // stable cross-provider event ID
|
|
calendarId: string; // provider-internal calendar identifier
|
|
summary: string;
|
|
allDay: boolean;
|
|
dtstart: Date | string; // Date for timed, 'YYYY-MM-DD' for all-day
|
|
dtend: Date | string;
|
|
location?: string;
|
|
description?: string;
|
|
rruleString?: string; // bare RRULE value if recurring master
|
|
reminderLeadMinutes?: number | null; // legacy single (backward compat)
|
|
reminderLeadMinutesMultiple?: number[]; // v1.2 multiple reminders
|
|
rawPayload?: string; // CalDAV: raw iCalendar string; Google: JSON string
|
|
}
|
|
|
|
export interface ProviderCalendar {
|
|
id: string;
|
|
displayName: string;
|
|
color?: string;
|
|
isShared: boolean;
|
|
}
|
|
|
|
export interface CalendarProvider {
|
|
readonly providerType: 'caldav' | 'google';
|
|
discoverCalendars(): Promise<ProviderCalendar[]>;
|
|
syncEvents(calendarId: string, since?: Date): Promise<NormalizedEvent[]>;
|
|
createEvent(calendarId: string, event: Omit<NormalizedEvent, 'uid' | 'calendarId'>): Promise<string>;
|
|
updateEvent(calendarId: string, event: NormalizedEvent): Promise<void>;
|
|
deleteEvent(calendarId: string, uid: string): Promise<void>;
|
|
}
|
|
```
|
|
|
|
**Fastmail adapter**: wraps the existing `broker/sync.ts`, `broker/write.ts`, `broker/poller.ts`
|
|
behind this interface. Refactor, not rewrite.
|
|
|
|
**Google adapter**: new `broker/googleCalendarProvider.ts` implementing `CalendarProvider` using
|
|
`@googleapis/calendar` + `google-auth-library`. Fetches credentials from `member_credentials`
|
|
where `provider_type = 'google'`, re-hydrates `OAuth2Client` per call.
|
|
|
|
---
|
|
|
|
### 3. Multiple Reminders Per Event — No New Dependency
|
|
|
|
`ical.js` already supports multiple VALARM subcomponents via repeated `vevent.addSubcomponent(alarm)`
|
|
calls. The existing `buildVeventString` already processes a `valarms` array (preserve-on-edit path).
|
|
The v1.2 change is purely a data-model and serialization update:
|
|
|
|
1. **Schema**: Add `reminder_lead_minutes_json TEXT` column to `calendar_events` (nullable JSON
|
|
array e.g. `[15, 60]`). Keep `reminder_lead_minutes INT` for backward compat; treat single-value
|
|
as `[value]`.
|
|
2. **vevent.ts**: Change `buildVeventString` to accept `reminderLeadMinutes: number[]`; loop
|
|
`buildTimedValarm(lead)` for each, call `vevent.addSubcomponent()` per alarm.
|
|
3. **classifyValarms**: The `length > 1` branch currently returns `{ kind: 'custom' }`. v1.2
|
|
should return `{ kind: 'multi-preset', leads: number[] }` when all alarms are relative DURATION
|
|
triggers with preset lead values.
|
|
4. **Google adapter**: Map `reminders.overrides` bidirectionally — multiple `{ method: 'popup', minutes: N }` entries.
|
|
|
|
No new npm dependency.
|
|
|
|
---
|
|
|
|
### 4. PWA Dark Mode / Theming — Pure CSS + Existing Zustand
|
|
|
|
No theming library needed. The token layer is already structured for this:
|
|
|
|
- `tokens.css` has `[data-theme='light']` with all semantic tokens defined.
|
|
- Schedule-X `--sx-color-*` vars are already mapped to project tokens in `tokens.css` — so
|
|
adding a `[data-theme="dark"]` block that overrides `--color-surface`, `--color-text-primary`,
|
|
etc. automatically cascades into Schedule-X with no Schedule-X config change.
|
|
- The dark stub comment `[data-theme="dark"] { ... }` is already in `tokens.css` (Phase 17
|
|
groundwork). Fill it in.
|
|
|
|
**Implementation — no new package:**
|
|
|
|
```typescript
|
|
// apps/pwa/src/store/themeStore.ts
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware'; // built-in, already in Zustand 5.x
|
|
|
|
type ThemeMode = 'light' | 'dark' | 'system';
|
|
|
|
interface ThemeStore {
|
|
mode: ThemeMode;
|
|
setMode: (m: ThemeMode) => void;
|
|
}
|
|
|
|
export const useThemeStore = create<ThemeStore>()(
|
|
persist(
|
|
(set) => ({ mode: 'system', setMode: (mode) => set({ mode }) }),
|
|
{ name: 'familysync-theme' },
|
|
),
|
|
);
|
|
```
|
|
|
|
**DOM application** (called on mount and on mode change):
|
|
```typescript
|
|
function resolveTheme(mode: ThemeMode): 'light' | 'dark' {
|
|
if (mode !== 'system') return mode;
|
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
|
}
|
|
|
|
document.documentElement.dataset.theme = resolveTheme(store.mode);
|
|
```
|
|
|
|
**System preference listener:**
|
|
```typescript
|
|
window.matchMedia('(prefers-color-scheme: dark)')
|
|
.addEventListener('change', () => {
|
|
if (useThemeStore.getState().mode === 'system') {
|
|
document.documentElement.dataset.theme = resolveTheme('system');
|
|
}
|
|
});
|
|
```
|
|
|
|
**Flash prevention**: Add an inline `<script>` in `index.html` (before the React bundle) that
|
|
reads `localStorage['familysync-theme']` and sets `document.documentElement.dataset.theme`
|
|
synchronously. This is the standard flash-of-wrong-theme prevention pattern; no library needed.
|
|
|
|
Zustand `persist` middleware is already built into Zustand 5.0.14. No additional package.
|
|
|
|
---
|
|
|
|
### 5. Zero-Manual-Setup DB Bootstrap — Programmatic `drizzle-orm/mysql2/migrator`
|
|
|
|
`drizzle-orm/mysql2/migrator` is already part of `drizzle-orm@0.45.2`. No new package.
|
|
|
|
The migrate API requires a **single connection** (not the runtime pool):
|
|
|
|
```typescript
|
|
// apps/api/src/db/migrate.ts
|
|
import mysql from 'mysql2/promise';
|
|
import { drizzle } from 'drizzle-orm/mysql2';
|
|
import { migrate } from 'drizzle-orm/mysql2/migrator';
|
|
|
|
export async function runMigrations(): Promise<void> {
|
|
// migrate() must use a single connection, not the runtime pool
|
|
const connection = await mysql.createConnection({
|
|
host: process.env.DB_HOST ?? 'localhost',
|
|
port: Number(process.env.DB_PORT ?? 3306),
|
|
user: process.env.DB_USER ?? 'familysync',
|
|
password: process.env.DB_PASSWORD ?? '',
|
|
database: process.env.DB_NAME ?? 'familysync',
|
|
multipleStatements: true, // required: migration files contain multiple DDL statements
|
|
});
|
|
|
|
const db = drizzle(connection);
|
|
|
|
try {
|
|
await migrate(db, { migrationsFolder: './src/db/migrations' });
|
|
console.log('[db] migrations applied');
|
|
} finally {
|
|
await connection.end();
|
|
}
|
|
}
|
|
```
|
|
|
|
Call `await runMigrations()` in `apps/api/src/index.ts` **before** `serve()` and before
|
|
starting the broker poller. The existing `db` pool (from `db/client.ts`) is separate and
|
|
unchanged for all runtime queries.
|
|
|
|
**Idempotency**: Drizzle tracks applied migrations in a `__drizzle_migrations` table it
|
|
creates automatically. Running `migrate()` on a container restart is a no-op for already-applied
|
|
files. Safe to call unconditionally at every boot.
|
|
|
|
**MariaDB 11**: `multipleStatements: true` is required because drizzle-kit generates migration
|
|
files with multiple DDL statements separated by semicolons. MariaDB 11 is wire-compatible with
|
|
MySQL and this flag works identically.
|
|
|
|
**BANNED**: `db:push` (`drizzle-kit push`) remains banned on MariaDB 11. It schedules
|
|
destructive schema diffs. Only `drizzle-kit generate` (dev) + `migrate()` at runtime (prod).
|
|
|
|
---
|
|
|
|
### 6. CI Dependency Updates — Existing `pnpm` Tooling Only
|
|
|
|
No new tooling package. The workflow:
|
|
|
|
1. `pnpm outdated --recursive` — table of current / wanted / latest across all workspaces.
|
|
2. `pnpm update --interactive --latest -r` — selective upgrade; review each before accepting.
|
|
3. `pnpm audit --fix=update` (pnpm v11+) — bump packages to fix security findings rather than
|
|
adding overrides.
|
|
4. Run full local CI gates (`pnpm run lint`, `pnpm run typecheck`, `pnpm test`) before committing.
|
|
|
|
| Category | Action |
|
|
|----------|--------|
|
|
| Patch / minor | `pnpm update -r` within semver range |
|
|
| Major with API changes | Evaluate per-package; check changelog |
|
|
| HIGH/CRITICAL security | Prioritize; `--fix=update` where possible |
|
|
| `@playwright/test` | Pin to the Playwright binary installed in CI runner; bumping requires browser reinstall |
|
|
|
|
---
|
|
|
|
## v1.1 Stack Additions — Operability & Polish
|
|
|
|
This section covers ONLY what is new for v1.1. The rest of the file (below) documents the v1.0 stack, which is unchanged.
|
|
|
|
### What needs NO new dependency
|
|
|
|
| Feature | Existing tool that covers it | Why no addition needed |
|
|
| --------------------------------------------------- | -------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| Per-event reminders (VALARM) | `ical.js` + `tsdav` + existing write path | VALARM is a VCALENDAR component; ical.js parses/emits it; tsdav handles the PUT. No new library. |
|
|
| Outbox drain event-driven wake | `ioredis` pub/sub (already in stack) | Publish a `caldav:drain` event on Redis after a write; outbox worker subscribes and drains immediately. Zero new deps. |
|
|
| Admin Settings UI (app passwords + shared calendar) | Existing Drizzle schema + AES-256-GCM crypto (already in `apps/api`) | Role-gated Hono routes + React form. Schema already has the tables. |
|
|
| Setup wizard — DB connectivity probe | `mysql2` (already in stack) | Attempt a `mysql2` connect with the env-supplied credentials; resolve/reject gives pass/fail. |
|
|
| Setup wizard — VAPID key validation | `web-push` + Node.js built-in `crypto` (already in stack) | `Buffer.from(key, 'base64url').length === 32` for the private key; `web-push.generateVAPIDKeys()` for a fresh keypair; no extra library. |
|
|
| Setup wizard — OIDC discovery probe | Node.js 22 built-in `fetch` | `fetch(issuer + '/.well-known/openid-configuration')` and check for `200` + `authorization_endpoint` field. Native fetch in Node 22; zero extra library. |
|
|
| Setup wizard — env-var presence checks | `zod` (already in stack) | A `z.object({...}).safeParse(process.env)` at startup is the entire validation. Already used for request body validation. |
|
|
|
|
### What IS new for v1.1
|
|
|
|
**Two additions only:** `@playwright/test` for the mobile test harness, and the Gitea Actions workflow files (YAML only — no new runtime dep).
|
|
|
|
---
|
|
|
|
### New: @playwright/test (dev dependency, apps/pwa)
|
|
|
|
**Purpose:** Mobile-viewport + device-emulation + authenticated test harness. The existing `playwright-cli` global binary is an interactive/agentic tool not designed for CI spec files — it does not expose `storageState` save/restore, device emulation presets (`devices['iPhone 15 Pro']`), or a programmatic config (`playwright.config.ts`) needed to run mobile tests on a self-hosted runner.
|
|
|
|
**Package:** `@playwright/test`
|
|
**Current version:** 1.60.0 (verified npm, June 2026)
|
|
**Install scope:** `devDependencies` in `apps/pwa` only (not the monorepo root; only the PWA workspace needs browser tests).
|
|
|
|
**Why this and not playwright-cli alone:**
|
|
|
|
- `playwright-cli` (the global binary) does not support `storageState` file save/restore — the mechanism required to inject an Authelia session into a test context without re-running the full OIDC redirect flow on every test run.
|
|
- `@playwright/test` provides `devices` registry (iPhone 15 Pro, Pixel 5, etc.) which sets `viewport`, `userAgent`, `isMobile`, `hasTouch` together as a named preset.
|
|
- `@playwright/test` is the only path to a `playwright.config.ts` that defines a `setup` project (do login once, write `storageState` to `.auth/user.json`) and a `mobile` project that consumes it — the pattern needed for an authenticated, mobile-emulated CI run against the DEV_AUTH_BYPASS entry point.
|
|
- `playwright-cli` and `@playwright/test` coexist: `playwright-cli` continues to be the interactive verification tool during development; `@playwright/test` is the CI spec runner.
|
|
|
|
**Authentication strategy for OIDC-gated PWA:**
|
|
|
|
Authelia cannot be bypassed in a normal CI environment. The approach is to use the existing `DEV_AUTH_BYPASS=true` env flag (already implemented in `apps/api`) which injects user 1's session without an OIDC redirect. The `setup` project navigates to the app with `DEV_AUTH_BYPASS` active, waits for the authenticated state, then calls `context.storageState({ path: '.auth/user.json' })`. All subsequent test projects set `storageState: '.auth/user.json'` in their `use` config. This avoids any need to mock Authelia or run a real OIDC provider in CI.
|
|
|
|
**Device presets to use:**
|
|
|
|
```typescript
|
|
// playwright.config.ts (apps/pwa)
|
|
import { defineConfig, devices } from '@playwright/test';
|
|
|
|
export default defineConfig({
|
|
projects: [
|
|
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
|
{
|
|
name: 'mobile-safari',
|
|
use: { ...devices['iPhone 15 Pro'], storageState: '.auth/user.json' },
|
|
dependencies: ['setup'],
|
|
},
|
|
{
|
|
name: 'mobile-chrome',
|
|
use: { ...devices['Pixel 5'], storageState: '.auth/user.json' },
|
|
dependencies: ['setup'],
|
|
},
|
|
],
|
|
});
|
|
```
|
|
|
|
**Version compatibility:** `@playwright/test@1.60.0` — installs its own browser binaries. In CI (Gitea Actions), use `npx playwright install --with-deps chromium` in the workflow to install only Chromium (smallest footprint). The `catthehacker/ubuntu:act-latest` job container includes Node 20+ and system deps needed by Playwright.
|
|
|
|
**Do NOT install:** `playwright` (the library package) separately — `@playwright/test` bundles it. Do not install `@playwright/test` at the monorepo root; it belongs only in `apps/pwa`.
|
|
|
|
---
|
|
|
|
### New: Gitea Actions workflow files (.gitea/workflows/)
|
|
|
|
No new runtime npm packages. Workflow files are YAML only.
|
|
|
|
**Syntax compatibility:** Gitea Actions uses the same YAML syntax as GitHub Actions (`on:`, `jobs:`, `steps:`, `services:`, `uses:`). Workflow files live in `.gitea/workflows/` (not `.github/workflows/`). GitHub Actions actions (`actions/checkout@v4`, `docker/login-action@v3`, `docker/build-push-action@v5`) are usable directly; act_runner fetches them from their origin repos.
|
|
|
|
**Runner label:** The registered self-hosted runner should be labeled (e.g., `self-hosted` or `unraid`). Use `runs-on: self-hosted` in all job definitions. Do NOT use `runs-on: ubuntu-latest` — that label is only resolved by GitHub's hosted runners; a Gitea self-hosted runner with `ubuntu-latest` label works but needs explicit configuration.
|
|
|
|
**Job container image:** Use `container: image: catthehacker/ubuntu:act-latest` for jobs that need a rich Linux environment (lint/typecheck/test). This image is the standard act runner image: includes Node.js, npm, git, curl, and system libs for Playwright. For jobs that only need Docker CLI (image build/push), no `container` key is needed if the runner is in Docker socket-mount mode.
|
|
|
|
**MariaDB service container pattern:**
|
|
|
|
```yaml
|
|
jobs:
|
|
api-integration:
|
|
runs-on: self-hosted
|
|
container:
|
|
image: catthehacker/ubuntu:act-latest
|
|
services:
|
|
mariadb:
|
|
image: mariadb:11
|
|
env:
|
|
MARIADB_ROOT_PASSWORD: testroot
|
|
MARIADB_DATABASE: familysync_test
|
|
MARIADB_USER: familysync
|
|
MARIADB_PASSWORD: testpass
|
|
options: >-
|
|
--health-cmd="healthcheck.sh --connect --innodb_initialized"
|
|
--health-interval=10s
|
|
--health-timeout=5s
|
|
--health-retries=5
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- run: npm ci
|
|
working-directory: apps/api
|
|
- run: npm run db:migrate
|
|
working-directory: apps/api
|
|
env:
|
|
DB_HOST: mariadb
|
|
DB_PORT: 3306
|
|
DB_NAME: familysync_test
|
|
DB_USER: familysync
|
|
DB_PASSWORD: testpass
|
|
- run: npm test
|
|
working-directory: apps/api
|
|
env:
|
|
DB_HOST: mariadb
|
|
```
|
|
|
|
**Critical note on MariaDB 11 health check:** MariaDB 11.x Docker images removed the `mysqladmin` binary. The health check must use `healthcheck.sh --connect --innodb_initialized` (the script ships in the official image). Using `mysqladmin ping` will cause the service container to remain unhealthy and block the job indefinitely.
|
|
|
|
**Docker build + push to Gitea container registry:**
|
|
|
|
```yaml
|
|
jobs:
|
|
build-push:
|
|
runs-on: self-hosted
|
|
steps:
|
|
- uses: actions/checkout@v4
|
|
- uses: docker/login-action@v3
|
|
with:
|
|
registry: ${{ vars.GITEA_REGISTRY }} # e.g. git.bergerhouse.ca
|
|
username: ${{ secrets.REGISTRY_USER }}
|
|
password: ${{ secrets.REGISTRY_PASSWORD }}
|
|
- uses: docker/build-push-action@v5
|
|
with:
|
|
context: .
|
|
file: apps/api/Dockerfile
|
|
push: true
|
|
tags: |
|
|
${{ vars.GITEA_REGISTRY }}/${{ gitea.repository_owner }}/familysync-api:${{ gitea.sha }}
|
|
${{ vars.GITEA_REGISTRY }}/${{ gitea.repository_owner }}/familysync-api:latest
|
|
```
|
|
|
|
**Secrets required:** `REGISTRY_USER` and `REGISTRY_PASSWORD` — a Gitea Personal Access Token with `write:package` scope. Gitea does NOT inject a built-in `GITEA_TOKEN` that grants container-registry push; a PAT is required. Store credentials in the repo's Settings > Secrets > Actions.
|
|
|
|
**Docker-in-Docker consideration:** If the runner is operating in Docker socket-mount mode (the default for the Gitea act_runner Docker container), the `docker` CLI inside a `catthehacker/ubuntu:act-latest` job container can reach the host Docker daemon via the mounted socket — sufficient for `docker/build-push-action`. If the runner is in DinD mode, additional config is needed (custom DinD image + `DOCKER_HOST=tcp://docker:2376`). The socket-mount mode is simpler and sufficient for this use case.
|
|
|
|
**Workflow file structure recommendation:**
|
|
|
|
```
|
|
.gitea/workflows/
|
|
ci.yml # lint + typecheck + vitest unit (runs on every PR push)
|
|
integration.yml # API integration tests against MariaDB service container (runs on PR to main)
|
|
build.yml # Docker build + push to Gitea registry (runs on merge to main)
|
|
mobile-test.yml # Playwright mobile tests (runs on PR to main)
|
|
```
|
|
|
|
---
|
|
|
|
## Recommended Stack (v1.0 baseline — unchanged)
|
|
|
|
### Core Technologies
|
|
|
|
| Technology | Version | Purpose | Why Recommended |
|
|
| -------------------- | ------- | ------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| Node.js + TypeScript | 22 LTS | Backend runtime | First-class typing, same language as frontend, largest CalDAV/OIDC library ecosystem |
|
|
| Hono | 4.12.23 | HTTP framework | Web-Standards-native, first-class TypeScript, built-in SSE helper, WebSocket via `@hono/node-server`; lighter than Express and better ergonomics than Fastify for this size |
|
|
| Drizzle ORM | 0.45.2 | MariaDB query layer | Type-safe SQL, zero runtime overhead, native `mysql2` driver support, schema-as-code migrations via `drizzle-kit` |
|
|
| mysql2 | 3.22.4 | MariaDB driver | The only maintained native MariaDB/MySQL driver; Drizzle targets it explicitly |
|
|
| React 19 | 19.x | PWA frontend | Required by project; concurrent features, stable |
|
|
| Vite | 8.0.x | Build tooling | De-facto standard for React PWAs; fast HMR, native ESM |
|
|
| vite-plugin-pwa | 1.3.0 | Service worker + manifest | Zero-config Workbox integration, handles install prompt, offline cache, background sync scaffolding |
|
|
|
|
### Supporting Libraries
|
|
|
|
| Library | Version | Purpose | When to Use |
|
|
| --------------------- | ------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
| tsdav | 2.2.2 | CalDAV client for Node.js | All calendar reads and writes against Fastmail CalDAV endpoint; handles PROPFIND, REPORT, PUT, DELETE |
|
|
| ical.js | 2.2.1 | iCalendar (.ics) parsing | Parse raw VCALENDAR/VEVENT payloads returned by tsdav; handles VTIMEZONE, RDATE, EXDATE |
|
|
| rrule | 2.8.1 | Recurrence rule expansion | Expand RRULE strings into concrete event occurrences for the calendar view; ical.js's built-in expansion is less ergonomic for UI consumption |
|
|
| web-push | 3.6.7 | Server-side VAPID push | Generate VAPID keys, sign and dispatch push messages to browser push services (APNs for iOS, FCM for Android) |
|
|
| @hono/oidc-auth | 1.8.3 | OIDC session middleware for Hono | Storage-less JWT session cookies; authorization-code + PKCE flow; works with any RFC-compliant OIDC provider including Authelia |
|
|
| openid-client | 6.8.4 | Low-level OIDC primitives | If `@hono/oidc-auth` proves insufficient (e.g., custom token introspection), use this as the lower-level escape hatch |
|
|
| ioredis | 5.11.0 | Redis client | Pub/sub for broadcasting list-change events to SSE connections across Node processes |
|
|
| zod | 3.24.x | Schema validation | Validate API request bodies and CalDAV event payloads before writing back to Fastmail |
|
|
| @hono/zod-validator | 0.8.0 | Hono middleware for Zod | Validate request body/query in route handlers with Zod schemas |
|
|
| @tanstack/react-query | 5.101.0 | Server state + caching | Manages calendar and list data fetching, background refetch, stale-while-revalidate; pairs with SSE for live list updates |
|
|
| zustand | 5.0.14 | Client state | UI-only state (selected date range, color assignments, drawer open/closed); keep server state in React Query |
|
|
| drizzle-kit | 0.31.10 | Schema migrations | Generates and runs MariaDB migrations from Drizzle schema definitions |
|
|
|
|
### Development Tools
|
|
|
|
| Tool | Purpose | Notes |
|
|
| -------------------- | --------------------------------------- | ------------------------------------------------------------------------------ |
|
|
| TypeScript 5.x | Strict typing across backend + frontend | `strict: true`; share types between packages via a `packages/shared` workspace |
|
|
| ESLint + Prettier | Lint + format | Standard config; no bikeshedding needed |
|
|
| Docker Compose | Local dev + production parity | Match Unraid stack exactly in dev |
|
|
| Vitest | Unit + integration tests | Vite-native, same config as frontend |
|
|
| **@playwright/test** | **v1.1 NEW — Mobile PWA test harness** | **devDependency in apps/pwa only; 1.60.0** |
|
|
|
|
---
|
|
|
|
## Installation
|
|
|
|
```bash
|
|
# Backend
|
|
npm install hono @hono/node-server @hono/oidc-auth @hono/zod-validator
|
|
npm install drizzle-orm mysql2 ioredis
|
|
npm install tsdav ical.js rrule
|
|
npm install web-push zod openid-client
|
|
|
|
# v1.2 additions (apps/api)
|
|
npm install google-auth-library @googleapis/calendar
|
|
|
|
# Frontend
|
|
npm install react react-dom @tanstack/react-query zustand
|
|
npm install -D vite vite-plugin-pwa
|
|
|
|
# Dev (monorepo root)
|
|
npm install -D typescript drizzle-kit vitest @types/node @types/web-push
|
|
|
|
# Dev (apps/pwa only — v1.1)
|
|
npm install -D @playwright/test
|
|
npx playwright install --with-deps chromium
|
|
```
|
|
|
|
---
|
|
|
|
## Calendar Integration: CalDAV, Not JMAP
|
|
|
|
**Decision: CalDAV via `tsdav`. JMAP for calendars is not available from Fastmail.**
|
|
|
|
Fastmail's developer docs (as of 2026-06-03) state explicitly: calendar access is CalDAV only; JMAP calendar support is planned but blocked on RFC 8984 specification finalization. The JMAP working group has not finalized the calendars spec. Do not plan around JMAP calendars — it is not a near-term option.
|
|
|
|
**CalDAV mechanics with tsdav:**
|
|
|
|
- Principal URL: `https://caldav.fastmail.com/dav/principals/user/broker@fastmail.com/`
|
|
- `tsdav` performs `PROPFIND` on the principal to discover all calendar collections, then fetches each collection's events via `REPORT` (calendar-query or calendar-multiget).
|
|
- One app password covers all calendars owned by that account under the default "Mail, Contacts & Calendars" scope.
|
|
- `tsdav` returns raw iCalendar strings. Pass each to `ical.js` for parsing into event objects, then use `rrule` for RRULE expansion into the date range the UI needs.
|
|
- Write-back (create/edit/delete): PUT a new `.ics` to the collection URL; DELETE by UID.
|
|
|
|
**Authentication model:**
|
|
|
|
Use a Fastmail app password (not OAuth) for the backend broker token. App passwords are simple HTTP Basic credentials. OAuth is intended for distributing apps to Fastmail users — not applicable here. The app password is a server secret stored in an environment variable; it never leaves the backend.
|
|
|
|
**Personal calendar aggregation — IMPORTANT CAVEAT (LOW confidence):**
|
|
|
|
Fastmail's multi-user calendar sharing is documented only for users within the same Fastmail account (i.e., a multi-user/family Fastmail subscription). If both household members are on the same Fastmail family plan, the primary account holder can be granted edit access to the other member's personal calendar, and the broker token for the primary account will discover and read/write those shared calendars via CalDAV. If the wife has a separate independent Fastmail account, cross-account CalDAV sharing via a single broker token is unconfirmed — this should be tested before Phase 1 commits to the personal-calendar overlay feature. The shared family calendar (owned by the primary account) works unconditionally.
|
|
|
|
**What NOT to use for calendars:**
|
|
|
|
- `node-ical`: older fork with weaker RRULE support; ical.js is maintained by Mozilla and is the reference implementation
|
|
- Direct `fetch`/`axios` against CalDAV: re-inventing XML namespace handling and PROPFIND parsing; tsdav exists specifically to avoid this
|
|
- JMAP: not available for calendars on Fastmail today
|
|
|
|
---
|
|
|
|
## Backend Framework
|
|
|
|
**Decision: Hono on Node.js**
|
|
|
|
Hono is the right size for this app. Express is fine but has no TypeScript-native ergonomics. NestJS is overkill for a two-person household app. Hono gives you:
|
|
|
|
- First-class TypeScript with RPC-style type sharing (Hono RPC can export typed client for the React frontend — eliminates API drift)
|
|
- Built-in SSE streaming helper (`streamSSE`) for live list updates
|
|
- WebSocket support via `@hono/node-server`
|
|
- Runs on Node.js 22 LTS in Docker with `@hono/node-server`
|
|
|
|
**ORM: Drizzle + mysql2**
|
|
|
|
Drizzle is the correct choice over Prisma for this stack:
|
|
|
|
- Prisma generates a binary engine that adds complexity in Docker images and has weaker MariaDB compatibility signals
|
|
- Drizzle uses `mysql2` directly — the same driver you'd use raw; no runtime translation layer
|
|
- Drizzle's `mysqlTable` schema is fully MariaDB-compatible (MariaDB is wire-compatible with MySQL; Drizzle's `mysql` dialect works)
|
|
- Type inference from schema → query results is the core value proposition; zero runtime overhead
|
|
|
|
---
|
|
|
|
## React PWA Stack
|
|
|
|
**Build: Vite 8 + vite-plugin-pwa 1.3.0**
|
|
|
|
Standard for React PWAs. `vite-plugin-pwa` configures the Web App Manifest and injects a Workbox service worker. Use `injectManifest` strategy (not `generateSW`) so you have explicit control over the service worker file — required for Web Push subscription management.
|
|
|
|
**State/data: TanStack Query + Zustand**
|
|
|
|
- TanStack Query owns all server-side state: calendar events, lists, user profile. It handles background refetch, cache invalidation, and loading states. Use `queryClient.invalidateQueries` from SSE event handlers to keep list data live.
|
|
- Zustand owns pure UI state: selected month, color assignments per calendar, drawer states. Do not put server data in Zustand.
|
|
|
|
**Web Push (VAPID):**
|
|
|
|
1. Generate a VAPID key pair once (`web-push generateVAPIDKeys`), store in environment variables.
|
|
2. Expose the public key via an API route; the PWA calls `pushManager.subscribe({ userVisibleOnly: true, applicationServerKey })` from a user-gesture handler.
|
|
3. Store the `PushSubscription` object (endpoint, keys) in MariaDB per user.
|
|
4. Backend sends notifications via `web-push.sendNotification(subscription, payload)`.
|
|
|
|
**iOS-specific Web Push constraints (CRITICAL):**
|
|
|
|
| Requirement | Detail |
|
|
| --------------------- | -------------------------------------------------------------------------------------- |
|
|
| Minimum iOS version | 16.4 — push is silently unavailable on earlier versions |
|
|
| Installation required | PWA **must** be added to Home Screen; push does not work from Safari browser tabs |
|
|
| User gesture | `pushManager.subscribe()` must be called inside a tap handler, not on page load |
|
|
| EU users on iOS 17.4+ | PWAs may open in Safari tabs instead of standalone mode due to DMA; affects push reach |
|
|
| Silent push | Not supported on iOS; all push messages must display a visible notification |
|
|
| Background sync | Not supported on iOS; no `BackgroundSync` or `PeriodicBackgroundSync` |
|
|
|
|
**Declarative Web Push (Safari 18.4+):** Apple shipped Declarative Web Push in Safari 18.4 (iOS 18.4, March 2025). It's backward-compatible: send a JSON payload with `"web_push": 8030` and the browser renders the notification without a service worker handler. The `web-push` npm library (v3.6.7) does not generate this format natively — you'd hand-craft the JSON payload for iOS while the same endpoint handles standard Web Push for Android/desktop. As of mid-2026, Declarative Web Push is a W3C Working Draft and the preferred format for iOS/macOS push. Build the push payload to be Declarative Web Push compatible from day one (it's just a JSON schema change), since `web-push` still handles the VAPID transport layer.
|
|
|
|
**Onboarding UX for iOS (wife):** The install-to-home-screen step is unavoidable for push notifications. Design the first-run flow to prompt this explicitly (custom install banner, step-by-step guide). Once installed, OIDC login via Authelia is one tap — the low-friction goal is achievable.
|
|
|
|
---
|
|
|
|
## Authelia OIDC Integration
|
|
|
|
**Decision: `@hono/oidc-auth` middleware**
|
|
|
|
Authelia exposes a standards-compliant OIDC discovery endpoint. `@hono/oidc-auth` uses `oauth4webapi` under the hood, supports authorization code + PKCE, and produces storage-less JWT session cookies — no Redis or session DB required for auth state.
|
|
|
|
**Flow:**
|
|
|
|
1. Unauthenticated request → middleware redirects to Authelia's authorization endpoint
|
|
2. Authelia authenticates the user, redirects back with `code`
|
|
3. Middleware exchanges code for tokens, creates signed JWT session cookie (httpOnly, Secure, SameSite=Lax)
|
|
4. Cookie is verified on every request; refresh tokens are used to silently re-authenticate before expiry
|
|
|
|
**Authelia configuration requirements:**
|
|
|
|
- `response_types: [code]`
|
|
- `grant_types: [authorization_code, refresh_token]`
|
|
- `require_pkce: true`, `pkce_challenge_method: S256`
|
|
- `token_endpoint_auth_method: client_secret_basic`
|
|
|
|
Authelia's own integration docs show this exact pattern for Express.js (`express-openid-connect`). `@hono/oidc-auth` is the Hono-native equivalent. If it hits edge cases, `openid-client` v6 is the lower-level fallback.
|
|
|
|
**Do NOT use:** `oidc-client-ts` — it is a browser-side library for SPAs doing the OIDC flow in the frontend. This app has a backend session; the OIDC flow belongs on the server.
|
|
|
|
---
|
|
|
|
## Live List Sync
|
|
|
|
**Decision: SSE (Server-Sent Events) + Redis Pub/Sub, not WebSockets**
|
|
|
|
Lists are co-edited by two people. The update direction is server → client (server broadcasts when one client mutates a list). SSE is simpler than WebSockets for this: plain HTTP, works through proxies, automatic reconnection in browsers.
|
|
|
|
Pattern:
|
|
|
|
1. Client opens `GET /api/lists/stream` → Hono `streamSSE` keeps connection alive
|
|
2. On a list mutation, the backend publishes a `list:updated:{listId}` event to Redis
|
|
3. All Node processes subscribed to Redis receive the event and push it to connected SSE clients
|
|
4. React Query on the client receives the SSE event → `invalidateQueries(['lists', listId])` → refetches
|
|
|
|
Redis (`ioredis`) is only needed if multiple Node containers run behind a load balancer. For a single Unraid Docker Compose with one backend container, you can skip Redis and use an in-process event emitter — leave the abstraction clean so Redis can be added later.
|
|
|
|
---
|
|
|
|
## Alternatives Considered
|
|
|
|
| Recommended | Alternative | Why Not |
|
|
| ---------------------- | ------------------------------------ | ------------------------------------------------------------------------------------------------- |
|
|
| Hono | Express | No native TypeScript ergonomics; no built-in SSE; larger ecosystem but more boilerplate |
|
|
| Hono | Fastify | Good choice but heavier plugin model; Hono's Web Standards alignment is better for this size |
|
|
| Drizzle | Prisma | Binary engine complicates Docker; weaker explicit MariaDB support; heavier |
|
|
| tsdav | Raw fetch + xml2js | CalDAV XML namespace handling is tedious; tsdav is the established TypeScript CalDAV client |
|
|
| ical.js | node-ical | node-ical is a fork that has diverged; ical.js is the Mozilla-maintained reference implementation |
|
|
| @hono/oidc-auth | express-openid-connect | express-openid-connect is Express-specific; Hono middleware is the correct fit |
|
|
| SSE | WebSockets | WebSockets are bidirectional; list sync is server→client only; SSE is simpler and proxy-friendly |
|
|
| CalDAV | JMAP | JMAP calendars not available on Fastmail as of 2026 |
|
|
| @playwright/test | playwright-cli alone | playwright-cli lacks storageState save/restore and device presets needed for CI; both coexist |
|
|
| PAT for Gitea registry | secrets.GITEA_TOKEN / built-in token | Gitea does not inject a built-in token with container-registry push scope; PAT required |
|
|
| @googleapis/calendar | googleapis (monolithic) | Monolithic bundles 170+ clients; scoped package is Calendar-only; same typed API, fraction of size |
|
|
| Hand-rolled CalendarProvider interface | cross-provider normalization library | No maintained library exists; hand-rolled interface stays under project control |
|
|
|
|
---
|
|
|
|
## What NOT to Use
|
|
|
|
| Avoid | Why | Use Instead |
|
|
| ---------------------------------------------------- | ----------------------------------------------------------------- | --------------------------------------------------------- |
|
|
| JMAP for calendars | Not implemented by Fastmail; spec not finalized | CalDAV via tsdav |
|
|
| Prisma | Binary engine, weaker MariaDB compat, larger footprint in Docker | Drizzle ORM |
|
|
| oidc-client-ts | Browser-side OIDC library; wrong layer for a backend-session app | @hono/oidc-auth |
|
|
| node-ical | Older fork of ical.js, less maintained, weaker RRULE handling | ical.js |
|
|
| Create React App | Deprecated February 2025 | Vite |
|
|
| PostgreSQL | Not in the Unraid stack; hard constraint | MariaDB |
|
|
| NestJS | Massive framework overhead for a two-user household app | Hono |
|
|
| Firebase/FCM as push broker | Third-party dependency; VAPID direct push works without it | web-push (VAPID) |
|
|
| `mysqladmin ping` health check with MariaDB 11 | mysqladmin not shipped in mariadb:11 image; silently blocks CI | `healthcheck.sh --connect --innodb_initialized` |
|
|
| `runs-on: ubuntu-latest` on Gitea self-hosted runner | Label only resolves on GitHub's hosted infrastructure | `runs-on: self-hosted` (or the runner's registered label) |
|
|
| Any validation library for setup wizard | zod + mysql2 + web-push + Node 22 fetch cover all checks natively | Use existing stack |
|
|
| `googleapis` (monolithic npm package) | Bundles 170+ API clients; ~50 MB for Calendar-only use | `@googleapis/calendar` + `google-auth-library` |
|
|
| `next-themes` or any theming library | Adds indirection over CSS data-theme + Zustand persist (already in stack) | CSS `[data-theme="dark"]` + Zustand `persist` middleware |
|
|
| `drizzle-kit push` in production / MariaDB 11 | Schedules destructive schema diffs; banned on MariaDB 11 | `drizzle-kit generate` + `migrate()` at runtime |
|
|
| FCM/Firebase for Google Calendar push | Google Calendar push = REST polling / webhooks; unrelated to VAPID push | Use existing `web-push` for app notifications; Google Calendar webhooks are separate |
|
|
|
|
---
|
|
|
|
## Version Compatibility
|
|
|
|
| Package | Compatible With | Notes |
|
|
| ---------------------------- | ---------------------- | ------------------------------------------------------------------------------------------ |
|
|
| drizzle-orm@0.45.x | mysql2@3.x | Use `drizzle-orm/mysql2` import path; mysql2@3.x uses Promises API by default |
|
|
| drizzle-orm/mysql2/migrator | drizzle-orm@0.45.x, mysql2@3.x | Single connection required (not pool); `multipleStatements: true` for MariaDB 11 |
|
|
| vite-plugin-pwa@1.3.x | Vite@8.x, Workbox@7.x | vite-plugin-pwa 0.16+ requires Node 16+; 1.x tracks Vite 6+ |
|
|
| @hono/oidc-auth@1.8.x | hono@4.x, oauth4webapi | Peer-depends on hono 4.x |
|
|
| ical.js@2.x | rrule@2.8.x | Use together: ical.js parses the RRULE string, pass to `new RRule(RRule.parseString(...))` |
|
|
| web-push@3.6.x | Node.js 18+ | VAPID uses Web Crypto; works in Node.js 18+ natively |
|
|
| @playwright/test@1.60.x | Node.js 18+ | Install Chromium only in CI (`npx playwright install --with-deps chromium`) |
|
|
| mariadb:11 service container | GitHub/Gitea Actions | Health check must use `healthcheck.sh`; `mysqladmin` removed in 11.x |
|
|
| google-auth-library@10.7.0 | Node.js 18+, TypeScript 5.x | Ships own types; no @types/ needed; OAuth2Client auto-refreshes expired access tokens |
|
|
| @googleapis/calendar@15.0.0 | googleapis-common@^8.0.0 | Auto-installed as transitive dep; do not pin googleapis-common separately |
|
|
| zustand/middleware `persist` | zustand@5.0.x | Built-in middleware; no separate import package; works with localStorage in PWA |
|
|
| Schedule-X `--sx-color-*` | tokens.css `[data-theme]` | All Schedule-X color vars already map to project tokens; dark overrides cascade automatically |
|
|
|
|
---
|
|
|
|
## Open Questions Flagged for Phase Research
|
|
|
|
1. **Personal calendar cross-account sharing (LOW confidence):** Does the wife's personal Fastmail calendar (if she has a separate account) appear in the broker token's CalDAV principal discovery? This must be manually tested before committing to the personal-calendar overlay in Phase 1. If it does not work, the v1 fallback is: shared family calendar only, with a read-only ICS subscription URL for the wife's personal calendar displayed separately.
|
|
|
|
2. **Declarative Web Push server-side format:** The `web-push` npm library does not natively output the `"web_push": 8030` Declarative Web Push JSON format. Verify whether iOS 18.4+ APNs endpoint accepts standard VAPID push payloads (it does for the VAPID transport layer) vs. needing the declarative JSON in the payload body. The answer is: VAPID is the transport; Declarative Web Push is the payload format. Both can coexist in the same push subscription.
|
|
|
|
3. **EU DMA regression:** If either household member is in the EU on iOS 17.4+, PWA standalone mode is broken and push will not work. Confirm geographic context is outside EU — this is the project owner's constraint to verify.
|
|
|
|
4. **Gitea act_runner Docker socket access in Unraid container:** The Unraid Gitea Actions runner container needs `/var/run/docker.sock` mounted for the Docker build job to reach the host daemon. Verify the runner container's compose config has the socket mount before the build workflow runs. Without it, `docker/build-push-action` will fail silently.
|
|
|
|
5. **Playwright mobile tests and DEV_AUTH_BYPASS in CI:** The mobile test harness depends on `DEV_AUTH_BYPASS=true` being available in CI. This means the CI run starts the API with that flag — confirm it is only set in the test environment, never in the production image/deploy step.
|
|
|
|
6. **Google OAuth2 consent screen verification:** Google requires app verification for production OAuth apps requesting Calendar scopes. For a self-hosted household app, the project must be in "testing" mode (max 100 users) or published. For a two-person household, testing mode (unverified) is sufficient; add both Google accounts as test users in Google Cloud Console. No app review needed.
|
|
|
|
7. **Google Calendar webhook push vs. polling:** The Google Calendar API supports webhook push notifications (via `events.watch()`) that POST to a public URL when calendars change. This is more efficient than polling but requires a verified public HTTPS endpoint. The existing ctag-poller pattern (5-min interval) is simpler and sufficient for a two-person household — evaluate webhooks only if polling latency becomes a problem.
|
|
|
|
---
|
|
|
|
## Sources
|
|
|
|
- [Fastmail API Documentation](https://www.fastmail.com/dev/) — Confirmed CalDAV-only for calendars; JMAP calendars not available
|
|
- [Fastmail App Passwords](https://www.fastmail.help/hc/en-us/articles/360058752854-App-passwords) — Scope covers CalDAV; single password covers all calendars in account
|
|
- [Using Fastmail with CalDAV libraries](https://utf9k.net/blog/fastmail-caldav/) — Principal URL pattern, app password auth
|
|
- [Fastmail Calendar Sharing](https://www.fastmail.help/hc/en-us/articles/1500000279781-Sharing-calendars-with-other-users) — Sharing is multi-user-account scoped; cross-account sharing unconfirmed
|
|
- [tsdav npm](https://www.npmjs.com/package/tsdav) — Version 2.2.2 confirmed
|
|
- [ical.js npm](https://www.npmjs.com/package/ical.js) — Version 2.2.1 confirmed; Mozilla-maintained
|
|
- [rrule npm](https://www.npmjs.com/package/rrule) — Version 2.8.1 confirmed
|
|
- [Hono](https://hono.dev/) — Version 4.12.23; Node.js adapter confirmed
|
|
- [Drizzle ORM MySQL](https://orm.drizzle.team/docs/get-started-mysql) — MariaDB via mysql2 confirmed
|
|
- [Drizzle ORM Migrations API](https://mintlify.wiki/drizzle-team/drizzle-orm/api/core/migrations) — `migrate(db, { migrationsFolder })` with mysql2, single connection required
|
|
- [vite-plugin-pwa](https://vite-pwa-org.netlify.app/) — Version 1.3.0; Workbox 7 integration
|
|
- [web-push npm](https://www.npmjs.com/package/web-push) — Version 3.6.7
|
|
- [Meet Declarative Web Push — WebKit](https://webkit.org/blog/16535/meet-declarative-web-push/) — Safari 18.4+, iOS 18.4+ confirmed
|
|
- [PWA iOS Limitations 2026](https://www.magicbell.com/blog/pwa-ios-limitations-safari-support-complete-guide) — iOS 16.4 minimum; home screen required; EU DMA regression
|
|
- [Authelia Express.js Integration](https://www.authelia.com/integration/openid-connect/clients/expressjs/) — Authorization code + PKCE flow; client_secret_basic
|
|
- [@hono/oidc-auth GitHub](https://github.com/honojs/middleware/tree/main/packages/oidc-auth) — Storage-less JWT session cookies; Version 1.8.3
|
|
- [@playwright/test npm](https://www.npmjs.com/package/@playwright/test) — Version 1.60.0 current; storageState, devices registry confirmed
|
|
- [Playwright Authentication docs](https://playwright.dev/docs/auth) — storageState save/restore pattern, worker-scoped fixture
|
|
- [Playwright Emulation docs](https://playwright.dev/docs/emulation) — devices['iPhone 15 Pro'], isMobile, viewport, userAgent presets
|
|
- [Gitea Container Registry docs](https://docs.gitea.com/usage/packages/container) — Registry URL format, PAT required for push
|
|
- [Automating Docker builds with Gitea Actions](https://www.vanmeeuwen.dev/blog/automating-docker-builds-with-gitea-actions) — docker/login-action@v3 + docker/build-push-action workflow pattern
|
|
- [Gitea Official Tutorial — Automating Release Versioning](https://about.gitea.com/resources/tutorials/automating-release-versioning-with-gitea-actions-to-the-gitea-package-registry) — Complete workflow YAML with docker/setup-buildx-action, registry secrets
|
|
- [Gitea runner-images](https://gitea.com/gitea/runner-images) — catthehacker/ubuntu:act-latest as recommended job container
|
|
- [MariaDB 11 health check fix](https://github.com/mage-os/github-actions/issues/365) — mysqladmin removed in mariadb:11.4; healthcheck.sh required
|
|
- [MySQL in GitHub Actions (ovirium.com)](https://ovirium.com/blog/how-to-make-mysql-work-in-your-github-actions/) — Service container pattern; ports, env, options syntax (GitHub-compatible = Gitea-compatible)
|
|
- [DEV Community — Docker-in-Docker with Gitea Actions](https://dev.to/tmlr/the-definitive-guide-to-safe-docker-in-docker-with-gitea-actions-331l) — DinD vs socket-mount tradeoffs; socket-mount recommended for homelab
|
|
- [@googleapis/calendar npm](https://www.npmjs.com/package/@googleapis/calendar) — Version 15.0.0, verified 2026-06-19; depends only on googleapis-common
|
|
- [google-auth-library npm](https://www.npmjs.com/package/google-auth-library) — Version 10.7.0, verified 2026-06-19; ships own TypeScript types
|
|
- [Google Auth Library Node.js — Context7 / googleapis GitHub](https://github.com/googleapis/google-auth-library-nodejs) — OAuth2Client.generateAuthUrl, getToken, setCredentials, tokens event
|
|
- [Google Calendar API — Recurring Events](https://developers.google.com/workspace/calendar/api/guides/recurringevents) — RRULE recurrence array, instance fields, singleEvents param, series delete
|
|
- [Google Calendar API — Reminders](https://developers.google.com/workspace/calendar/concepts/reminders) — overrides array, method types, useDefault=false requirement
|
|
- [pnpm audit CLI](https://pnpm.io/cli/audit) — --fix=update (pnpm v11+) vs --fix=override default
|
|
- [pnpm outdated CLI](https://pnpm.io/cli/outdated) — --recursive workspace support
|
|
- [Schedule-X Theme Docs](https://schedule-x.dev/docs/calendar/theme) — --sx-color-* CSS variable list; no built-in dark mode
|
|
|
|
---
|
|
|
|
_Stack research for: FamilySync — self-hosted family calendar + shared-lists PWA_
|
|
_Researched: 2026-06-03 (v1.0 baseline) / 2026-06-10 (v1.1 Operability & Polish) / 2026-06-19 (v1.2 Multi-Provider, Theming & Zero-Setup)_
|