Repository navigation
feat(pages): serve a recipe's picture of the dish and state its absence - #117
Conversation
The renderer could already show a recipe's hero image (media.heroImage), but no page handed it a resolver, so a recipe page never showed a picture and said nothing about why it had none. The pages app now serves GET /recipes/:id/picture behind the library credential, from the same byte store the photo route keeps photographs in (ADR-0009). The recipe page, the library card and the cook route's degraded page name that address. When a recipe has no picture, the page states the gap instead of rendering nothing: for a photographed cookbook page it says the photograph is the source and not a picture of the dish (ontology §7); otherwise it says none was kept. RecipeView's optional heroImage became `picture: PictureView`, a union of shown | absent with a reason, so "there is no picture" cannot be confused with "this view forgot to carry one". Content-Type follows only from the byte signature (src/http/image-signature.ts: JPEG, PNG, WebP), sent with nosniff and no-store. Any other bytes, a missing credential, an unknown recipe, and a missing or crafted identity all get the one shared not-found answer. The route addresses by recipe id, never by storage identity. A new recordedIdentity() in src/storage re-admits an identity read from a record, so no module outside storage casts to StorageIdentity (guard slice1/storage-identity-confinement); get still fails closed on non-issuable identities. Alternatives rejected: serving by storage identity (leaks the store's spelling); trusting a stored or claimed MIME type (an HTML or SVG stored as a picture would run on the instance origin); showing the photographed page as the dish picture (ruled out by ontology §7). Tested: new tests/run/recipe-picture.test.ts, ten proofs against the composed instance. Twelve planted violations (guessed fallback type, fixed type, empty media store in the instance, page/library/degraded page without resolver, photographed flag forced false, absence rendered as nothing, credential check removed, any RIFF as WebP, shortened PNG signature, picture plus gap sentence) each go red at their own assertion. The photographed-page sentence contains an apostrophe served as ', so a raw-string "not on the page" check was vacuous; the proofs compare the escaped form. The majors plant text follows the new library call site; the README guard drops byte-store.ts from its not-loaded list (it is now loaded at runtime), and the README gap paragraph is reworded with its bold anchor unchanged. Full gate npm run quality green (1389 passed). Risk: nothing writes media.heroImage yet, so a running instance still shows no picture; the README says so. The next change fetches the JSON-LD image at URL import. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125aMujwwRnPLKYSpiW6uZw
PR Review VerdictResult: BLOCK Deterministic: Measured on the merge result, not the head: head Acceptance Criteria
Findings
Not Checked
Routednone Generated by Claude Code |
…hold the picture address Review of #117 returned BLOCK with two findings, both reproduced. The picture route's security claim, that it serves by recipe and never by an identity taken from the request, was held by nothing: a planted route reading `?identity=` passed every proof, and the same store holds the kept photographs. The new proof `picture/the-address-is-the-librarys` names another stored image's identity under seven query keys and a header and requires the recipe's own bytes back. `recipePage` awaited `repo.loadSnapshot` unguarded. The Postgres store throws on a row that no longer satisfies the contract, so GET /recipes/:id and the page the cook route degrades to answered 500, a regression of slice6/recipe-viewable-when-derivation-fails that is green on main. And the boolean `photographedSource` conflated "not a photograph" with "nobody could read it", so merely catching the error would have stated "none was kept" about a recipe whose photograph the instance holds. Approach: `pictureSourceOf()` catches the read, and a failed or missing read is `unread`. `ViewOptions.photographedSource?: boolean` became `pictureSource?: PictureSource` ("photographed_page" | "not_photographed" | "unread"), absent meaning unread in the renderer itself. A new `PictureAbsence` "source_unread" carries its own sentence. The new describe `picture/an-unreadable-source-costs-the-page-nothing` uses a repository whose `loadSnapshot` throws the real validator error, for both addresses, alongside renderer-default and missing-snapshot proofs. Alternatives rejected: catching while keeping the boolean (it invents "none kept"); reporting the failure through `onDegraded` (that observer is for plan degradation and is not wired in the instance; the stated gap on the page is the trace). Tested: 19 planted violations, each red at its own assertion, including the reviewer's `?identity=` route, any-query-key and header variants, rethrowing the read error, read failure mapped to not_photographed, missing snapshot as not_photographed, renderer default as not_photographed, plus the earlier twelve. The first repository proxy in the tests called methods with the proxy as `this`, which cannot read private fields, and produced its own 500; methods are now bound to the store. Full gate on the merge result with main e1a05ac: 1396 passed. Risk: the query proof sees only the spellings it sends; stated in the test file header. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_0125aMujwwRnPLKYSpiW6uZw
PR Review VerdictResult: PASS Deterministic: Round two, delta-scoped: did the two blocking findings' closures hold, and did anything new appear at blocking severity. Measured on head Acceptance Criteria
Findings
Not Checked
Routednone Generated by Claude Code |
PR Review VerdictResult: PASS Deterministic: Round two, delta-scoped: did the two blocking findings' closures hold, and did anything new appear at blocking severity. Measured on head Acceptance Criteria
Findings
Not Checked
Routednone Generated by Claude Code |
Requested by Kornelius · project thread
Before: a recipe page never showed a picture, even for a recipe that had one, and it said nothing about the missing picture. The renderer could draw one, but no page handed it a way to name the image, and no address served the bytes.
After: a recipe that has a picture of the dish shows it on its page, on its library card, and on the page the cooking address falls back to. A recipe without one says so in a sentence that is true for its source:
Reading the source never costs the page. A snapshot the store refuses to hand back still leaves both addresses readable.
Nothing writes
media.heroImageyet, so a running instance still shows no picture, and the README still says so. The follow-up PR takes the picture from the linked page's structured data when a URL is imported.How
GET /recipes/:id/pictureis behind the library credential and reads from the same byte store the photo route keeps photographs in (ADR-0009). It addresses by recipe id only: nothing in the request chooses other bytes than the recipe's own.content-typecomes only from the bytes' own signature (src/http/image-signature.ts: JPEG, PNG, WebP). The response also carriesx-content-type-options: nosniffandcache-control: no-store.recordedIdentity()insrc/storagereads back an identity a record kept, so no module outside storage casts toStorageIdentity.getstill returns nothing for an identity the store could not have issued.picture: either shown, or absent with a reason (photographed_page,none_kept,source_unread,not_served). What the caller learned about the source is three-valued (photographed_page,not_photographed,unread), and absent meansunread. "Not a photograph" and "nobody could read it" are different facts.Proofs:
tests/run/recipe-picture.test.tsruns fifteen proofs against the composed instance. Every image in it is generated by the test.Nineteen violations were planted, and each was red at its own assertion:
?identity=Object.values(c.req.query())[0])Review round 1 (BLOCK): two findings, both fixed in
fe914a2.?identity=passed every proof. A proof now names another stored image's identity under seven query keys and a header, and requires the recipe's own bytes back. It sees only the spellings it sends: measured in review round 2, a route readingc.req.query("image")stays green. A structural claim that the identity passed tomediaStore.getis unreachable fromc.reqwould close that, and is left for the next change to this route.Measured along the way:
'. A "not on the page" check against the raw string was therefore vacuous. The proofs now compare the escaped form.this. Its methods are now bound to the store.Also touched:
byte-store.tsis now loaded at runtime, and the row's reason is updatednpm run qualityon the merge with maine1a05acis green: 1396 passed.🤖 Generated with Claude Code
https://claude.ai/code/session_0125aMujwwRnPLKYSpiW6uZw
Generated by Claude Code