# Phase 3 Gate 2 — Live Verification Results

## Header

| Field      | Value                                                                                                       |
| ---------- | ----------------------------------------------------------------------------------------------------------- |
| Deploy URL | LIVE via Pangolin/Newt (operator domain) — confirmed reachable; real Authelia OIDC login working 2026-06-07 |
| Build SHA  | 86069b8 (2026-06-07 live bring-up + write-path fixes)                                                       |
| Build date | 2026-06-07                                                                                                  |
| PWA build  | CLEAN — dist/sw.js + workbox generated; 140/140 tests                                                       |
| API build  | CLEAN — tsc passed; 102/102 tests                                                                           |

> **2026-06-07 live verification note.** Gate 2 was executed live against the running
> Docker stack through Pangolin/Newt (Mode A). Several blocker bugs were found and fixed
> during this session (see commits): newt MTU blackhole, OIDC state-cookie churn, event
> write-path timezone + calendar identity, missing calendars join (edit/delete 503),
> delete cache-reconciliation, post-write refetch race, and a calendar remount flash.
> Rows verified below were confirmed via operator browser testing + backend evidence
> (calendar_outbox rows reaching `done` against caldav.fastmail.com). playwright-cli is
> unavailable in this WSL2 env, so desktop rows were operator-driven, not automated.

---

## 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:

```bash
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`:

```yaml
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.

```dotenv
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=production` is set in the container — this forces `devBypassActive=false` in
  `apps/api/src/index.ts`, mounting the OIDC guard unconditionally.
- `DEV_AUTH_BYPASS` is **absent** (or unset) from the production environment block.
  Even if accidentally present, `NODE_ENV=production` suppresses it at the first conditional
  in `devBypass.ts`, but leave it out to keep the config unambiguous.

### 3. Expose via Pangolin / Newt (Mode A local rig)

```bash
# 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)

```bash
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

```bash
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 | ✅ PASS (2026-06-07)  | Real Authelia OIDC login lands on the calendar; name (email claim), assigned color, and cached events render. Name self-heals to full name once Authelia emits name/preferred_username (see backlog/memory).                                                                                                                                                                             |
| A2  | AUTH-02 | Fully close + reopen browser → revisit the URL → no re-login prompted (session persists)                                                         | 🟡 PASS (transparent) | Confirmed (desktop + iPhone): cold open bounces through Authelia but its SSO carries the session, so NO credential prompt — user lands straight on the app. Note: the app's own oidc-auth cookie is session-scoped (dropped on browser close), so each cold open does a redirect round-trip. Acceptable for v1; making the app cookie persistent (skip the bounce) is a minor follow-up. |
| A3  | AUTH-03 | Second member logs in on a separate device → distinct stable color assigned (different from first member's color)                                | ✅ PASS (2026-06-07)  | Second member (amelia, id=3) logged in on her iPhone. Found + fixed a collision bug (both members were #E8734A — COUNT%palette reused a slot after a deletion); now luc=#E8734A, amelia=#4A90D9 (distinct, stable). Fix: first-unused-palette-color (commit f700182).                                                                                                                    |

### 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.webmanifest` has `scope: "/"` and `start_url: "/"`;
> confirm `/callback` is 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"                     | ✅ PASS (2026-06-07) | Wife added FamilySync to her iPhone Home Screen and logged in (user id=3 created).                                  |
| B2  | iOS PWA             | Launch FamilySync from Home Screen → opens full-screen with no Safari browser chrome (standalone mode)                                       | ✅ PASS (2026-06-07) | Confirmed: launches full-screen standalone from Home Screen.                                                        |
| 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) | ✅ PASS (2026-06-07) | Confirmed working — OIDC login from standalone stays in the app, no drop to Safari. **Load-bearing check cleared.** |
| B4  | PWA-01              | Installed PWA on iOS opens full-screen with no browser chrome                                                                                | ✅ PASS (2026-06-07) | Confirmed (same as B2).                                                                                             |
| B5  | PWA-02              | Installed PWA on Android opens full-screen with no browser chrome                                                                            | [ ] PENDING — device | Android install not yet exercised.                                                                                  |

### 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) | ✅ PASS (2026-06-08) | Held GET /api/sse/heartbeat open ~6 min over familysync-dev.bergerhouse.net (Pangolin→Newt→api) with a valid session cookie (01:37:53Z→01:43:54Z); 35 heartbeat events id 0→34 at ~10s cadence; response bytes grew 71→2535 (incremental → Pangolin buffering OFF); no early cut. Phase 4 entry gate (D-14 / issue #1034) CLEARED. Caveat: proves no idle-timeout/buffering over ~6 min, not the absence of a max total connection-duration cap — residual risk covered by Phase 4 design (D-10/D-11/D-12 in 04-CONTEXT.md). |

