# Permanent household gate with family accounts — VERDICT **The spike PASSES: exit items 1–5 all pass, measured on 9202 on 2026-10-01 between 19:19 and 19:25 UTC.** The exit test (`EXIT-TEST.md`) was committed at 19:13 UTC (felhom.eu `7c50dba`), before anything was built or measured. Operator ruling `09` §3 decision 63 (option A). **Nothing was built into the product. The build waits for the operator's go.** **Method.** The gate was a throwaway forwardAuth service (`familygate/main.go`, about 230 lines of Go; it never ran outside 9202). It read the visitor by controller v0.286.0's rule. Grimmory v3.4.1 (with MariaDB 11.4) and MeTube 2026.09.29 were started by hand with `docker compose` on 9202, from `familygate/spike-compose.yml`. They were not from the drill catalog: MeTube has no template, and a hand compose keeps the live catalog untouched either way. Requests were made through 9202's traefik, both from the LAN and through the simulated tunnel (a container at cloudflared's fixed address `172.16.253.2`, the same method as Part A). ## Exit items | # | Item | Result | Measured | |---|---|---|---| | 1 | **A stranger reaches nothing.** | **PASS** | 18 paths × 2 routes (LAN and tunnel) = 36 stranger requests. All 36 got the gate's answer (302 to the sign-in page, or 401) and **0 reached an app**. The paths covered the front page, the API, setup, the app's own login, Grimmory's `/ws` websocket, MeTube's socket.io (polling and websocket upgrade), `/add`, `/download`, static files and an unknown path. Evidence: `items-1-2.txt`. | | 2 | **Each family member has their own login. It lasts days. Logout works.** | **PASS** | Anna and Béla each signed in with their own password, not the dashboard's, and got the apps (200). MeTube's websocket upgrade gave Béla **101**; a stranger got 401 (`item-2-websocket.txt`). The cookie lasts 30 days (`Max-Age 2592000`), is HttpOnly, Secure and SameSite=Lax, and has no Domain attribute, so it is host-only. It survived a gate restart. After logout the old cookie was refused. A wrong password got 401 and no cookie. One app's cookie did not open another app (each app host has its own session). | | 3 | **A stranger's wrong guesses lock only the stranger.** | **PASS** | The stranger tried 7 times through the tunnel, with a new forged leftmost address each time. Tries 1–5 got 401; from try 6 on, 429. Even Anna's right password got **429** while that visitor was locked. Then Anna from `203.0.113.10` (tunnel) and Béla from the LAN both signed in **at once** (302 + cookie). The gate's log counted the stranger at his real address, not the forged ones (`item-3.txt`). | | 4 | **Grimmory's e-reader paths work through a per-app path exception, and the app's own login still applies there.** | **PASS, with one build requirement** | With no family cookie, through the tunnel or the LAN: OPDS v1 with the OPDS user's own login → **200** (the feed); wrong password → 401; no credentials → 401. Kobo `/v1/initialization` and `/v1/library/sync` with the device token → **200** (the first call took ~15 s: Grimmory asks Kobo's store first, then falls back); a made-up token → 401. KOReader `users/auth` with its own user and md5 key → **200**; wrong key → 401; `users/create` (registration) → 401. Komga API with no credentials → 401. Path tricks out of the exception (`../`, `%2e%2e`) → **the gate** (traefik cleans the path before it routes). **Finding F1:** `PathPrefix(/api/v1/opds)` also matched `/api/v1/opdsx`, which then reached the app ungated. Grimmory's own login refused it (401), but a build must anchor every exception: `PathRegexp(^/api/v1/opds(/\|$))`. Evidence: `item-4.txt`, `item-4-setup.txt` (secrets redacted). | | 5 | **The family login cannot reach the box dashboard.** | **PASS** | The family cookie is host-only, so a browser never sends it to `felhom.`. Sent by hand anyway, the dashboard answered 302 to `/login`, and its API answered 401. Anna's family password at the dashboard login got "Hibás jelszó." and no session (`item-5-6.txt`). | | 6 | **Cost.** | measured | **Time per gated request: +0.4 ms.** Median of 60 pairs: 14.6 ms gated vs 14.2 ms on an ungated name for the same service. Inside the controller, the setup gate already measured ~2 ms (decision 46). **Gate's answerer down:** every gated path answers **500**, so it fails closed, not open; the sign-in page answers 502; the e-reader exceptions keep working, because they never asked the gate. **Build cost:** two sessions; see below. | ## Answers to the brief's questions - **Where do family accounts live, and who manages them?** In the controller's data directory, as a `family.json` next to `settings.json`, with bcrypt hashes. That puts them in the controller's own backup and restore, and the hub never sees them. **The household's dashboard admin manages them** from a "Család" (family) card: add a member with a name and a generated password shown once, reset a password, remove a member. Removing a member ends their sessions. Members have no dashboard access of any kind. - **One sign-in for all gated apps, or one per app?** **One sign-in, with a cookie per app.** The spike signed in per app host, and that works, but a family member would then sign in to every app separately. The setup gate already has the right shape: a session on the dashboard host, plus a 60-second, one-use token that mints a host-only cookie for each app (`/__gate/start`). A family session on the dashboard host would mint each app's cookie the same way. - That family session is a different cookie from the household admin session, and it never opens the dashboard (item 5's rule). - Each app still gets its own host-only cookie, so no app's backend ever sees another app's session. - **How are Radicale- and Dawarich-style API clients let through?** With an anchored per-app exception list, as Grimmory's measured here. - The list belongs in the template, e.g. `family_gate.except: ["^/api/v1/opds(/|$)", …]`, and the controller turns it into a router that has no gate. - Dawarich's phone app uses `/api/v1/*` with its API key; its exception keeps the app's own key check. - **Radicale should not be family-gated.** Every request it serves comes from a calendar client. It already has its own login, and the exception would be the whole host. - **Does MeTube become publishable behind it?** **Yes, behind the gate and only behind it.** Its fit verdict R-767 was "stop: no login at all"; the gate becomes its login. - Measured: a stranger reached nothing, including socket.io and `/add`. - Two caveats for its page: every family member shares one MeTube (one queue, one download folder), and downloads fill the drive. It also still needs its own checklist record before publishing (new-app gate). - Grimmory behind the gate makes R-775 doubly settled: Part A already makes its sign-in lock per visitor, and the gate puts the web sign-in out of a stranger's reach. ## Build plan (if the operator says go) **Session 1: the controller (one release).** 1. `family.json`: members with bcrypt hashes; add, reset and remove; removing a member revokes their sessions. 2. A dashboard card "Család", in both languages, with a member list and a password shown once. 3. A family session on the dashboard host, as a separate cookie that never opens the dashboard. 4. The `/__family/login` page, with lock-out per visitor (`clientIP`) and logout. 5. `ServeGateAuth` grows a permanent mode: for an app with `family_gate`, a valid family app-cookie → 200, otherwise the same 302/401 as today, then the token handshake. 6. A traefik file per gated app (the setup gate's writer), plus an ungated router for each anchored exception. 7. Tests, red-proofed, for each exit item. Live on 9202 against items 1–5. **Session 2: the catalog.** 1. `family_gate:` with `except:` in the `.felhom.yml` format and its gate. 2. Grimmory: the template with OPDS, Kobo, KOReader and Komga exceptions, its checklist record (R-775's held template from `audits/new-apps-2026-10-01/wip/grimmory/`), published. 3. MeTube: a new template plus its checklist record, published behind the gate. 4. Both live on 9202 and one demo box. **Not in the build:** an identity app (decision 63, option B), which stays possible later behind the same forwardAuth hook; per-member rights inside an app (the app's own users do that). **What a build inherits from the setup gate's measured costs:** a gated app answers 500 while the controller is restarting or down, which is seconds during a self-update. A phone app reaches a gated app only through its exception list. ## Findings - **F1 (build requirement, not a product defect):** traefik's `PathPrefix` is a plain string prefix. An exception must be anchored, or a look-alike path walks past the gate. Recorded in the build row. - Kobo's first `/v1/initialization` takes ~15 s: Grimmory asks Kobo's store first, then falls back. Not a gate cost. ## Teardown (three layers) - **Machine:** - Removed with `docker compose -p spike down -v`: `spike-gate`, `gm-spike`, `gm-spike-db`, `mt-spike`, the `gm-internal` network and the `gm_spike_db` volume. - Images removed: grimmory, metube, alpine:3.20, mariadb:11.4. - Deleted: `/root/spike` and traefik's `dynamic/spike-family.yml`. - Checked afterwards: no `spike` container or volume is left. - **Host:** nothing was provisioned on demo-hp itself; 9202's disk is its own. - **Hub:** nothing. 9202 is not enrolled.