web/app: read-only market app with a typed nostr core - #195
Open
orveth wants to merge 10 commits into
Open
Conversation
Reads the mobee market from the relay in the browser and derives every figure it shows from those events. Static files, no bundler: the browser loads the same ES modules the tests import. kinds.js every kind number, in one file, grep-enforced relay.js pages history, then stays subscribed cache.js dedup by id; replaceable events by author+kind+d model.js typed records; nothing downstream reads a tag array trades.js trade join and market metrics Two relay behaviours that fail silently, both regression-tested: A relay caps each filter in a REQ separately, so filters sharing a REQ run out at different depths and each needs its own paging cursor. One shared cursor taken from the globally-oldest event steps past whatever the shallower filter has not delivered — the read still ends on a clean EOSE with plausible numbers while missing half the market. CLOSED is not always a refusal: the relay echoes it to acknowledge a CLOSE we sent. A real refusal carries a reason, and which one decides whether a retry can ever succeed. Settlement counts are a floor, not a total. A receipt is optional — payment travels as gift-wrap and the wallet is the only complete record — so a trade can settle with none published. The names say so. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Buyers left, live activity centre, sellers right, over a shared time window (24h / week / all time, default week). Every row opens a detail sheet: participant track records, or the full event behind a feed line. participants.js derives the boards. A window filters EVENTS, not finished trades — a trade happens at several moments, so filtering it by a single timestamp would pick one stage's clock to stand for the whole thing. Seller availability and seller history are kept apart: online means a heartbeat within five minutes, lastSeen means they did something. A seller that traded yesterday cannot take work today, and advertising them as available would waste a buyer's time. A delivery with no receipt shows as awaiting-receipt, never as a debt — settlement can complete without publishing one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The job description is the `i` tag, not the event content — every live offer has empty content, so the parser was reading a field that could never hold anything. Fixtures now carry real tag shapes; the old ones supplied a content description no offer publishes, which is why the tests passed against a parser that could not work. Offers now read as the work itself in the feed, with the full text, deliverable type and deadline in the detail sheet. Results carry the harness that did the work, how it was delivered and how long it took. Reconnect is now asserted rather than assumed: the retry timer is injectable, so the test drives the backoff instead of sleeping through it. A test that waits "long enough" is asserting against a clock and rots when anything changes speed. Verified by revert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Delivery events carry the agent runtime that produced them. Sellers show it on the board, delivery lines name it, and a seller's detail lists every harness they have delivered on with a count — a tally rather than a label, because a seller can move between runtimes. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
A seller's advert carries its name, asking rate, mint and whether it takes open-pool work. The board now shows the name instead of a pubkey prefix, with harness, ask and open-pool beneath it. Advertised values are kept visibly apart from measured ones: an advert is a claim, the trades are the evidence. Only the structured fields are shown — a seller's free-text about is frequently stale against its own rate_sats and mint, and printing both publishes a contradiction. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Name, runtime and ask fit a single row. Harness names drop their packaging — claude-agent-acp and codex-acp-ng are claude and codex to a buyer — with the exact string kept as the title. The ask is a bare number against a sat suffix, so the column scans as a price. Co-Authored-By: Claude Opus 5 <orveth@agi.cash>
Relay data is untrusted and anyone can publish to a relay. Event ids and pubkeys reach markup and data- attributes, so a value that is not really one is an injection path. They are now required to be 32 bytes of lowercase hex at the parse boundary, which means nothing downstream has to remember to escape them, and an e-tag that is not an event id can no longer become a trade key. Free text is deliberately NOT sanitised here — descriptions, names and harness strings are preserved verbatim and escaped at render. Sanitising at the model would corrupt legitimate text containing < or & and give a false impression that output is safe. Fixtures used short ids like "o1"; real ids are 64 hex, so the tests were exercising shapes the wire never produces. They now build real-shaped ids from readable labels. Co-Authored-By: Claude Opus 5 <orveth@agi.cash>
A seller's advertised rate, mint and open-pool flag are self-reported and nothing checks them against what it actually does — mobee #205, where seats advertised three wrong prices and the wrong mint for weeks while doing real-sat work. The board's ask carries that in its title; the detail sheet labels the advert block unverified and says plainly that the trades are the evidence. Same shape as the relay-openness trap: a claim the page must not restate as fact. Co-Authored-By: Claude Opus 5 <orveth@agi.cash>
Nav, hero, orderbook, key stats, buy and sell sections, footer. Deep blue with neon yellow for money and blue for structure; monospace throughout so the whole surface reads as an instrument rather than a brochure. The hero is exactly half the viewport so the book is already on screen when the page loads, and the book is one viewport tall with each column scrolling inside itself rather than growing the page. Copy-to-clipboard reports failure honestly — clipboard access can be refused, and a button that says "copied" when nothing was copied is worse than one that says "select it". Co-Authored-By: Claude Opus 5 <orveth@agi.cash>
The two pitch sections are four boxes each, read left to right — the sequence is the explanation, so someone can see the whole shape of a trade without reading a paragraph. mobee has two entry points and the hero showed one, which left half the audience without their line. Both are there now, labelled buy and sell, in blue and neon respectively so it is obvious at a glance which is which. Co-Authored-By: Claude Opus 5 <orveth@agi.cash>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Phase 1 of mobee's public web app: the skeleton plus a nostr core that reads the market and processes it. UI is phase 2.
Reads the market from the relay in the visitor's browser and derives every figure from those events. Static files, no bundler — the browser loads the same ES modules the tests import, so what ships is what was tested. Relay baked in; read-only by construction.
Layout
js/kinds.js— every kind number, in one file, enforced by a grep testjs/relay.js— pages history to exhaustion, then stays subscribedjs/cache.js— dedup by id; replaceable events resolved by author+kind+djs/model.js— typed records; nothing downstream reads a tag arrayjs/trades.js— trade join and market metricsjs/app.js— presentation only; the core never touches the DOMEvidence
npm test— 42 passing.npm run live-checkagainst the real relay: 1,101 events over 6 pages in 1.2s →112 posted / 106 claimed / 83 awarded / 78 delivered / 67 receipted · 124 receipts on record · 1,505 sats · 14 buyers · 21 sellers · 62 trades rooted outside the view.
Browser-verified on the built
dist/: reaches live, zero console errors, no horizontal overflow at 1100px or 390px. Every figure matches an independent implementation written separately against the same events — that agreement is the acceptance evidence, not a self-report.Two relay behaviours that fail silently
Both cost real debugging time today; both are regression-tested.
A relay caps each filter in a REQ separately. Filters sharing a REQ run out at different depths, so each needs its own paging cursor. One shared cursor taken from the globally-oldest event steps past whatever the shallower filter has not delivered. Measured: shared cursor read 543 events and reported 45 jobs posted; per-filter cursors read 1,101 and report the true 112. The broken version gave a clean EOSE, no error, and entirely plausible numbers while missing half the market.
CLOSEDis not always a refusal. The relay echoes["CLOSED", subid, ""]to acknowledge aCLOSEwe sent. Treating that as a rejection ends history at the first page. A real refusal carries a reason, and which one decides whether retrying can help:auth-required:may succeed later,restricted:will not.Counting settlements
A receipt is optional — payment travels as gift-wrap and the wallet is the only complete record — so a trade can settle with none published. Every settlement figure is a floor, not a total, and the names carry it (
receiptsOnRecord,satsInReceipts) so a view cannot quietly relabel it.Figures describe the market on the current protocol. The retired kinds are deliberately unread, so these counts are narrower than the analytics pipeline's view of all history.
Scope
Adds
web/app/only.web/networkis untouched and still green.