iOS asks for apple-touch-icon when saving to the home screen and we
served nothing — hence the gray "B". A 180x180 PNG cropped from the
mascot logo is linked from the shell, served from /static/, and also
answers the cookie-less root probes (/apple-touch-icon*.png) the guard
already treats as public. apple-mobile-web-app-title trims the clip
name to "bggpipe".
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
The key already persisted across restarts, but the cookie was a
session cookie — Safari eventually drops those and the paste ritual
returned. The pairing cookie now lasts a year, and startup prints a QR
code of the pairing URL (qrcode dep, ASCII render) so a phone pairs by
pointing its camera at the terminal. Revoke every paired device by
deleting data/.lan_key.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
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
The phone's first visit 403'd its own subresources: Safari's preload
scanner fetches /static/* before the document response's Set-Cookie is
committed, and favicon/apple-touch-icon probes are cookie-less system
fetches. A ?k= visit now answers 303-with-cookie to the same path —
the cookie is committed before any document loads, and the key is
scrubbed from the phone's address bar and history. /static/* and the
icon probe paths are exempt from the key: they're the app's own
css/js/artwork, no user data (shelf photos stay gated).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
A multi-homed machine (VM bridges, Ethernet + Wi-Fi) has several
addresses and the server cannot know which network the phone is on —
but the OS's default route is the right answer nearly always. The
banner now prints one "on your phone" URL from the route probe, with
the other interfaces on an if-that-doesn't-answer line; when the probe
fails, the settings hint plus candidates.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
Dogfooding caught both: the auto-opened desktop tab (and every old
polling tab) 403'd for lack of the key, and the "from your phone" list
offered 127.0.0.1. Loopback CLIENT connections now skip the key — a
network peer cannot arrive with a loopback client address — and fall
through to the same Host/Origin guard as the localhost default, so
rebinding pages (foreign Host) and cross-origin POSTs (foreign Origin)
from a local browser stay blocked. Loopback addresses are filtered out
of the printed phone URLs.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
Five blind reviewers over 2936d21; 13 confirmed fixes. The design
change: the Host allowlist was CSRF armor being asked to do access
control. --lan now mints a per-run access key — the printed URLs carry
?k=..., the first visit sets a cookie — required on EVERY request,
reads included (shelf photos and pipeline state are private). That
closes DNS-rebinding read exfiltration (GETs were exempt from the old
guard), closes any-LAN-device mutations via a forged localhost Host,
and frees phones from allowlist accuracy — multi-interface machines,
DHCP renewals, and failed IP discovery no longer strand writes. A
foreign Origin is still refused even with the key.
Guard hardening: Host parsed via url.hostname (ports, IPv6 brackets,
case) instead of a manual split; refusals now echo one stderr line
(they were invisible at log_level=warning) and the LAN 403 names the
remedy; startup warns when no LAN IP could be determined instead of
printing hostname-only URLs as if verified.
Silent failures: the queue page no longer freezes blank forever when a
render throws (LAST was recorded before render; one malformed CSV cell
would blank all three ledgers and blame the network) — all three
change-detection pages record LAST only after a successful render, and
the queue null-guards source_photos.
Mobile: touch-size the review/ticket/merge buttons the finger-sized
rule lost to on specificity. Style: the meta-cell builder is one shared
metaLine() helper; the Help page no longer claims localhost-only;
dead -webkit prefix dropped; --lan help text in house style.
Tests: token gating (reads and writes, cookie handoff, foreign-Origin
refusal), the Origin-present + Host-with-port path every real browser
mutation takes (was fully uncovered), run_web_review's lan branch via
monkeypatched uvicorn, and a lan_hosts test that actually pins the
lowercase/non-empty/v4-only invariants the guard depends on.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
Verified page by page in a 390px viewport against live data. The rail
collapses to a brand line plus one horizontally scrollable nav row
(was: two wrapped rows). The Titles table becomes stacked line-cards —
title, chips, match, photos, finger-sized actions — with empty cells
dropped (the templates now emit them tight so :empty applies); the
editor stacks full-width inputs. Queue/library ledgers scroll inside
their card and photo lists wrap at commas instead of clipping. The
review done-card stats wrap, keyboard hints hide on touch widths,
reshoot stencils go horizontal, buttons get touch-sized padding.
Also: post-rename copy on the remove button.
bggpipe web --lan binds 0.0.0.0 and extends the mutation guard's host
allowlist with this machine's names and addresses (never a wildcard —
DNS-rebinding arrives under the attacker's hostname, which an allowlist
rejects), prints every reachable URL, and warns loudly that the app has
no login. Default stays localhost-only.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
The badge counted lines nothing on the page identified. Each shaky line
(vision confidence below high, nothing verified yet — the badge's exact
predicate, served per-line so page and badge can't disagree) now shows
a dashed gold "shaky read" chip, a filterbar toggle shows just them,
and a "✓ looks right" button confirms a correct read in one click —
recorded as a confidence-high edit record, so verification is as
durable as any other curation and, unlike a real edit, re-queues
nothing. Saving an unchanged edit form on a shaky line counts as
confirming it. Help's chip legend explains the chip and its three ways
to clear (confirm, edit, or a BGG match).
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
Eric's observation: cleaning up raw reads happens BEFORE resolve, but
the app ordered Review ahead of Catalog and never said proofreading was
a step at all. The pipeline has two human checkpoints — proofread the
reads (after extract), decide the matches (after resolve) — and now the
app says so: the sidebar runs Pipeline, Photos, Titles, Review, Queue,
Library, Help in true workflow order; the Titles page (né Catalog — the
old name suggested a finished collection, which is the Library's job;
/catalog redirects) gets a badge counting unresolved shaky reads; an
edit marks its entry human-verified so the badge drains as you
proofread; the extract stage card nudges toward the proofread before
resolving; and the Help flow is rewritten as six stages + two
checkpoints with the loop called out.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
README: the Catalog page description and a new "Fixing what the model
gets wrong" section cover the curation actions (edit/split/remove) and
their durability contract. Spec: amended-as-built notes — duplicate
same-edition copies are now supported via explicit splits, review is
both a TUI and the web app, and the data model lists the three curation
stores plus the dismissed-tickets file.
New Help page in the app (nav entry on every page): the six-stage flow,
what each page is for, the curation semantics with undo instructions, a
status-chip legend rendered with the real chips, keyboard shortcuts,
and where the data lives on disk.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
Titles that aren't games (misread box art, out-of-scope items) can now
be removed outright: a danger button in the catalog's edit panel posts
/api/remove-title, which drops the line's matches rows (veto'd ones
too — removal is the human explicitly discarding the line), records the
decision photo-scoped in data/title_removals.json, and replays
titles.json. Every rebuild filters removed sightings after edits and
before dedupe, so re-extraction cannot resurrect them; undo by deleting
the record from the store. The three stores now share one scoped-record
parser and recorder.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
The standing post-feature audit over a7f0cfe. Correctness (data): splits
become photo-scoped store records so splitting one edition no longer
force-splits same-named editions, and renaming a split copy migrates its
protection to the corrected title instead of silently re-merging copies.
Correctness (web): edit scoping now counts siblings by NORMALIZED title
(matching how stored edits apply), same-title-same-photos edits are
refused rather than corrupting the sibling entry, split copies serve
their real per-photo cues to the edit form instead of blanks, and a
split whose row vanished underneath returns 409 instead of a false 200.
Silent failures: replay_titles refuses to rebuild from a PARTIAL raw
cache (fresh clone + one --only extract would have truncated the
committed titles.json); the edit endpoint writes in crash-safe order
(cull, record, replay); corrupt curation stores fail loud naming the
file; retried edits don't double-record. Review-decision durability:
drop_rows never drops dedupe_veto rows — a rename retitles them in
place — and writes through a no-reload path so a concurrent rewrite
can't silently discard the cull. Style: catalog action cells get their
own class (.rowactions' flex display broke table alignment), editor
inputs match the design system and stop overriding the global
focus-visible outline, EditBody's clear-semantics docstring scoped to
cue fields, "nothing to change" derived from the record itself.
Tests: 8 new (photo-scoped splits, veto preservation, photo-narrowed
drops, 409s on both curation endpoints under a running job, partial-raw
replay guard, rename-keeps-protection lifecycle, corrupt-store error,
cue-field editing) and the dead edition_hint key in the edit test now
exercises real cue fields. 259 passing.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
Wiz-War had no split button: can_split required a matches row, but fresh
extractions leave multi-photo titles rowless until resolve runs. Splits
are now a title-level decision persisted in data/title_splits.json,
honored by extract's dedupe and resolve's dedupe on every rebuild, with
the button on any multi-photo line — resolved or not.
Same mechanism carries human corrections: data/title_edits.json stores
fixed misreads and known cues (publisher/edition/year/language), applied
before dedupe on every titles.json rebuild, editable from a new inline
form on every catalog line. An edit drops the title's stale matches rows
so resolve re-queries with the corrected data.
The catalog page now sorts alphabetically (case-insensitive; split
copies stay adjacent) instead of extraction order.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016jXZFSTZQKzAC8fqpWSz9g
The re-run confirmed round 1 held and then caught second-order bugs in
its own fixes plus two long-standing ones everyone missed. TUI decisions
after a mid-session reload were counted but never written (rows are now
re-adopted into the fresh list on every save, preferring undecided slots
on duplicate keys); row_ix was computed by equality so duplicate rows
shared an ordinal (identity now, merges included, veto sends it); upload
job keys collided for two same-version copies (completions are counted
per key, so --limit or an interrupt can no longer strand the second
copy); diff consumes collids on exact-version matches (a vetoed
same-version second copy was silently swallowed) and splits mismatches:
report-only disagreement while an unclaimed copy exists, second-copy add
only when every copy is claimed.
Also: XML responses are validated and written atomically before caching
(a torn or truncated 200 body can never poison a re-run), JSON artifacts
write atomically, thing/search parsers refuse missing ids like the
collection parser, empty game names are refused by the upload queue, a
never-rendering version picker fails retryably instead of terminally,
the systemic-failure abort compares exception types, blocked same-title
entries defer as a group so positional pairing can't misalign,
truncation heads pick the earliest separator, diff messages tell the
truth when a token exists without a username, and the shared-constant
sweep now actually covers every module (statuses, search types, marker
names, client_for, ports). pydantic declared as a direct dependency.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Correctness: review vetoes persist via a dedupe_veto column (resolve
re-runs no longer overturn humans); diff emits second copies whose
confident version matches no owned copy (spec: pairs own only on both
ids) and fetches the live collection with refresh; resolve pairs
titles.json entries to rows by title so a reshoot photo updates
provenance instead of duplicating rows; version lookups survive empty
/thing results; publisher tie-break now honors the mixed
base/expansion veto and refuses multi-candidate picks; empty-normalized
(non-Latin) titles never count as exact.
Upload: LoginError aborts a run instead of logging N bogus failures
(and 3 identical consecutive failures abort as systemic); Cloudflare
interstitials are detected; added-without-version gets its own logged
status that verify understands; same-game updates run one per pass so
the name-targeted row edit can't overwrite a fresh version; absent
diff outputs fail loudly; pagination clicks are paced.
Web review: a lock serializes freshen/decide (threadpool race dropped
decisions); failed saves roll memory back and always alert the browser
(non-JSON 500s included); session warnings reach the page instead of a
StringIO; state-load failures and dead servers show banners instead of
a blank page; duplicate (title, photos) rows are addressable by
ordinal.
Consistency: shared CONFIDENT_VERSION_STATUSES, client_for(),
Config paths for every artifact, one review-port constant, named
matching thresholds, strict collection-id parsing, error-doc responses
never cached, unknown config keys warn, extract reports dropped vision
entries, fixture generators share escaping + marker text.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
ReviewSession re-reads matches.csv/titles.json on mtime change so
external extract/resolve runs show up per-request (and stale in-memory
rows can no longer overwrite them); the page polls state every 3s,
re-rendering only on change and never mid-typing. --web --dev adds
uvicorn source-watch restarts, scoped to the package dir so decision
writes to data/ don't trigger them.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Original art (a bagpiper whose bag is a board game box) lives in
assets/; web-sized derivatives ship in the package: a face-crop avatar
in the header and favicon, and the framed full-length piper on the
review done screen. Served via an allowlisted /static route. README
leads with the full portrait, credited to Juniper.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Rows resolving to the same (bgg_id, version_id — or both version-
unknown) are the same physical game read twice unless their extraction
cues conflict (two editions stay separate). The survivor is the read
whose transcription matches the BGG name; losers are marked
match_status=merged with a new merged_into column — no row is ever
deleted, and older matches.csv files without the column still read.
Downstream: diff skips merged rows but folds their photos into the
survivor's to_add provenance; enrich and the review passes ignore them.
The web UI gains a Merges section ("Jokin Ha... merged into Joking
Hazard") with a veto (v key) that restores the row as a distinct
approved match, plus a merged catalog chip and header tally.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The done screen claimed "diff-ready" while 104 extracted titles had
never been resolved (they're invisible to matches.csv until the BGG
token arrives). The state now counts titles.json entries with no
matches row: the header tally shows "awaiting resolve", the done screen
says "Resolved set fully reviewed" with the real extracted total and
what to run on token day, and a read-only Catalog section lists every
extracted title with its status chip (auto/approved/rejected/awaiting
BGG), matched game, version, and source photos.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
One self-contained page (inline CSS/JS, system fonts, works offline):
match cards show source photos, extracted cues, and candidates with
cached-XML thumbnails (placeholder tiles until real fixtures exist);
actions are pick / manual BGG id / reject, plus a skippable editions
pass (pick or unknown). Keyboard-first: j/k navigate, 1-9 pick, r
reject, m manual, u unknown, d dismiss. Every decision writes
matches.csv through the same ReviewSession methods the TUI now shares —
the TUI remains as the no-flag fallback. unidentified.json renders as
visually distinct reshoot work-orders with dismissals persisted in
data/unidentified_dismissed.json (survives extract rebuilds). Progress
tally and a diff-ready done screen; photo serving is allowlisted to
photos/ contents; server binds 127.0.0.1 only. Layout leaves room for
a later games.json browse view.
Provenance guard: fixture generators now write STUB_FIXTURES.marker
into their cache dirs, and CLAUDE.md gains the hard rule that stub-
resolved version_ids are placeholders — upload must refuse to run
while data/bgg_cache/STUB_FIXTURES.marker exists.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>