Grimmory and MeTube published behind the family gate (controller >= 0.287.0, decisions 63/64), with complete records
gates / gates (push) Successful in 3s

- family_gate / family_gate_except / min_controller format (README, REUSE); gate family-gate + decoys
- templates/grimmory (v3.5.0, exceptions OPDS/Kobo/KOReader/Komga) + onboarding/grimmory.md
- templates/metube (2026.09.29, no exceptions; ladder .28 -> .29) + onboarding/metube.md; fixture MeTube
- volume-persistence gate: routed port read from the label NAME (R-801, red-proofed); APP_EXERCISE (R-788)
- box_walk: family_cookie; no cached "not gated" while an app has no router

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0159rPz1ZhFKsS53msqPYxtS
This commit is contained in:
2026-10-02 09:36:49 +02:00
parent de0a9bd29f
commit 96829d0d0f
20 changed files with 1078 additions and 20 deletions
+28 -1
View File
@@ -130,6 +130,14 @@ def gate_cookie(sub):
sess = open(f"{SC}/sess{os.getpid()}.txt").read().strip()
r = sh(["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", "Accept: text/html", "-H", f"Host: {sub}.{DOMAIN}", f"{BASE}/"])
loc = _loc(r.stdout)
st = re.match(r"HTTP/\S+ (\d+)", r.stdout or "")
if not loc and (not st or st.group(1) in ("404", "502", "503", "504")):
# the app is not routed at this moment (a restart, an update's stop): NOT the same as "not gated" — a cached ""
# here sent every later call past nothing and read the gate's 401 as the app's (2026-10-02, grim-step)
say(f" gate: {sub} not routed right now ({st.group(1) if st else 'no answer'}) — not cached, asked again next call")
return ""
if "/__family/start" in loc: # v0.287.0: the FAMILY gate — the household's dashboard session vouches, as for
return family_cookie(sub, loc, sess) # the setup gate (decisions 63/64)
if "/__gate/start" not in loc:
GATE[sub] = ""
return "" # not gated (open, or no gate for this app)
@@ -148,6 +156,25 @@ def gate_cookie(sub):
return GATE[sub]
def family_cookie(sub, loc, sess):
"""Pass the FAMILY gate (controller >= 0.287.0) as the household: /__family/start on the dashboard host with the
dashboard session → the app host's /__felhom_gate/fcb → `felhom_famgate`. Never printed."""
import urllib.parse
u = urllib.parse.urlsplit(loc)
r = sh(["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", "Accept: text/html", "-H", f"Host: {u.hostname}", "-H", f"Cookie: {sess}",
f"{BASE}{u.path}?{u.query}"])
u = urllib.parse.urlsplit(_loc(r.stdout))
if "/__felhom_gate/fcb" not in u.path:
say(f" family gate: the dashboard did not hand back a callback for {sub}")
GATE[sub] = ""
return ""
r = sh(["curl", "-sk", "-D", "-", "-o", "/dev/null", "-H", "Accept: text/html", "-H", f"Host: {u.hostname}", f"{BASE}{u.path}?{u.query}"])
m = re.search(r"(?im)^set-cookie:\s*(felhom_famgate=[^;\r\n]+)", r.stdout or "")
GATE[sub] = m.group(1) if m else ""
say(f" family gate: {sub} is family-gated — passed as the household (cookie {'set' if GATE[sub] else 'NOT set'})")
return GATE[sub]
def app_curl(sub, path, *extra, method=None, data=None, timeout=45, _retry=True):
"""A call to the APP's own front door on 9202 — the household's route, not ours. Carries the setup
gate's cookie when the app is gated, merged into a fixture's own Cookie header (never a second one)."""
@@ -172,7 +199,7 @@ def app_curl(sub, path, *extra, method=None, data=None, timeout=45, _retry=True)
r = sh(args, timeout=timeout + 30, inp=data)
body, _, tail = (r.stdout or "").rpartition("\n")
code, _, redir = tail.strip().partition(" ")
if _retry and "/__gate/start" in redir:
if _retry and ("/__gate/start" in redir or "/__family/start" in redir):
GATE.pop(sub, None) # the gate cookie expired or was never taken — log in as the household again
return app_curl(sub, path, *raw, method=method, data=data, timeout=timeout, _retry=False)
return r.returncode, code.strip(), body
+3
View File
@@ -114,6 +114,9 @@ GATES = [
# exists. Static, files only, so it is --fast and bites in the hook AND in CI. The 53 apps published before
# the checklist are exempt by name inside the script.
("onboarding", "check-onboarding.py", False, True, False),
# 2026-10-02 (`09` §3 decisions 63/64): a family-gated template's exceptions are literal prefixes, it declares
# min_controller >= 0.287.0, and the newest baked golden knows the gate. Static, files only.
("family-gate", "check-family-gate.py", False, True, False),
]
VERDICT = {0: "OK", 1: "FAILED", 2: "INCONCLUSIVE"}
+146
View File
@@ -0,0 +1,146 @@
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""check-family-gate.py — the family gate's template fields are safe to publish (`09` §3 decisions 63/64, controller 0.287.0).
A template with `family_gate: true` is published ONLY behind the household's family gate. Three ways that goes wrong, each
refused here:
1. an exception (`family_gate_except:`) that is not a LITERAL path prefix — a regex, a traefik matcher, `..`, `//`, or `/`
itself. The controller anchors each prefix at a path-segment boundary (`^/prefix(/|$)`, finding F1 of the spike: an
unanchored `PathPrefix(/api/v1/opds)` let `/api/v1/opdsx` past the gate) and refuses the install on anything else —
this gate refuses it at push time instead of at a household's install;
2. `family_gate: true` without `min_controller: "0.287.0"` (or newer) — the controller refuses a template that needs a
newer box, but only if the template SAYS so;
3. `family_gate: true` while the newest baked golden is older than 0.287.0 — a box installed from that golden runs a
controller that does not know `family_gate` and would publish the app OPEN. Read from the sibling `felhom.eu`
checkout (`documentation/tests/golden-<VER>-<DATE>/` holding a bake log with a `GOLDEN_SHA256=` line — a directory
NAME is not a bake, R-410). The sibling absent (CI's single clone) → printed as NOT CHECKED, never as a pass of rule 3.
Also refused: `family_gate_except:` on a template without `family_gate: true` (an exception list with no gate is a label
without the fact).
Line-based on purpose: the catalog's CI has NO PyYAML. Only TOP-LEVEL, uncommented keys count; a key inside a comment, a
README or a tagline is not the field.
Run from the repo root: python3 scripts/check-family-gate.py [--root=<catalog>] [--felhom-eu=<sibling>]
Exit 0 clean · 1 refused. Decoys: scripts/test_gate_decoys.py (family-gate).
"""
import io
import os
import re
import sys
MIN_VERSION = (0, 287, 0)
LITERAL = re.compile(r"^/[A-Za-z0-9._~/-]*$")
TOP_TRUE = re.compile(r"^family_gate:\s*true\s*(#.*)?$")
TOP_EXCEPT = re.compile(r"^family_gate_except:\s*(#.*)?$")
TOP_MINC = re.compile(r'^min_controller:\s*"?([0-9]+\.[0-9]+\.[0-9]+)"?\s*(#.*)?$')
ITEM = re.compile(r'^\s+-\s*(?:"([^"]*)"|\'([^\']*)\'|(\S+))\s*(#.*)?$')
GOLDEN_DIR = re.compile(r"^golden-(\d+)\.(\d+)\.(\d+)-\d{4}-\d{2}-\d{2}$")
GOLDEN_SHA = re.compile(r"GOLDEN_SHA256=[0-9a-f]{64}")
BAKE_LOGS = ("bake.log", "06-bake.log", "bake-clean.log", "06-bake-clean.log")
def arg(name, default):
for a in sys.argv[1:]:
if a.startswith(name + "="):
return a.split("=", 1)[1]
return default
def parse(text):
"""(family_gate, excepts or None, min_controller tuple or None) from a .felhom.yml text."""
gate, excepts, minc = False, None, None
lines = text.splitlines()
for i, ln in enumerate(lines):
if TOP_TRUE.match(ln):
gate = True
m = TOP_MINC.match(ln)
if m:
minc = tuple(int(x) for x in m.group(1).split("."))
if TOP_EXCEPT.match(ln):
excepts = []
for nxt in lines[i + 1:]:
if not nxt.strip() or nxt.lstrip().startswith("#"):
continue
mi = ITEM.match(nxt)
if not mi:
break
excepts.append(next(g for g in mi.groups()[:3] if g is not None))
return gate, excepts, minc
def literal_ok(p):
if not LITERAL.match(p) or "//" in p or "/../" in p or p.endswith("/..") or p.strip("/") == "":
return False
return True
def newest_golden(sibling):
tests = os.path.join(sibling, "documentation", "tests")
if not os.path.isdir(tests):
return None
best = None
for name in os.listdir(tests):
m = GOLDEN_DIR.match(name)
if not m:
continue
d = os.path.join(tests, name)
baked = False
for log in BAKE_LOGS:
p = os.path.join(d, log)
if os.path.isfile(p) and GOLDEN_SHA.search(io.open(p, encoding="utf-8", errors="replace").read()):
baked = True
break
if baked:
v = tuple(int(x) for x in m.groups())
best = v if best is None or v > best else best
return best
def main():
root = arg("--root", os.getcwd())
sibling = arg("--felhom-eu", os.path.join(os.path.dirname(os.path.abspath(root)), "felhom.eu"))
tdir = os.path.join(root, "templates")
fails, gated, unchecked = [], [], False
for app in sorted(os.listdir(tdir)):
fy = os.path.join(tdir, app, ".felhom.yml")
if not os.path.isfile(fy):
continue
gate, excepts, minc = parse(io.open(fy, encoding="utf-8").read())
if excepts is not None and not gate:
fails.append("%s: family_gate_except without family_gate: true — an exception list with no gate" % app)
if not gate:
continue
gated.append(app)
for p in excepts or []:
if not literal_ok(p):
fails.append("%s: family_gate_except %r is not a literal path prefix (the controller anchors "
"^/prefix(/|$) and refuses anything else — F1)" % (app, p))
if minc is None or minc < MIN_VERSION:
fails.append("%s: family_gate needs min_controller: \"%d.%d.%d\" or newer (got %s)"
% ((app,) + MIN_VERSION + (minc,)))
if gated:
g = newest_golden(sibling)
if g is None:
print("family-gate: rule 3 NOT CHECKED — no felhom.eu sibling with a baked golden at %s" % sibling)
unchecked = True
elif g < MIN_VERSION:
fails.append("family_gate on %s while the newest baked golden is %s — a box installed from it would publish "
"the app OPEN; bake a golden >= %d.%d.%d first" % ((", ".join(gated), "%d.%d.%d" % g) + MIN_VERSION))
else:
print("family-gate: newest baked golden %d.%d.%d >= %d.%d.%d" % (g + MIN_VERSION))
for f in fails:
print(" REFUSED " + f)
if fails:
print("family-gate gate: %d refusal(s)" % len(fails))
return 1
# The summary line carries the gap: an "OK" read alone must not stand for rule 3 when rule 3 was not checked (the
# 2026-10-02 bench run read exactly that). Exit stays 0 — CI's single clone can never check it; the hook does.
print("family-gate gate OK: %d family-gated template(s) %s%s" % (len(gated), gated,
" — rule 3 (golden >= 0.287.0) NOT CHECKED here" if unchecked else ""))
return 0
if __name__ == "__main__":
sys.exit(main())
+48 -6
View File
@@ -583,6 +583,51 @@ def _exercise(cids, ports, deep=False):
return hits
def routed_ports(resolved):
"""The ports traefik routes to, from `docker compose config --format json`.
That output gives `labels` as a MAPPING (`{"traefik.http.services.x.loadbalancer.server.port": "8081"}`), where the
port's NAME is the key — so each label is read as `key=value`, the shape PORT_RE matches. Reading the values alone
found NO port for any template (2026-10-02, R-801): the gate's HTTP exercise never ran, and every verdict came from
what an app writes at start by itself. Pinned by test_routed_ports_reads_the_mapping_form."""
out = set()
for svc in (resolved.get("services") or {}).values():
labels = svc.get("labels") or {}
items = [f"{k}={v}" for k, v in labels.items()] if isinstance(labels, dict) else [str(l) for l in labels]
for lbl in items:
m = PORT_RE.search(lbl)
if m:
out.add(int(m.group(1)))
return sorted(out)
# APP_EXERCISE — the app's OWN write path, for an app whose GET pages write nothing (R-788, 2026-10-02): MeTube writes
# only when it downloads, so a GET-only exercise leaves both of its mounts empty and the honest verdict is UNDETERMINED.
# Each entry is (method, path, json body); it is sent to every running container's routed port, like `_exercise`, and
# the gate then observes where the data landed as for any app. A request the app refuses writes nothing and the verdict
# stays UNDETERMINED — the exercise cannot turn a non-answer into a pass.
APP_EXERCISE = {
"metube": [("POST", "/add", {"url": "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4",
"quality": "best", "format": "any", "download_type": "video", "auto_start": True})],
}
def _exercise_app(app, cids, ports):
hits = []
for method, path, body in APP_EXERCISE.get(app, []):
for cid in cids:
info = _inspect(cid) or {}
for net in ((info.get("NetworkSettings") or {}).get("Networks") or {}).values():
ip = net.get("IPAddress")
for port in (ports if ip else []):
code = _sh(["curl", "-sS", "-o", "/dev/null", "-w", "%{http_code}", "--max-time", "30", "-X", method,
"-H", "Content-Type: application/json", "--data", json.dumps(body),
f"http://{ip}:{port}{path}"], timeout=60).stdout.strip()
if code and code != "000":
hits.append(f"{method} {ip}:{port}{path} -> {code} (the app's own write path)")
return hits
def docker_prober(app: str, app_dir: Path, settle: int = 45, wait: int = 300) -> dict:
"""Deploy the template, exercise it, and report WHERE the data landed. The Docker seam.
@@ -615,12 +660,7 @@ def docker_prober(app: str, app_dir: Path, settle: int = 45, wait: int = 300) ->
return {"app": app, "error": f"compose config failed: "
f"{(cfg.stderr or cfg.stdout)[:300]}"}
declared = set((resolved.get("volumes") or {}).keys())
ports = sorted({int(m.group(1))
for svc in (resolved.get("services") or {}).values()
for lbl in ((svc.get("labels") or {}).values()
if isinstance(svc.get("labels"), dict)
else (svc.get("labels") or []))
for m in [PORT_RE.search(str(lbl))] if m})
ports = routed_ports(resolved)
up = _sh(base + ["up", "-d"], timeout=1800)
cids = [c for c in _sh(base + ["ps", "-aq"], timeout=120).stdout.split() if c]
@@ -643,6 +683,8 @@ def docker_prober(app: str, app_dir: Path, settle: int = 45, wait: int = 300) ->
running = [c for c in cids
if ((_inspect(c) or {}).get("State") or {}).get("Status") == "running"]
hits = _exercise(running, ports) if (running and ports) else []
if running and ports and app in APP_EXERCISE:
hits += _exercise_app(app, running, ports)
time.sleep(settle)
def observe():
+49
View File
@@ -407,6 +407,32 @@
"deploy_fields[SUBDOMAIN].label": "Aldomain",
"description": "Családfa készítő és genealógiai szoftver"
},
"grimmory": {
"app_info.add_people": "A családtagot először a vezérlőpult Beállítások, Biztonság oldalán, a Család kártyán add hozzá; utána a Grimmory Beállítások, Felhasználók oldalán hozd létre a fiókját.",
"app_info.first_steps[0]": "Nyisd meg a library.DOMAIN címet, és hozd létre az admin fiókodat",
"app_info.first_steps[1]": "A családtagjaidat a vezérlőpult Beállítások, Biztonság oldalán, a Család kártyán add hozzá - idegen el sem éri a Grimmoryt",
"app_info.first_steps[2]": "Hozz létre egy könyvtárat a /books mappával - ez a meghajtódon a userdata/media/grimmory mappa",
"app_info.first_steps[3]": "Töltsd fel a könyveidet, vagy másold őket a „Beolvasandó e-könyvek (Grimmory)\" mappába",
"app_info.first_steps[4]": "Az e-könyv-olvasódhoz kapcsold be az OPDS-t a Beállításokban, és hozz létre OPDS-felhasználót",
"app_info.first_steps[5]": "A könyvadatokat a Grimmory külső szolgáltatóktól kéri le (Google Books, Open Library); a Kobo-szinkron a Kobo áruházán át is megy",
"app_info.tagline": "E-könyvtár - olvasd böngészőben, Kobón, KOReaderrel vagy bármely OPDS-olvasóval",
"app_info.use_cases[0]": "E-könyvek és képregények rendezett könyvtárban, borítóval és adatokkal",
"app_info.use_cases[1]": "Olvasás a böngészőben, haladás mentése; Kobo- és KOReader-szinkron",
"app_info.use_cases[2]": "OPDS-katalógus az e-könyv-olvasó alkalmazásoknak (a Beállításokban kapcsold be)",
"app_info.use_cases[3]": "A Calibre-Web helyett vagy mellett: ez modernebb felület, a Calibre-Web a Calibre-könyvtárakhoz jó",
"data_paths[grimmory].label": "Beolvasandó e-könyvek (Grimmory)",
"data_paths[media/grimmory].label": "E-könyvtár (Grimmory)",
"deploy_fields[DB_PASSWORD].label": "Adatbázis jelszó",
"deploy_fields[DB_ROOT_PASSWORD].label": "Adatbázis root jelszó",
"deploy_fields[DOMAIN].description": "A szerver domain neve",
"deploy_fields[DOMAIN].label": "Domain",
"deploy_fields[HDD_PATH].description": "A meghajtó, amelyen a könyveid lesznek",
"deploy_fields[HDD_PATH].label": "E-könyvtár meghajtója",
"deploy_fields[HDD_PATH].placeholder": "/mnt/felhom-drives/hdd_1",
"deploy_fields[SUBDOMAIN].description": "Az alkalmazás aldomainje",
"deploy_fields[SUBDOMAIN].label": "Aldomain",
"description": "E-könyvtár böngészőben olvasóval, OPDS-sel, Kobo és KOReader szinkronnal"
},
"home-assistant": {
"app_info.first_steps[0]": "Nyisd meg a ha.DOMAIN címet a böngészőben",
"app_info.first_steps[1]": "Hozd létre a tulajdonos fiókot az onboarding során",
@@ -600,6 +626,27 @@
"deploy_fields[SUBDOMAIN].label": "Aldomain",
"description": "Receptkezelő és étkezéstervező"
},
"metube": {
"app_info.add_people": "A családtagokat a Beállítások, Biztonság oldal Család kártyáján adod hozzá; mindenki a saját nevével és jelszavával lép be.",
"app_info.first_steps[0]": "Add hozzá a családtagjaidat a Beállítások, Biztonság oldal Család kártyáján",
"app_info.first_steps[1]": "Nyisd meg a video.DOMAIN címet, és lépj be a családi neveddel",
"app_info.first_steps[2]": "Illessz be egy linket, válaszd ki a minőséget, és indítsd el a letöltést",
"app_info.first_steps[3]": "Csak saját használatra: csak olyan videót tölts le, amelyhez jogod van (például a sajátodat vagy szabad felhasználásút). A letöltés a háztartásod internetcíméről történik.",
"app_info.prerequisites[0]": "A letöltés a háztartásod internetkapcsolatát használja; egy videószolgáltató ritkán korlátozhatja a sok letöltést egy címről",
"app_info.tagline": "Videók és hanganyagok letöltése egy linkből - csak a családodnak",
"app_info.use_cases[0]": "Egy videó vagy lejátszási lista letöltése a linkjéből, videóként vagy csak hangként",
"app_info.use_cases[1]": "A letöltések a meghajtódra kerülnek, a fájlböngészőben és a médialejátszódban is látod őket",
"app_info.use_cases[2]": "Csak a családtagjaid érik el: idegen nem tud letölteni a háztartásod internetcíméről",
"data_paths[media/metube].label": "Letöltött videók (MeTube)",
"deploy_fields[DOMAIN].description": "A szerver domain neve",
"deploy_fields[DOMAIN].label": "Domain",
"deploy_fields[HDD_PATH].description": "A meghajtó, amelyre a letöltések kerülnek",
"deploy_fields[HDD_PATH].label": "Letöltések meghajtója",
"deploy_fields[HDD_PATH].placeholder": "/mnt/felhom-drives/hdd_1",
"deploy_fields[SUBDOMAIN].description": "Az alkalmazás aldomainje",
"deploy_fields[SUBDOMAIN].label": "Aldomain",
"description": "Videók és hanganyagok letöltése a meghajtódra, a család számára"
},
"n8n": {
"app_info.first_steps[0]": "Nyisd meg az auto.DOMAIN címet a böngészőben",
"app_info.first_steps[1]": "Hozd létre az admin fiókot",
@@ -1229,7 +1276,9 @@
},
"reasons": {
"dawarich": "new app 2026-10-01 through NEW-APP-CHECKLIST.md: te-form, no kérjük; reviewed by CC against the operator rules",
"grimmory": "new app 2026-10-02 (family gate); Hungarian reviewed: informal te, no kerjuk (ASCII scan with a positive control)",
"karakeep": "new app 2026-10-01 through NEW-APP-CHECKLIST.md: te-form, no kérjük; reviewed by CC against the operator rules",
"metube": "new app 2026-10-02 (family gate); Hungarian reviewed: informal te, no kerjuk (ASCII scan with a positive control)",
"radicale": "new app 2026-10-01 through NEW-APP-CHECKLIST.md: te-form, no kérjük; reviewed by CC against the operator rules"
}
}
+16
View File
@@ -463,5 +463,21 @@ class TestEnvBuilding(unittest.TestCase):
self.assertEqual([f["env_var"] for f in cvp.parse_deploy_fields(felhom)], ["A"])
class TestRoutedPorts(unittest.TestCase):
def test_routed_ports_reads_the_mapping_form(self):
"""R-801: `compose config --format json` gives labels as a mapping; the port is in the KEY."""
resolved = {"services": {"metube": {"labels": {
"traefik.enable": "true",
"traefik.http.services.metube.loadbalancer.server.port": "8081"}}}}
self.assertEqual(cvp.routed_ports(resolved), [8081])
def test_routed_ports_list_form_still_read(self):
resolved = {"services": {"a": {"labels": ["traefik.http.services.a.loadbalancer.server.port=3000"]}}}
self.assertEqual(cvp.routed_ports(resolved), [3000])
def test_no_routed_port(self):
self.assertEqual(cvp.routed_ports({"services": {"db": {"labels": {"x": "y"}}}}), [])
if __name__ == "__main__":
unittest.main(verbosity=2)
+48
View File
@@ -55,6 +55,7 @@ COVERS = {
"test-record": "a ladder whose newest step is not the compose's images (a move without a record), a gap, a line that is not one JSON entry, a failed verdict - vs a clean ladder (09 decision 13)",
"test-record-move": "an image move with NO entry, with the entry only in a COMMENT or in README, with a failed/backfilled entry, with a digest the registry no longer serves, memory_tight without a raised limit - vs a proven entry that matches; a ref moving in a compose COMMENT is not a move (09 decision 13)",
"probe-measured": "the measurement written in the TAGLINE or another comment block, not directly above setup_done_probe:; a date with no before/after; before/after with no date; 'read upstream' instead of 'measured' - vs a genuine measured comment (R-715)",
"family-gate": "family_gate written only in a COMMENT (not gated, no min_controller owed); min_controller only in a comment; a golden DIRECTORY named 0.287.0 with no bake log (the mkdir shape, R-410); a sibling with no golden (stated NOT CHECKED, never a pass of rule 3) - vs the facts: an unanchorable exception (regex, '/', '..'), an exception list with no gate, min_controller below 0.287.0, the newest baked golden below 0.287.0; and a genuine family app passes (decisions 63/64, finding F1)",
"onboarding": "a NEW template with no record; a record missing an id, or carrying it only inside an HTML comment; a `done` whose path does not exist, is an EMPTY directory (the mkdir shape, R-410) or names an absent sibling-repo file; an `n/a` with an empty or two-word reason; an `open` row; `opened:` backdated before the checklist; the template a new app copies lacking a new id - vs a complete record, an id added after `opened:`, and an exempt app's record with open rows (NEW-APP-CHECKLIST.md)",
"copy-i18n": "Hungarian edited in a COMMENT/README/display_name (label, not copy) vs a real frozen string changed; an English block that is not English, is not matched to a Hungarian twin, or rewrites a credential (R-560). Also the DEGRADED mode CI actually runs — PyYAML shadowed out, freeze only (R-595)",
}
@@ -618,6 +619,52 @@ def onboarding_cases():
finally:
shutil.rmtree(ws, ignore_errors=True)
def family_gate_cases():
"""check-family-gate.py reads FILES: templates/*/.felhom.yml and the sibling felhom.eu's golden bake records."""
global ran
import tempfile
ws = tempfile.mkdtemp(prefix="catalog-familygate-")
try:
cat, sib = os.path.join(ws, "cat"), os.path.join(ws, "felhom.eu")
def golden(ver, baked=True):
d = os.path.join(sib, "documentation", "tests", "golden-%s-2026-10-02" % ver)
os.makedirs(d, exist_ok=True)
if baked:
io.open(os.path.join(d, "bake.log"), "w").write("GOLDEN_SHA256=" + "a" * 64 + "\n")
def app(body, name="famapp"):
d = os.path.join(cat, "templates", name)
os.makedirs(d, exist_ok=True)
io.open(os.path.join(d, ".felhom.yml"), "w").write("display_name: X\n" + body)
GOOD = 'family_gate: true\nfamily_gate_except:\n - "/api/v1/opds" # e-readers\n - "/api/kobo/"\nmin_controller: "0.287.0"\n'
def run(name, setup, expect_rc, must=(), sibling=True):
global ran
shutil.rmtree(cat, ignore_errors=True); shutil.rmtree(sib, ignore_errors=True)
os.makedirs(os.path.join(cat, "templates"))
setup()
args = [sys.executable, os.path.join(ROOT, "scripts", "check-family-gate.py"), "--root=" + cat,
"--felhom-eu=" + (sib if sibling else os.path.join(ws, "absent"))]
r = sh(args, ROOT); out = r.stdout + r.stderr; ran += 1
ok = r.returncode == expect_rc and all(m in out for m in must)
print(" %s %-70s rc=%d (expected %d)" % ("ok" if ok else "XX", name, r.returncode, expect_rc))
if not ok:
fails.append("%s: rc=%d expected %d; missing %s\n%s" % (name, r.returncode, expect_rc, [m for m in must if m not in out], out[-400:]))
print("\n-- family-gate: genuine and decoys")
run("GENUINE: a family app, literal exceptions, min 0.287.0, golden 0.287.0", lambda: (app(GOOD), golden("0.287.0")), 0, ("family-gate gate OK",))
run("DECOY: family_gate only in a COMMENT -> not gated, nothing owed", lambda: (app("# family_gate: true\n"), golden("0.286.1")), 0, ("0 family-gated",))
run("DECOY: no sibling -> rule 3 stated NOT CHECKED", lambda: app(GOOD), 0, ("NOT CHECKED",), sibling=False)
print("-- family-gate: the facts (each MUST be refused)")
run("FACT: an exception that is a regex", lambda: (app(GOOD.replace('"/api/kobo/"', '"/api/(.*)"')), golden("0.287.0")), 1, ("not a literal path prefix",))
run("FACT: the exception '/' (the whole app)", lambda: (app(GOOD.replace('"/api/kobo/"', '"/"')), golden("0.287.0")), 1, ("not a literal",))
run("FACT: an exception with '..'", lambda: (app(GOOD.replace('"/api/kobo/"', '"/api/../admin"')), golden("0.287.0")), 1, ("not a literal",))
run("FACT: an exception list with no gate", lambda: (app('family_gate_except:\n - "/x"\n'), golden("0.287.0")), 1, ("with no gate",))
run("FACT: min_controller only in a comment", lambda: (app(GOOD.replace('min_controller: "0.287.0"', '# min_controller: "0.287.0"')), golden("0.287.0")), 1, ("needs min_controller",))
run("FACT: min_controller below the release", lambda: (app(GOOD.replace('0.287.0', '0.286.1')), golden("0.287.0")), 1, ("needs min_controller",))
run("FACT: newest baked golden below the release", lambda: (app(GOOD), golden("0.286.1")), 1, ("publish the app OPEN",))
run("FACT: a golden DIRECTORY 0.287.0 with no bake log (a name is not a bake)", lambda: (app(GOOD), golden("0.286.1"), golden("0.287.0", baked=False)), 1, ("0.286.1",))
finally:
shutil.rmtree(ws, ignore_errors=True)
def main():
gate = os.path.join(ROOT, "scripts", "check-engine-major.py")
if not os.path.isfile(gate):
@@ -1095,6 +1142,7 @@ i18n:
shutil.rmtree(clone, ignore_errors=True)
onboarding_cases()
family_gate_cases()
if fails:
print()
+54
View File
@@ -2159,12 +2159,66 @@ class Grimmory:
return found
class MeTube:
"""MeTube (2026-10-02, new app through NEW-APP-CHECKLIST.md, published behind the family gate). THE FRONT DOOR is its
own web API, the one its page calls: `POST /add` a link, then `GET /history` until the item is `finished`; the file is
served back at `GET /download/<filename>`. The link is a 1 MB public test video (yt-dlp's generic extractor — no
video-service account, no cookies). Negative control on every readback: a file never downloaded answers 404, so a
read that says "found" cannot be a catch-all. On the box the family gate is passed as the household (box_walk)."""
sub = "video"
URL = "https://test-videos.co.uk/vids/bigbuckbunny/mp4/h264/360/Big_Buck_Bunny_360_10s_1MB.mp4"
def _hist(self, w, sub):
rc, code, out = w.app_curl(sub, "/history")
try:
return code, json.loads(out)
except Exception:
return code, {}
def seed(self, w, sub, say):
if not w.wait_app(sub, "/", want=("200",), tries=60):
self.tried = "/ never answered 200"
return None
rc, code, out = w.app_curl(sub, "/add", "-H", "Content-Type: application/json", "-H", f"Origin: https://{sub}.{w.DOMAIN}",
data=json.dumps({"url": self.URL, "quality": "best", "format": "any", "auto_start": True}), method="POST")
say(f" metube: POST /add http={code} {(out or '')[:40]}")
if code != "200":
self.tried = f"add -> {code} {(out or '')[:80]}"
return None
for _ in range(60):
code, h = self._hist(w, sub)
done = [d for d in h.get("done", []) if d.get("url") == self.URL and d.get("status") == "finished"]
if done:
fn = done[0].get("filename") or (done[0].get("title", "") + ".mp4")
say(f" metube: the download finished ({done[0].get('size')} bytes)")
return {"filename": fn, "size": done[0].get("size")}
time.sleep(3)
self.tried = "the download never finished"
return None
def verify(self, w, sub, t, say):
if not w.wait_app(sub, "/", want=("200",), tries=60):
say(" metube: / never answered")
return False
import urllib.parse
rc, c_absent, _ = w.app_curl(sub, "/download/never-" + secrets.token_hex(4) + ".mp4")
if c_absent != "404":
say(f" metube: READBACK UNUSABLE — a file never downloaded answered {c_absent}")
return False
code, h = self._hist(w, sub)
in_hist = any(d.get("url") == self.URL and d.get("status") == "finished" for d in h.get("done", []))
rc, code, out = w.app_curl(sub, "/download/" + urllib.parse.quote(t["filename"]), "-o", "/dev/null")
say(f" metube: history has it={in_hist}; GET /download/<file> http={code}")
return code == "200" and in_hist
FIXTURES = {
"uptime-kuma": UptimeKuma(),
"crafty-controller": Crafty(),
"wger": Wger(),
"calibre-web": CalibreWeb(),
"sparkyfitness": Sparkyfitness(),
"metube": MeTube(),
"rallly": Rallly(),
"outline": Outline(),
"home-assistant": HomeAssistant(),