--lan key persists across restarts; stale tabs get a readable 403

The key rotated on every server restart, stranding every phone that
held the previous cookie — during active development that guaranteed a
wall of refusals from stale polling tabs after each restart. The key
now lives in data/.lan_key (gitignored, 0600 — the Playwright-state
treatment for credential-adjacent files) and is reused across
restarts; delete the file to rotate. A keyless browser navigation now
gets a one-line HTML page saying what to do instead of raw JSON.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
This commit is contained in:
Eric Wagoner
2026-08-03 16:39:24 -04:00
co-authored by Claude Fable 5
parent 344a930a8a
commit f82489716a
7 changed files with 64 additions and 27 deletions
+1
View File
@@ -1,6 +1,7 @@
# Secrets & credential-adjacent state # Secrets & credential-adjacent state
.env .env
*.storage_state.json *.storage_state.json
data/.lan_key
playwright/.auth/ playwright/.auth/
storage_state.json storage_state.json
+5
View File
@@ -47,5 +47,10 @@
"IMG_4502.jpeg" "IMG_4502.jpeg"
], ],
"confidence": "high" "confidence": "high"
},
{
"match": "ACTION CASTLE",
"title_raw": "Parsely",
"confidence": "high"
} }
] ]
+6
View File
@@ -64,5 +64,11 @@
"photos": [ "photos": [
"IMG_4507.jpeg" "IMG_4507.jpeg"
] ]
},
{
"title": "ACTION CASTLE I",
"photos": [
"IMG_4573.jpeg"
]
} }
] ]
+2 -15
View File
@@ -1656,7 +1656,7 @@
"title_normalized": "botany expansion perilous perfumes poisonous carnivorous parasitic and bizarre plants" "title_normalized": "botany expansion perilous perfumes poisonous carnivorous parasitic and bizarre plants"
}, },
{ {
"title_raw": "ACTION CASTLE", "title_raw": "Parsely",
"confidence": "high", "confidence": "high",
"publisher_hint": "Memento More Computers Inc.", "publisher_hint": "Memento More Computers Inc.",
"edition_hint": "", "edition_hint": "",
@@ -1666,20 +1666,7 @@
"source_photos": [ "source_photos": [
"IMG_4573.jpeg" "IMG_4573.jpeg"
], ],
"title_normalized": "action castle" "title_normalized": "parsely"
},
{
"title_raw": "ACTION CASTLE I",
"confidence": "medium",
"publisher_hint": "",
"edition_hint": "",
"year_hint": null,
"language_hint": "English",
"art_notes": "Dark spine box among a stack of illustrated game boxes on the book cover art",
"source_photos": [
"IMG_4573.jpeg"
],
"title_normalized": "action castle i"
}, },
{ {
"title_raw": "SIX-GUN SHOWDOWN", "title_raw": "SIX-GUN SHOWDOWN",
+6
View File
@@ -95,6 +95,12 @@ class Config:
self.data_dir / "collection_snapshot_expansions.xml", self.data_dir / "collection_snapshot_expansions.xml",
) )
@property
def lan_key_path(self) -> Path:
# the --lan access key, persisted so restarts don't strand phones'
# cookies; credential-adjacent like Playwright state — gitignored
return self.data_dir / ".lan_key"
@property @property
def storage_state_path(self) -> Path: def storage_state_path(self) -> Path:
# cwd-relative on purpose (credential-adjacent, gitignored) but # cwd-relative on purpose (credential-adjacent, gitignored) but
+29 -12
View File
@@ -360,14 +360,17 @@ def create_app(
f"(host {host!r}): missing or wrong access key", f"(host {host!r}): missing or wrong access key",
err=True, err=True,
) )
return JSONResponse( hint = (
{ "missing or wrong access key — open the exact URL "
"detail": "missing or wrong access key — open the " "printed where the server started (it ends in ?k=...)"
"exact URL printed where the server started "
"(it ends in ?k=...)"
},
status_code=403,
) )
if "text/html" in request.headers.get("accept", ""):
# a tapped link or stale tab: a person is looking
return HTMLResponse(
f"<h1>bggpipe: access key needed</h1><p>{hint}</p>",
status_code=403,
)
return JSONResponse({"detail": hint}, status_code=403)
if request.method in ("GET", "HEAD") and request.query_params.get("k"): if request.method in ("GET", "HEAD") and request.query_params.get("k"):
# the key arrived in the typed URL: commit it to a cookie # the key arrived in the typed URL: commit it to a cookie
# via redirect BEFORE the document loads (the preload # via redirect BEFORE the document loads (the preload
@@ -1102,6 +1105,22 @@ def _dev_app() -> FastAPI:
return create_app(load_config(Path(path) if path else None), lan_token=token) return create_app(load_config(Path(path) if path else None), lan_token=token)
def _lan_key(cfg: Config) -> str:
"""The --lan access key: created once, reused across restarts so a
phone's cookie keeps working. Delete the file to rotate the key."""
path = cfg.lan_key_path
if path.exists():
key = path.read_text().strip()
if key:
return key
key = secrets.token_urlsafe(16)
path.parent.mkdir(parents=True, exist_ok=True)
fd = os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
with os.fdopen(fd, "w") as handle:
handle.write(key + "\n")
return key
def run_web_review( def run_web_review(
cfg: Config, cfg: Config,
*, *,
@@ -1115,10 +1134,7 @@ def run_web_review(
import uvicorn import uvicorn
url = f"http://127.0.0.1:{port}{landing}" url = f"http://127.0.0.1:{port}{landing}"
# 16 bytes = 128-bit key: standard session-credential strength. The token = _lan_key(cfg) if lan else None
# cost is a longer one-time URL to get onto the phone — copy it, or
# type it once; the cookie remembers it from then on.
token = secrets.token_urlsafe(16) if lan else None
if lan: if lan:
primary = _route_ip() primary = _route_ip()
spares = sorted( spares = sorted(
@@ -1150,7 +1166,8 @@ def run_web_review(
typer.echo( typer.echo(
" --lan: no login beyond that key — anyone who has it can run " " --lan: no login beyond that key — anyone who has it can run "
"stages, change your data, and (once unlocked) drive uploads " "stages, change your data, and (once unlocked) drive uploads "
"to your BGG account. Use only on a network you trust." "to your BGG account. Use only on a network you trust. (The "
"key persists across restarts; delete data/.lan_key to rotate.)"
) )
else: else:
typer.echo( typer.echo(
+15
View File
@@ -1021,6 +1021,12 @@ def test_run_web_review_lan_branch_binds_and_warns(tmp_path, monkeypatch, capsys
wr.run_web_review(cfg, port=9999, lan=True) wr.run_web_review(cfg, port=9999, lan=True)
out = capsys.readouterr().out out = capsys.readouterr().out
assert captured["host"] == "0.0.0.0" assert captured["host"] == "0.0.0.0"
# the key persists (0600) so restarts don't strand the phone's cookie
assert (cfg.lan_key_path.stat().st_mode & 0o777) == 0o600
key = cfg.lan_key_path.read_text().strip()
assert f"?k={key}" in out
wr.run_web_review(cfg, port=9999, lan=True)
assert f"?k={key}" in capsys.readouterr().out # same key after restart
# the default-route address leads; other interfaces are fallbacks # the default-route address leads; other interfaces are fallbacks
assert "on your phone, open: http://192.168.1.5:9999/?k=" in out assert "on your phone, open: http://192.168.1.5:9999/?k=" in out
fallback = next(line for line in out.splitlines() if "try:" in line) fallback = next(line for line in out.splitlines() if "try:" in line)
@@ -1030,3 +1036,12 @@ def test_run_web_review_lan_branch_binds_and_warns(tmp_path, monkeypatch, capsys
assert "Use only on a network you trust" in out assert "Use only on a network you trust" in out
wr.run_web_review(cfg, port=9999, lan=False) wr.run_web_review(cfg, port=9999, lan=False)
assert captured["host"] == "127.0.0.1" assert captured["host"] == "127.0.0.1"
def test_lan_403_is_html_for_navigations(tmp_path):
cfg = make_cfg(tmp_path)
app = create_app(cfg, client=unauthorized_client(tmp_path), lan_token="sekret")
phone = TestClient(app, base_url="http://192.168.1.99:8377")
res = phone.get("/titles", headers={"accept": "text/html,application/xhtml+xml"})
assert res.status_code == 403
assert "access key needed" in res.text # a person sees prose, not JSON