- PWA build: CLEAN — 1818 modules, sw.js + workbox generated (build SHA 40dfbb4)
- API build: CLEAN — tsc passed, no errors
- 03-GATE2-RESULTS.md created with deploy header (URL TBD), full Gate 2 checklist
(all rows marked PENDING — operator/device), and operator-setup section covering:
Authelia OIDC client registration, OIDC_AUTH_EXTERNAL_URL, Pangolin/Newt Mode A rig,
DB schema push, and /health tunnel verification
8.5 KiB
Phase 3 Gate 2 — Live Verification Results
Header
| Field | Value |
|---|---|
| Deploy URL | TBD — operator must configure (see Operator Setup below) |
| Build SHA | 40dfbb4 |
| Build date | 2026-06-05T22:50:16Z |
| PWA build | CLEAN — 1818 modules, dist/sw.js + workbox generated |
| API build | CLEAN — tsc passed, no errors |
Operator Setup Required Before Gate 2
The following steps require operator credentials/access and cannot be automated by the executor. Complete all steps before proceeding to the checklist below.
1. Register FamilySync as an Authelia OIDC confidential client
Generate a hashed client secret:
authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72
# Record BOTH the plaintext (for OIDC_CLIENT_SECRET) and the hash (for Authelia config).
Add to Authelia configuration.yml under identity_providers.oidc.clients:
identity_providers:
oidc:
clients:
- client_id: 'familysync-dev' # Use 'familysync' for Unraid prod (Mode B)
client_name: 'FamilySync'
client_secret: '$pbkdf2-sha512$...' # The HASH from the command above
public: false
authorization_policy: 'one_factor'
redirect_uris:
- 'https://familysync-dev.DOMAIN/callback' # Replace DOMAIN; Mode B: familysync.DOMAIN
scopes: [openid, profile, email]
response_types: [code]
grant_types: [authorization_code, refresh_token]
token_endpoint_auth_method: client_secret_basic
require_pkce: true
pkce_challenge_method: S256
Reload Authelia: docker restart authelia (or your reload mechanism).
2. Set OIDC_AUTH_EXTERNAL_URL in the app's .env
OIDC_AUTH_EXTERNAL_URL is mandatory behind Pangolin. Without it, @hono/oidc-auth builds
redirect_uri from the internal container hostname, which will not match the registered URI and
will cause a 400 from Authelia.
OIDC_AUTH_EXTERNAL_URL=https://familysync-dev.DOMAIN # Mode A test rig
# (Mode B: https://familysync.DOMAIN)
OIDC_CLIENT_ID=familysync-dev
OIDC_CLIENT_SECRET=<plaintext from the crypto hash step>
OIDC_REDIRECT_URI=https://familysync-dev.DOMAIN/callback
Also ensure:
NODE_ENV=productionis set in the container — this forcesdevBypassActive=falseinapps/api/src/index.ts, mounting the OIDC guard unconditionally.DEV_AUTH_BYPASSis absent (or unset) from the production environment block. Even if accidentally present,NODE_ENV=productionsuppresses it at the first conditional indevBypass.ts, but leave it out to keep the config unambiguous.
3. Expose via Pangolin / Newt (Mode A local rig)
# Run Newt on your dev box pointing at the Pangolin site token issued for this host:
docker run -d --name newt --restart unless-stopped \
-e PANGOLIN_ENDPOINT=https://pangolin.DOMAIN \
-e NEWT_ID=<site-id> -e NEWT_SECRET=<site-secret> \
fosrl/newt:latest
In Pangolin, create a route:
- Host:
familysync-dev.DOMAIN - Upstream:
http://<api-host>:3000 - Pangolin's own auth: OFF — FamilySync does Authelia OIDC at the app layer.
- Response buffering: OFF; idle/read timeout: >= 120s (required for SSE).
4. Apply database schema (first deploy only)
docker compose up -d mariadb
DB_HOST=127.0.0.1 DB_PORT=3306 DB_USER=familysync DB_NAME=familysync DB_PASSWORD=<value> \
pnpm --filter @familysync/api exec drizzle-kit push
# Verify: SHOW TABLES; -> users, member_credentials, calendars, calendar_events
5. Bring up the app and confirm /health over the tunnel
docker compose up -d --build
# Local sanity:
curl -s http://localhost:3000/health # expect: {"ok":true,"db":"up"}
# Through the tunnel (record this result in the checklist below):
curl -s https://familysync-dev.DOMAIN/health # expect: {"ok":true,"db":"up"}
Update the Deploy URL at the top of this file once confirmed.
Gate 2 Checklist
Run the checklist from an external network (phone on cellular is ideal). Mark each row PASS or FAIL and add notes. On failure, apply the indicated remedy and retest.
Part A — Auth, Session, Colors (Task 2)
| # | Ref | Check | Result | Notes |
|---|---|---|---|---|
| A1 | AUTH-01 | Open https://familysync-dev.DOMAIN → redirects to Authelia → login completes → land on the app with name, color, and at least one cached event |
[ ] PENDING — operator | |
| A2 | AUTH-02 | Fully close + reopen browser → revisit the URL → no re-login prompted (session persists) | [ ] PENDING — operator | |
| A3 | AUTH-03 | Second member logs in on a separate device → distinct stable color assigned (different from first member's color) | [ ] PENDING — operator/device |
Part B — iOS PWA Standalone Login (Task 2) — LOAD-BEARING CHECK
This is the most critical row. Pitfall 2: If the OIDC redirect breaks out of standalone mode (user lands in Safari instead of the app), the non-technical member cannot log in. Confirm this passes before recording any other rows as done.
Remedy if it fails: Verify
manifest.webmanifesthasscope: "/"andstart_url: "/"; confirm/callbackis in the service worker denylist (apps/pwa/src/sw-denylist.ts) and is not intercepted by Workbox; redeploy and retest.
| # | Ref | Check | Result | Notes |
|---|---|---|---|---|
| B1 | iOS PWA | Open https://familysync-dev.DOMAIN in Safari on iPhone → in-app install walkthrough appears → tap "Add to Home Screen" |
[ ] PENDING — device | |
| B2 | iOS PWA | Launch FamilySync from Home Screen → opens full-screen with no Safari browser chrome (standalone mode) | [ ] PENDING — device | |
| B3 | iOS PWA (Pitfall 2) | Complete Authelia OIDC login from standalone mode → redirect does NOT break out of standalone (user stays in the app, not dropped to Safari) | [ ] PENDING — device | Load-bearing check |
| B4 | PWA-01 | Installed PWA on iOS opens full-screen with no browser chrome | [ ] PENDING — device | |
| B5 | PWA-02 | Installed PWA on Android opens full-screen with no browser chrome | [ ] PENDING — device |
Part C — SSE Smoke Test (Gate before Phase 4)
| # | Ref | Check | Result | Notes |
|---|---|---|---|---|
| C1 | SSE | Hold stream open 5+ min without it being cut (see curl command below) | [ ] PENDING — operator |
# Get the session cookie from browser DevTools → Application → Cookies (oidc-auth=<value>)
curl -N -H "Cookie: oidc-auth=<value>" https://familysync-dev.DOMAIN/api/sse/heartbeat
# PASS: heartbeat event received ~every 10s for 5+ minutes
# FAIL: stream cut early → adjust Pangolin idle-timeout; if still failing, record as Phase 4 constraint
Part D — Create / Edit / Delete Fastmail Round-trips (Task 3)
| # | Ref | Check | Result | Notes |
|---|---|---|---|---|
| D1 | CAL-04 | Create a timed event → "Syncing…" toast → "Saved" toast → event appears in native Fastmail app on next sync | [ ] PENDING — operator | |
| D2 | CAL-07 | Create an all-day event → same Syncing→Saved flow → appears in Fastmail | [ ] PENDING — operator | |
| D3 | CAL-04 | Create a weekly recurring event → appears in Fastmail | [ ] PENDING — operator | |
| D4 | CAL-05 | Edit an existing event's title and time → Syncing→Saved → change persists in Fastmail | [ ] PENDING — operator | |
| D5 | CAL-06 | Delete an event via the two-tap confirmation dialog → Syncing→Saved → event disappears from all views on next sync | [ ] PENDING — operator | |
| D6 | D-08 | (Optional) Trigger a 412 conflict by editing the same event in Fastmail first → conflict toast appears in the app → calendar re-fetches | [ ] PENDING — operator | Optional |
/health Tunnel Verification
Record the curl result through the public URL here:
URL tested: https://familysync-dev.DOMAIN/health
Result: [ ] PENDING — operator
Response body: <fill in>
Summary
| Section | Status |
|---|---|
| Production builds (PWA + API) | CLEAN (automated, 2026-06-05) |
| Operator infra setup | PENDING |
| A — Auth / session / colors | PENDING |
| B — iOS standalone login (load-bearing) | PENDING |
| C — SSE smoke test | PENDING |
| D — Fastmail write round-trips | PENDING |
Gate 2 is complete when all rows are PASS. Record final status here:
Gate 2 outcome: [ ] PENDING