```bash
# 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                             | ✅ PASS (2026-06-07)                        | Timed create round-trips to caldav.fastmail.com (outbox rows reach `done`); appears in the app. Timezone fix applied (was 4h off).                                                                                                                                                                                                                                                                                                                                                           |
| D2  | CAL-07 | Create an all-day event → same Syncing→Saved flow → appears in Fastmail                                                                 | ✅ PASS (2026-06-07)                        | All-day create round-trips to Fastmail (verified VEVENT: DTSTART/DTEND VALUE=DATE, exclusive end). Found + fixed a display off-by-one (single-day showed across 2 days — Schedule-X inclusive vs iCal exclusive end; commit d4d5327). Reload to confirm 1-day rendering.                                                                                                                                                                                                                     |
| D3  | CAL-04 | Create a weekly recurring event → appears in Fastmail                                                                                   | ✅ PASS — write correct; UX gaps backlogged | A weekly event was created and recurred in Fastmail with a valid `RRULE:FREQ=WEEKLY`. Two UX gaps surfaced (NOT write-correctness): no "repeat until/count" bound (series is unbounded → recurs into 2028+) and the end-date is the per-occurrence duration (a 2-month end made each occurrence 63 days → overlapping every day). Backlogged 999.7/999.8. Deleting the recurring series cleared the master + all occurrences from Fastmail in one delete (recurring-series delete verified). |
| D4  | CAL-05 | Edit an existing event's title and time → Syncing→Saved → change persists in Fastmail                                                   | ✅ PASS (2026-06-07)                        | Edit/move confirmed working; update outbox rows reach `done`; post-write refetch race fixed so the change shows without manual refresh.                                                                                                                                                                                                                                                                                                                                                      |
| D5  | CAL-06 | Delete an event via the two-tap confirmation dialog → Syncing→Saved → event disappears from all views on next sync                      | ✅ PASS (2026-06-07)                        | Delete confirmed working; delete cache-reconciliation fix means the event leaves the cache/UI (was lingering as a ghost).                                                                                                                                                                                                                                                                                                                                                                    |
| 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 | ✅ PASS (2026-06-07)                        | Observed live: a stale-etag update produced `412 conflict` (outbox id=7) and the "This event changed elsewhere" conflict toast; calendar re-syncs.                                                                                                                                                                                                                                                                                                                                           |

---

## /health Tunnel Verification

Record the curl result through the public URL here:

```
URL tested:    https://<operator-domain>/health (via Pangolin/Newt) + http://localhost:3000/health
Result:        ✅ PASS (2026-06-07) — app reachable through the tunnel; real OIDC login completed
Response body: {"ok":true,"db":"up"}
```

---

## Summary

| Section                                 | Status                                                                                                                                 |
| --------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------- |
| Production builds (PWA + API)           | ✅ CLEAN (2026-06-07; 102 API + 140 PWA tests)                                                                                         |
| Operator infra setup                    | ✅ DONE (Authelia client + Pangolin/Newt live; OIDC login working)                                                                     |
| A — Auth / session / colors             | ✅ A1, A2 (transparent SSO), A3 all PASS                                                                                               |
| B — iOS standalone login (load-bearing) | ✅ B1–B4 PASS (install + standalone launch + standalone login); B5 (Android) deferred                                                  |
| C — SSE smoke test                      | ✅ PASS (2026-06-08) — Phase 4 ENTRY gate (D-14 / issue #1034) CLEARED; held ~6 min, 35 heartbeats, incremental delivery, no proxy cut |
| D — Fastmail write round-trips          | ✅ D1–D6 PASS (create/all-day/recurring/edit/delete/conflict); recurring-series delete also verified                                   |

Gate 2 is complete when all rows are PASS. Record final status here:

**Gate 2 outcome:** ✅ COMPLETE for Phase 03 scope (2026-06-07) — auth, session, distinct member
colors, iOS install + standalone login (load-bearing), and all write round-trips (create / all-day /
weekly recurring / edit / delete / 412-conflict, incl. recurring-series delete) verified live.
Many blocker bugs found + fixed this session (see git log). Recurring create writes valid RRULE;
its repeat-bound + per-occurrence-duration UX are tracked as backlog 999.7/999.8 (within the v1
"recurring create+display only" scope). Deferred by design: B5 (Android install) and C (SSE smoke —
Phase 4 entry gate per D-14). Phase 03 is code-complete and live-verified.
