Public: anyone can read this; the crew can change it.
Build listing-launch #2
Check · Review · Land · Live done — Landed and live in production.
A realtor can now type in one address with its facts and photos, press **Launch**, and get the MLS description, three social posts, a flyer and an email blast. The assets show in tabs, each with an edit box, a copy button and the Fair Housing check beside it. The listing is saved and reopens at `/listings/:id`. `bun test` passes (62 tests), the design lint is clean, and a strict type check passes.…
shipped production is serving it deploy 957b843
A strong build. Generation, the context-aware Fair Housing check, the flyer and the README are all close to the brief. bun test is green on 1d904cb (62 pass). Booted locally on that head, /, /listings/1, its flyer and /api/listings all answer in 5–30 ms. Escaping in the annotated check holds, photos are sniffed by their bytes and named by the app, and asset text has its control characters stripped. The new listing row has the same cells as note; it is a new resource, not a loosened cell.
I could not reach the preview or the screenshots from my sandbox, so everything above comes from the code and a local boot of this head.
Two things block it:
- Fix first. A stranger can make the server buffer about 54 MB per request.
POST /listingsreads the whole multipart body, up toLAUNCH_MAX= 9 × 6 MB + 64 KB, beforelaunchListingaskspermit. The door only applies the contract to/api/*, so nothing stops an anonymous request earlier. I tested it locally: an anonymous 40 MB form was read in full and answered 303, and the server's RSS went to about 215 MB. Behind the forge every stranger shares one rate-limit bucket, so a handful of parallel uploads is enough to run the app out of memory. Fix: beforereadForm, ask the table whether this person may create a listing at all. Export a smallmayLaunch(who)fromsrc/actions.tsthat callspermit(who, "listing", "create"), and answer 401/403 without reading the body when it says no. Add a test that an anonymous oversized POST is refused without its body being read.
- Both doors. The terminal can read and remove listings but cannot edit an asset or change the tone. Editing and re-checking an asset is the core of the brief ("every asset edits and re-checks"), and a human approving every word has to be possible at both doors. The tty already has a one-line input for notes. Reuse it so
eon an open asset replaces its text througheditAssetand redraws the findings, andtcycles the tone throughretoneListing. Say so in the README.
Smaller points, inline: MCP has no delete_listing even though the page and the API both delete, and the tty decides whether to show delete with the update verb.
Mason: please address sentinel's review on this pull, point by point, and push; keep the sample data and the README a realtor can read.
All of sentinel's points are fixed and pushed, with nothing I disagreed with. Pull #2 adds a listing launcher, and this round closes the 54 MB anonymous-upload hole and gives the terminal and MCP the same edit and remove verbs as the page. bun test passes (68), the design lint is clean, a strict type check passes, and the sample listing is still there.
1. A stranger can no longer make the server hold a large upload.
POST /listingsnow calls a newmayLaunch(db, who)insrc/actions.tsbefore reading anything. It askspermit(who, "listing", "create"). If the answer is no, the server replies 401 or 403 without reading the body and records the refusal in the audit trail.- The total upload a signed-in agent can send is now 24 MB of photos plus the 64 KB form cap, down from about 54 MB. Each photo is still at most 6 MB. A body over the cap goes back to the form with "Too large: up to 24 MB of photos in all." The form's hint says the same.
- New test in
src/listings.test.ts: an anonymous POST whose body sends one chunk and then never finishes is still answered 401. That proves the body was never waited for. With the check removed, the test fails ("waited for the body"). The test sends that request on its own connection so the unfinished body can't leak into the next request.
2. The terminal can now edit and re-check.
- I split
src/tty.tsinto asession(db, who)that takes keys and returns the screen; the live terminal only runs when the file is executed directly. This let me test it. - On an open listing,
j/kmove between assets.eputs the asset's words into the existing one-line input, where typing\nmakes a line break;entersaves througheditAssetand redraws the Fair Housing findings.trewrites every asset in the next tone throughretoneListing. - Only the listing's agent or an admin gets past
eort; anyone else is told so. - Launching a listing with photos stays on the page only, and the README says so.
- New
src/tty.test.tscovers editing an asset and seeing the findings again, changing tone, being refused on someone else's listing, removing your own, and line breaks surviving the one-line input.
Smaller points.
- The terminal's remove now asks the delete permission (a new
canRemoveListing), not the update one. - MCP has a
delete_listingtool that callsdeleteListing;src/server.test.tschecks that a stranger is refused and an admin can remove. It's added to the README's tool list.
To try it: in the terminal, press enter on a listing, j to the short post, e, type "family-friendly", then enter. The status line turns amber and the reason appears under the asset.
🤖 Generated with Claude Code
Most of this is solid. Every door I tried at this head behaves correctly: an anonymous write gets 401 (the launch is refused before its body is read), a cross-site POST gets 403, ../ in a photo name gets 404, uploads are identified by their bytes and saved under names the app picks, every output is escaped (the live-check HTML included), there is no SQL built from user text, and the security headers are on every response. Every action goes through permit, src/contract.test.ts has the listing row, and bun test passes (68 tests). Locally /, /listings/1 and the flyer answer in 2–12 ms. The new listing resource has the same cells as note, including anon read and list so a flyer link can be shared. The pull says so, and a person decides that cell. I could not open the preview or the screenshots from my sandbox, so this review comes from the code and a local run at 2bc9410.
Fix first: terminal writes do not reach web viewers live. The terminal is a separate process (bun run src/tty.ts). emit() in src/live.ts is an in-memory bus, so when a listing is edited, retoned or removed from src/tty.ts, nothing reaches /events. Someone with the listings page open sees the change only after a reload. The README now says a listing "changed or removed elsewhere" appears without a reload, which is not true for the terminal. Make the bus cross-process. For example, write each event to a small event table in the shared SQLite database and have the server tail it, either by polling max(id) / PRAGMA data_version every ~250 ms or by having /events read from that table. Then add a test: a session(...).press('x') on one db shows up on an /events stream served from the same data directory.
Also fix, in order:
- The launch body can make the server read up to ~200 MB.
readForm(c.request, LAUNCH_MAX)puts the cap at 24 MB, andreadBodythen drains up to 8× the cap (~196 MB) before it cuts the connection. A signed-in user also gets 20 writes in a burst, and each one holds the body roughly three times over (raw bytes, then FormData, thenarrayBuffer). For this route, cut off as soon as the cap is passed (drain 0×), or keep the drain to a fixed size such asBODY_MAX. - Photos are written to disk before the row exists. If
insertListingthrows, the files stay underphotos/with nothing pointing at them. Insert the row inside the transaction and write the files after it, or remove the saved names when the insert fails. - Control characters reach other people's terminals.
line()andassetText()strip C0 control characters but not C1 (U+0080–U+009F). Some terminals treat U+009B as CSI, and addresses and asset words are printed raw to other people's terminals. Add\u0080-\u009fto both character classes.
Mason: round two from sentinel is on the pull; fix what it names (the high one first), push, and say in one line what changed.
All four of sentinel's points are fixed and pushed. Edits, tone changes and removals made in the terminal now reach open web pages live, and a launch's body is cut off at the cap. Photos are saved together with their listing or not at all, and C1 control characters are stripped from typed words. bun test passes (72), the design lint is clean, and a strict type check passes. I disagree with nothing in the review.
1. Terminal writes reach web pages live (the high one).
- Events are now rows in a new
eventtable in the shared SQLite file (a new migration). emit(db, type, data)writes the row, and the server watches the table (followinsrc/live.ts). It hands its own writes to open pages at once and picks up writes from another process every 250 ms.- Event ids now come from the table, so
Last-Event-IDand?since=still replay the last 100. Each stream skips anything it already sent, so nothing arrives twice. The table keeps its newest 1,000 rows. - The README's "appears without a reload" is now true for the terminal as well, and
AGENTS.mdshows the newemit(db, …)form. - New test in
src/tty.test.ts: a terminal session on its own connection to the data directory pressesx. The server's/eventsstream then carrieslisting.removedfor that listing.
2. The launch body is cut off at the cap.
readBodyandreadFormtake adrainlimit (it stays eight times the cap everywhere else).POST /listingspassesdrain = 0, so the connection is cut as soon as the 24 MB cap is passed.readBodyused to read one more chunk before checking that limit; it now stops at once.- A new test streams a 100 MB body in 1 MB chunks against a 3 MB cap: at most six chunks are pulled.
3. No orphan photos.
- A launch names its files first, then inserts the row and writes the files inside one transaction. If either step throws, the transaction rolls back and the written files are removed.
src/photos.tsnow hasnameFor,writeandunwritein place ofsave.- A test covers both orders: an insert that fails leaves no file, and a file write that fails leaves no row.
4. No C1 control characters.
line()andassetText()now strip U+0080–U+009F along with C0.- I applied the same cleaning to note bodies, which the review did not name. Notes are also printed in other people's terminals; their control characters become a space.
- A test feeds
U+009B(which some terminals treat as CSI) andESC[2Jthrough an address, the features, an asset and a note.
🤖 Generated with Claude Code
Solid work. Security holds up: every action asks permit (no role is compared by hand), identity comes only from who(), the launch body is capped by bytes actually read and cut off rather than drained, the table is asked before a stranger's launch body is read, photos are recognised by their bytes and stored under names the app chooses (I tried /listings/1/photos/..%2F..%2Fapp.db and got a 404), the rate-limit key is the socket address or the identity, and output is escaped (the live check's innerHTML only receives server-escaped HTML, which I checked with <script>). Text that reaches the terminal has C0 and C1 control characters stripped. Events carry only ids, so a stream leaks nothing a guest cannot already read. I rebuilt the head from the diff: bun test passes 72/72, and locally every page answers 200 in 3–16 ms. A guest's PATCH gets 401, a guest's delete changes nothing, and /listings/abc gets 422. The new listing row in contract.json adds a resource rather than loosening an existing cell, its cells match note, it has a row in src/contract.test.ts, and the pull flags it for a person.
One miss, so changes are requested: the terminal cannot launch a listing. Launch is the app's core action. The page, the JSON API (POST /api/listings) and MCP (launch_listing) all offer it, but src/tty.ts does not, and the README calls it "the page's alone". Our bar is that whatever a person can do on the page, they can do in the terminal. The API already shows the way: a launch from the facts alone, with no photos.
Fix first: add an n key on the listings view of src/tty.ts. It should walk the fields one prompt at a time: address, beds, baths, sqft, lot, year, price, features (enter adds one, an empty line ends the list), tone, then the brand, prefilled from the person's last listing the way the home page's draft is. Then call launchListing(db, me, { fields }) with no photos, report the action's invalid message on the status line, and open the new listing in the reader. A guest pressing n should get "Sign in to launch a listing." Add a test in src/tty.test.ts (a user launches and lands on the listing with five assets; a guest is refused and nothing is written), and update the README terminal paragraph and the tty.ts header comment. Everything else can land as it is.
Read the whole change against the brief and the bar, then ran the head locally. The preview host and the forge's screenshots are outside this reviewer's sandbox, so the doors were exercised on a local boot of 772a788 with signed identities instead.
What holds:
- Identity and authorization. Identity comes only from the HMAC-signed forge headers (
who.ts, unchanged). Every listing action goes throughlistingFor/permit, with no role compared by hand. Bob editing, retoning or deleting Alice's listing gets 403 over the JSON API, the page form (redirected back with the refusal) and MCPedit_asset. Anonymous writes get 401 everywhere. An admin delete succeeds. A cross-site form POST is refused by the door. - Bodies. Bodies are capped by the bytes read: a chunked 200 KB JSON body gets 413, and a 30 MB chunked launch form is cut at
PHOTOS_TOTAL + BODY_MAXwithdrain = 0.mayLaunchasks the table before a byte of the photo form is read. - Photos. Photos are sniffed by their magic bytes and saved under app-chosen random names.
NAMEguards every path, and a traversal attempt answers 404. - Escaping.
<script>in a feature and<b>in the agent name come out escaped on the listing page and the flyer.annotateescapes every segment. The terminal input loses its control characters (line,assetTextand the note'soneLine, C1 included), so nothing typed can drive someone else's terminal. - Contract.
listingis a new resource with the same cells asnote, has its row of doors incontract.test.ts, and loosens no existing cell. - The terminal. The terminal reads and writes through the same actions with the same
permitchecks. Its writes land in theeventtable and reach/eventsfrom another process, andtty.test.tscovers that. - Tests.
bun testpasses: 72 tests, 0 failures. Pages answer in 2–16 ms locally. No dependencies, no TODOs, and the README is written for a realtor.
One note, not blocking: see the line comment on src/pages/listing.ts.
A realtor can now type in one address with its facts and photos, press Launch, and get the MLS description, three social posts, a flyer and an email blast. The assets show in tabs, each with an edit box, a copy button and the Fair Housing check beside it. The listing is saved and reopens at
/listings/:id.bun testpasses (62 tests), the design lint is clean, and a strict type check passes. Common pages answer in about 2–30 ms locally. There are no runtime dependencies.Policy change — please review.
contract.jsongains a newlistingresource whose cells are the same asnote: create user · read anon · update author · delete author · list anon. Strangers can now read and list listings, which they could not before. The forge may treat that as a loosened cell and hold the pull for a person. I chose anon read so a flyer link can be shared with buyers. Thenoteandauditrows are unchanged.What changed
src/fairhousing.ts): rules for phrases in context, not a word list. Each rule names the protected class it touches, a one-line reason and a rewrite. "family room", "integrated appliances", "Church Street" and "exclusive listing" pass; "family-friendly", "integrated neighborhood", "walking distance to church", "safe neighborhood" and "no kids" are flagged. "master bedroom" is a note rather than a risk. The check is never stored: it runs every time the words are read, after every save, and again as you type (viaPOST /fair-housing).src/generate.ts):generate(listing)builds every asset from what the agent typed, in three tones (classic, warm, bold), with the brokerage and license number included. A test holds every template clear of every rule in every tone. A real model can later sit behind this one function.src/listing.tschecks input at the boundary;src/photos.tsstores photos): photos are accepted only if their first bytes say JPEG, PNG or WebP. They are saved under random names beside the database and cached for a year. Removing a listing removes its files.src/pages/:- The first screen is the form plus the launched listings. A sample listing (412 Alder Lane) is planted once so the screen is never empty. - On a listing, the tabs are rendered on the server (
?tab=), so there is no layout shift and they work as plain links without JavaScript. - Someone who can't edit a listing is offered "Start a new one from this", which copies it into the form./listings/:id/flyer: hero photo, price, a strip of the facts, the description, the agent's headshot, name, brokerage and license number, and the Equal Housing Opportunity line. It prints on letter paper with only the flyer showing.design/tokens.json, and every pair is tested for contrast./api/listings(contract/openapi.jsonregenerated). There are six new MCP tools, and the terminal now lists listings and shows their assets and findings./notes.Tests I changed
src/server.test.ts: the notes-page checks now fetch/notes, and the MCP tools list includes the new tools.src/contract.test.tsgains the listing checks;src/design.test.tschecks the new colour pairs.Left out on purpose
To try it in the preview
/and launch a listing with a few photos.🤖 Generated with Claude Code
Closes #1