Public: anyone can read this; the crew can change it.
Add, remove and reorder a launched listing's photos; the first one is the hero on the flyer and the email #32
Check · Review · Land · Live done — Landed and live in production.
A launched listing's photos can now be added, removed and reordered, and the first photo is the hero on the flyer and the email. The tests I ran pass: `listings`, `server`, `walkthrough`, `audit`, `deps`, `openapi`, `contract`, `design` and `security`. I did not run the full suite.
shipped production is serving it deploy 957b843
A launched listing's photos can now be added, removed and reordered, and the first photo is the hero on the flyer and the email. The tests I ran pass: listings, server, walkthrough, audit, deps, openapi, contract, design and security. I did not run the full suite.
What changed
updatePhotos(src/actions.ts) takes{ id, add, remove, order }.
- It asks permit for listing:update, so only the listing's agent or an admin can use it.
- It refuses a name that isn't one of this listing's photos, so no path from user text reaches the filesystem.
- It refuses a ninth photo, an upload over 24 MB in all, and a file that isn't a JPEG, PNG or WebP. A refused change leaves the photos as they were.
- File handling follows
launch:
- New files are written inside the same transaction as the row.
- Removed files are deleted only after the row commits, through removePhotos, which skips any file another row still names.
- If the transaction fails, the new files are unwritten.
- Approvals and events: the change is audited as
listing.updateand emitslisting.changed. It clears the approval on the flyer and the email only; the MLS and social assets keep theirs. - Page:
POST /listings/:id/photosis a multipart form, same-origin checked, with the permission check done before the body is read. Edit facts now has a Photos form under the facts form, with "Make hero", remove checkboxes and an add-photos input. A new photo can be made the hero on the next save, not in the same one. - API:
PATCH /api/listings/:idtakes{ photos: { order, remove } }. Uploads are multipart on the page only, and the README says so. I regeneratedcontract/openapi.json. - README: a line under "Type the property in" says photos can be changed later.
- Tests: the two new tests in
src/listings.test.tscover add then hero, removal and the file gone, a ninth photo, another user and the audit. They also cover the page and API doors, the flyer and email<img>pointing at the new hero, and a shared file surviving removal. - Existing test changed: the Edit facts test asserted the page had no
type="file"input. I narrowed it to the facts form, since the page now carries the separate photos form.
What I chose not to do
- No MCP tool or terminal command for photos, because the issue doesn't ask for one.
- The 24 MB cap applies to the files sent in one change, not to the sum with photos already stored.
- No contract cell was loosened.
What I couldn't run
bun x tsc --noEmitprinted only tsc's help text in this sandbox, so the type check did not run.scripts/design-lint.tsdoesn't exist. I ranbun run lint:designinstead and it printed no violations.scripts/gate.tsandscripts/fast.tsdon't exist either.
To try it Open Edit facts on a listing you own and use the Photos card under the form.
Security and logic read clean: permit is asked, names are checked against the listing's own photos, the permission check runs before the body is read, the body is capped by bytes read, files are written and unwritten around the commit, and no contract cell is loosened. Changes needed, in one round:
- (first) Photos have no MCP door. Every other launched-listing edit has one (update_facts in src/mcp.ts), and the direction says what a person does on the page, an agent does through the MCP. Add an update_photos tool (id, order, remove; uploads stay page-only) that calls updatePhotos, with a test in src/listings.test.ts or the MCP tests that fails without it (stranger refused, bad name 422-equivalent, hero reordered), and add it to the README MCP list and any tool-count assertions.
- Add a row for the new door POST /listings/:id/photos (and the PATCH photos path) in src/contract.test.ts.
- The type check (bun x tsc --noEmit) did not run; get it to run and pass before pushing, since the new code has non-trivial narrowing (names(), photoChange).
- The 24 MB cap counts only the files in one change; fine given the 8-photo cap, but say so in the README or drop the claim in the form hint ("24 MB in all").
The terminal (src/tty.ts) has no Edit facts either, so I am not asking for photos there in this round.
All four review points are addressed, and I pushed to the same branch. The full suite passes (119 tests, none failing), bun run lint:design reports nothing, and the type check is clean.
- MCP door. There is a new
update_photostool insrc/mcp.tstakingid,orderandremove, and it callsupdatePhotos. Uploads stay on the page only. The README MCP list now names it and says so.src/server.test.tsexpects it in the tool list. A new test insrc/listings.test.tschecks that:
- a stranger is refused;
- a bad name such as ../../app.db is refused and nothing changes;
- reordering makes the chosen photo the hero;
- removing a photo deletes its file.
- Contract row.
src/contract.test.tshas a new test that runs the update cell for every identity, on the listing's own row and on someone else's. It goes throughupdatePhotosand theupdate_photostool. The pagePOST /listings/:id/photosand the PATCHphotospath both call the same action, so they follow this row. The page POST's refusals (cross-origin and another user) are tested insrc/listings.test.ts. - Type check.
bun x tsc --noEmitcan't run here: the repo has notsconfig.jsonand no installed types. I installedtypescriptandbun-typesin$TMPDIRoutside the repo and rantscwith strict settings oversrc/. That found a real error:photoChangepassesunknown[]into a parameter typedstring[]. I widenedupdatePhotos'sorderandremovetoreadonly unknown[]. The action already rejects non-string entries at runtime.tscis clean now, and the repo is unchanged by the setup. - 24 MB claim. The cap applies to the new files in one save, and I changed the wording to match. The form hint and the README now say "24 MB at a time" / "24 MB of new files per save". The eight-photo cap still bounds the total. The launch form's hint still says "24 MB in all", which is true there.
I did not add photos to the terminal, as you said.
All four earlier asks are met. (1) MCP door: update_photos calls updatePhotos, is listed in the README and server.test.ts, and has a test for a stranger, a bad name and the reorder and remove paths. (2) Contract row: a new test in contract.test.ts runs the listing:update cell for every identity through the action and the tool. (3) Types: order and remove are now readonly unknown[], and the action rejects non-string entries at runtime. (4) The 24 MB wording now says "per save" or "at a time" everywhere it applies to new files. Everything else holds. The action asks permit through listingFor. The page POST refuses before it reads the body and is same-origin checked. Names must belong to the listing, so no path from user text reaches the filesystem. Files are written inside the transaction and unwritten if it fails. Removal skips files another row still names. Only the flyer and email approvals are cleared, no contract cell is loosened, and there is no new dependency. Non-blocking: the terminal has no photo command. The brief said to skip it, but "both doors" would favour adding one later.
A launched listing's photos can now be added, removed and reordered, and the first photo is the hero on the flyer and the email. The tests I ran pass:
listings,server,walkthrough,audit,deps,openapi,contract,designandsecurity. I did not run the full suite.What changed
updatePhotos(src/actions.ts) takes{ id, add, remove, order }.- It asks
permitforlisting:update, so only the listing's agent or an admin can use it. - It refuses a name that isn't one of this listing's photos, so no path from user text reaches the filesystem. - It refuses a ninth photo, an upload over 24 MB in all, and a file that isn't a JPEG, PNG or WebP. A refused change leaves the photos as they were.launch:- New files are written inside the same transaction as the row. - Removed files are deleted only after the row commits, through
removePhotos, which skips any file another row still names. - If the transaction fails, the new files are unwritten.listing.updateand emitslisting.changed. It clears the approval on the flyer and the email only; the MLS and social assets keep theirs.POST /listings/:id/photosis a multipart form, same-origin checked, with the permission check done before the body is read. Edit facts now has a Photos form under the facts form, with "Make hero", remove checkboxes and an add-photos input. A new photo can be made the hero on the next save, not in the same one.PATCH /api/listings/:idtakes{ photos: { order, remove } }. Uploads are multipart on the page only, and the README says so. I regeneratedcontract/openapi.json.src/listings.test.tscover add then hero, removal and the file gone, a ninth photo, another user and the audit. They also cover the page and API doors, the flyer and email<img>pointing at the new hero, and a shared file surviving removal.type="file"input. I narrowed it to the facts form, since the page now carries the separate photos form.What I chose not to do
What I couldn't run
bun x tsc --noEmitprinted only tsc's help text in this sandbox, so the type check did not run.scripts/design-lint.tsdoesn't exist. I ranbun run lint:designinstead and it printed no violations.scripts/gate.tsandscripts/fast.tsdon't exist either.To try it Open Edit facts on a listing you own and use the Photos card under the form.
Closes #27