docs: v1.1 research (stack/features/architecture/pitfalls/summary)

This commit is contained in:
Lucas Berger
2026-06-10 21:34:42 -04:00
parent 9bd0f536fd
commit a31636e718
5 changed files with 1291 additions and 1085 deletions
+189 -4
View File
@@ -1,12 +1,171 @@
# Stack Research
**Domain:** Self-hosted family calendar + shared-lists PWA on Fastmail
**Researched:** 2026-06-03
**Researched:** 2026-06-03 (v1.0) / 2026-06-10 (v1.1 additions)
**Confidence:** MEDIUM-HIGH (calendar sharing cross-account caveat: LOW; rest HIGH)
---
## Recommended Stack
## 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
@@ -45,6 +204,7 @@
| 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** |
---
@@ -61,8 +221,12 @@ npm install web-push zod openid-client
npm install react react-dom @tanstack/react-query zustand
npm install -D vite vite-plugin-pwa
# Dev
# 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
```
---
@@ -204,6 +368,8 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
| @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 |
---
@@ -219,6 +385,9 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
| 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 |
---
@@ -231,6 +400,8 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
| @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 |
---
@@ -242,6 +413,10 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
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.
---
## Sources
@@ -261,8 +436,18 @@ Redis (`ioredis`) is only needed if multiple Node containers run behind a load b
- [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
---
*Stack research for: FamilySync — self-hosted family calendar + shared-lists PWA on Fastmail*
*Researched: 2026-06-03*
*Researched: 2026-06-03 (v1.0 baseline) / 2026-06-10 (v1.1 Operability & Polish additions)*