Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
9.3 KiB
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.<domain>. 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.jsonnext tosettings.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.
- The list belongs in the template, e.g.
- 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.
- Measured: a stranger reached nothing, including socket.io and
Build plan (if the operator says go)
Session 1: the controller (one release).
family.json: members with bcrypt hashes; add, reset and remove; removing a member revokes their sessions.- A dashboard card "Család", in both languages, with a member list and a password shown once.
- A family session on the dashboard host, as a separate cookie that never opens the dashboard.
- The
/__family/loginpage, with lock-out per visitor (clientIP) and logout. ServeGateAuthgrows a permanent mode: for an app withfamily_gate, a valid family app-cookie → 200, otherwise the same 302/401 as today, then the token handshake.- A traefik file per gated app (the setup gate's writer), plus an ungated router for each anchored exception.
- Tests, red-proofed, for each exit item. Live on 9202 against items 1–5.
Session 2: the catalog.
family_gate:withexcept:in the.felhom.ymlformat and its gate.- 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. - MeTube: a new template plus its checklist record, published behind the gate.
- 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
PathPrefixis 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/initializationtakes ~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, thegm-internalnetwork and thegm_spike_dbvolume. - Images removed: grimmory, metube, alpine:3.20, mariadb:11.4.
- Deleted:
/root/spikeand traefik'sdynamic/spike-family.yml. - Checked afterwards: no
spikecontainer or volume is left.
- Removed with
- Host: nothing was provisioned on demo-hp itself; 9202's disk is its own.
- Hub: nothing. 9202 is not enrolled.