Running it against the live service exposed two gaps. It announced "App
restarted" the moment the container existed, so the very next request got a 502
from an app that wasn't listening yet; it now polls /healthz until the app is
actually serving. And it only checked that the archive contained the right
files — it now opens the archived database and runs SQLite's integrity_check,
because an archive that exists but won't restore is the worst kind of backup.
End to end on the server, with a throwaway part and photo inserted to exercise
the verification path: 2.6 seconds of downtime, archive verified, service
answering immediately on return, throwaway data removed afterwards.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Four audit findings on the photo feature.
The upload route was async, so its blocking SQLite work ran on the event loop:
under contention it stalled every other request for SQLite's busy timeout, not
just its own. It also read the photo count without the write lock, so
overlapping uploads all observed the same total and stored past the ceiling
together. It is a synchronous endpoint now, running in the threadpool, taking
BEGIN IMMEDIATE before re-checking the part, the ceiling and the position, and
committing before it returns. Reverting either half makes the new test die with
the same TimeoutError the audit reported.
The 8MB cap protected nothing: Starlette parses and spools an entire multipart
body before a route's dependencies run — before the login check — so the bytes
were already on disk by the time anything rejected them, and an anonymous
caller could make us write them. A plain ASGI middleware outside routing now
refuses an over-large body first, and Caddy enforces the same ceiling at the
edge.
The documented backup captured the database and the photos at two different
moments while the app stayed writable, so a photo deleted in between left the
saved database pointing at a file the archive did not contain. tools/backup.sh
stops the app for the few seconds the copy takes and verifies afterwards that
every referenced photo is in the archive.
Cleanup could destroy data rather than merely litter: prune-images could delete
a file between an upload writing it and inserting its row, and deletions
unlinked before their transaction committed. Pruning now ignores anything under
an hour old unless forced, and deletes commit before unlinking — an orphaned
file is recoverable, a row without its photo is not. check-images reports drift
in both directions and fails only on the direction that loses data.
Checks go from 271 to 291.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
A bag falling apart after forty years still has the part number printed on it,
and the picture carries more than any field you could retype it into. Photos
attach from the part form; on a phone the picker opens the camera directly.
Files live beside the database in the same volume, with only metadata in SQLite
— blobs there bloat the database and complicate the VACUUM INTO backup. The
browser downscales to 2000px before uploading, honouring EXIF orientation via
createImageBitmap, which keeps an image library and its native dependencies out
of the server entirely.
Uploads are sniffed by content rather than trusted by their declared type, so a
file that merely claims to be a JPEG cannot be stored and served back from this
origin as something a browser will execute. SVG is refused for the same reason.
Reads are capped rather than trusting Content-Length, at most 12 photos per
part, and a row that fails to insert takes its file with it.
Deleting a photo or a part removes the files, not just the rows, and
`app.admin prune-images` sweeps anything a crash stranded. The README's backup
procedure now covers both halves; capturing only the database would have
silently lost every photo.
python-multipart returns for the upload, pinned at 0.0.32 — the version removed
earlier was 0.0.20, which carried advisories. Audit is clean.
Two frontend bugs surfaced while testing this in the browser: a photo count
changing left the list row stale, and the part form opened from the list's
cached copy rather than fetching current data.
Checks go from 197 to 271.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
tools/render_check.py drives the real UI in Playwright's Chromium and asserts
what a person actually sees: the login gate, live search narrowing on a spec
value, the quick-adjust buttons, the category spec template pre-filling and
switching the unit to grams, the settings and password panels, and the phone
layout at a true 390px viewport.
Playwright's own Chromium rather than the installed Chrome: headless Chrome
returns nothing while a desktop Chrome is open, silently enough that it reads
as the app being broken.
It found a real bug on its first run. The fetch helper treated any 401 as a
lapsed session, so a wrong password bounced to "Not authenticated" instead of
saying the password was wrong. 401s from the login call itself are now left to
report their own reason.
It also confirmed the CSP is doing its job from the other direction:
Playwright's wait_for_function compiles predicates with eval() and is refused,
so the check polls from Python rather than the app loosening script-src.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>