sentinel/bazaar

Public: anyone can read this; nobody can change it.

An e-commerce store: a catalog with categories and search, product pages with variants and stock, a cart in a signed cookie, checkout with address and a payment step through Stripe test mode when STRIPE keys are set and a clearly labelled mock gateway otherwise, order confirmation with email-style receipt pages, and an /admin for products, inventory and orders with refunds. Prices in integer cents, CSRF on every form, rate limits on checkout.

forked from trevato/bazaar

workbenchthis machine

This repository has no app yet.

following trevato/bazaar in step

Everything here is up to date with trevato/bazaar.

Proposes updates from trevato/bazaar.

README.md

bazaar

A small shop: a catalog with categories and search, product pages with variants and stock, a cart that follows the shopper, discount codes and gift cards, tax by where the parcel goes, a gift card balance window, checkout with an address, a payment step, receipts, dispatch, and an /admin for products, inventory, codes, cards, tax and orders with refunds and dispatch. Looking around and filling a cart need no account; buying needs one, because an order belongs to the account that placed it and /orders is that account's list of them. Every price is an integer number of cents, every form carries a CSRF token, and checkout, payment, typing a code, reading a gift card's balance and finding an order are each rate limited on their own. A category or a search longer than 24 things is paged, so a listing is never a page without an end and never hides what is past the end of it.

This is an app: the forge runs it. Production follows the default branch; every open pull gets a preview at its own address with its own copy of the data. Everything the app keeps lives under FORGE_APP_DATA, which is what makes "production data" and "preview data" two different things.

PORT=8080 FORGE_APP_DATA=./data bun run src/server.ts   # run it here
bun test                                                # the check every pull runs
FORGE_APP_DATA=./data bun run src/tty.ts                # the terminal interface

It has no dependencies: Bun's bun:sqlite, node:crypto and the standard library are all of it. The shop has no client-side JavaScript at all; the admin's live pages load one small file — see The admin sees it happen.

What is real and what is simulated

  • Real: the catalog, stock, carts, orders, discounts, gift card balances,

tax, totals, refunds and the ledger behind them. Stock, the use of a code and the money off a card are all taken in the same transaction that writes the order's lines, so nothing is sold, spent or discounted twice; refunds are checked against what is left refundable inside a transaction, and go back the way they came — the gateway first, then the card. An admin's changes to what the shop sells and what it charges are a ledger too: a price, a slug, a discount code or a tax rate that moves writes a row at /admin/changes naming who moved it and what it was before.

  • The audit trail: every stock movement, payment, refund, order event,

gift-card movement and use of a code names the actor who caused it — the buyer for a purchase, the admin for an admin's action, stripe for a webhook, system for the seed, and the forge's account for anything typed at the terminal. /admin/audit reads those five ledgers as one timeline, newest first, narrowed to one order by its number and paged with a cursor; it is an admin's page, and an admin's alone, as is the audit tab in the terminal. For one order it shows the arithmetic rather than a claim: charges plus the gift card, less refunds and what went back on the card, against what the order says was paid. Rows written before the actor column existed say unknown, which is the truth about them.

  • Simulated unless you configure it: the payment. With no Stripe keys the

store uses a mock gateway — a page labelled as a mock, with Approve and Decline buttons, that moves no money.

  • Never real: email. There is no mail server. A letter is composed and

stored, shown as an email-style page at /orders/<number>/receipt and listed in the admin outbox at /admin/mail. There are two letters about an order and no more — the receipt when it is paid, and "on its way" when it is dispatched — each written once and never rewritten. Because no letter is ever delivered, /orders is how a shopper gets back to one — see below.

  • Never real: the parcel. No goods ship. Dispatch is an admin saying

the parcel went: a date on the order, a row in its history and a letter in the outbox. Nothing is handed to a carrier and no tracking reference is looked up anywhere — whatever is typed in that box is stored and shown back, and that is the whole of it. It is a demonstration store, and every page says so.

Stock, and who gets the last one

Stock only ever moves by writing a row in stock_entry that says why, and variant.stock moves in the same statement. Taking it for a sale is a conditional UPDATE ... WHERE stock >= ?, so the shelf can never go below zero however many tills ask at once.

That statement is also the only thing that decides who gets the last one. The cart page and the checkout page both say what is on the shelf — a line that asks for more carries only N in stock in the same words on both — but a count read while a page was being drawn is not a promise. If the last one goes while you are typing your address, the shop tells you so and you lose nothing else. Your order is written, your code redeemed and your gift card spent inside one transaction; the UPDATE that takes the stock refuses, and the whole attempt is rolled back: no order, no line, no event, no use of the code, no movement off the card, and the order number it had drawn goes back in the hat. The checkout page comes back saying what the shelf says now — "… is out of stock." when it is gone, "Only 2 left of …" when some are still there — your cart, your code and your card are exactly as they were, and there is a link back to /cart so you can change the line rather than press a button that will fail the same way. The wording is never a race the shop cannot vouch for: "sold out while you were checking out" is said only when the quote handed to place() still had the unit. Over the web that quote is taken a few lines before the UPDATE, not when the page was drawn (#64 again), so what a shopper reads there is almost always the shelf's plain words.

A gift card in that same gap is three different stories. A card another till empties to nothing is no longer a code this cart can use: the checkout page comes back with "Gift card ····1234 has nothing left on it.", no order is written and the balance is untouched. A card that is spent down but is still worth what the page said it would pay keeps its promise, so the order goes through exactly as it was shown — the shopper is not told about money that was never theirs. A card that falls below what the page said it would pay cannot keep that promise, and orders.place refuses with "That gift card no longer has that much on it." rather than quietly asking the gateway for the difference. That last check compares what the card is worth now against what the page showed it paying, not against what it was worth then: a card falling from $200 to $100 still covers a $20 order, and refusing it would say a sentence that is not true of this order.

The middle and last stories cannot be told apart from the web today, because src/shop.ts builds the "shown" quote by pricing the same cart again in the POST handler, so the comparison is a quote against itself and a part-spent card is simply re-priced — what is left comes off the total and the gateway is asked for the rest. Issue #64 — the form carrying the figures it was drawn with, signed — is what makes that check bite over HTTP.

src/contention.test.ts is that story as a test — two place() calls for one unit, with the loser's rollback asserted ledger by ledger. src/server.test.ts holds the same ground over HTTP: the shopper who misses the last one keeps cart, code and card, and the emptied card is refused with its balance intact.

Codes, cards and tax

One box at the cart takes a code of either kind, because a shopper does not know which of the two they were given. What it is worth is read from the database on every render; the cookie carries the name of the code and nothing else, so editing it can change which code you are claiming, never what it takes off.

  • Discount codes are a percent, a fixed number of cents, or free

shipping, with an optional minimum, a start and expiry, and a cap on how many times they can be used. A discount applies to goods only — money is not bought at a discount — and every limit it carries is checked again by the conditional UPDATE inside the order's transaction: whether it is on, whether now is inside its window, and whether a use is left. A code with one use left goes to one person, and a code that expires between the cart and the order is not taken anyway. Every use, and every use given back when an order is cancelled, is a row in discount_redemption. An expiry is stored as a stamp and compared as text, so it has to be a real day: discounts.endOfDay parses it and round-trips it, and the form and the terminal both refuse 2026-99-99 rather than store a code that outlasts every date there is. WELCOME10 and SHIPFREE are seeded.

  • Gift cards are sold as an ordinary product (/p/gift-card) and issued

when the order is paid; the code is printed once, on the receipt. Only the code's HMAC and its last four characters are stored, so the table cannot be read for spendable codes. A balance moves only by writing a row in gift_card_entry, and spending is conditional on the balance being there, so two checkouts racing for the last $5 cannot both win and a balance never goes below zero. What a card cannot cover is left for the gateway; a card that covers the lot means there is no gateway to ask. /gift-cards shows whoever holds a code what is left on it and every movement of it: the code is asked for with a POST so it never lands in a URL or a log, the page has its own rate limit so reading a balance never costs a shopper the right to apply a code at the till, and a code the shop does not know is refused in the same words as one that is empty. Each movement is named in the shop's own fixed words — "Issued", "Spent on an order", "Taken off the card" — never the note an admin typed to themselves when they voided it. The window shows the last twenty movements and says so when there are more, and it offers to spend the card only while it is live and has something on it. The confirmation page says, next to the gift card line, that the code is on the receipt and that this is the only copy.

  • Tax is a table of rates in basis points by country and region

(/admin/tax), matched on the address the shopper typed — the exact region first, then the country, then a default. It is taken on goods after the discount, never on shipping and never on the sale of a gift card, which is money moving rather than a sale. The rate and its name are copied onto the order at the moment of sale, so changing the table never rewrites what somebody was charged. Before an address is typed the cart and the checkout page estimate with the home country's row — tax.HOME_COUNTRY, the country the checkout form itself offers — so the rate a shopper is shown and the rate the order charges are one lookup in one table, and the "anywhere else" default set at /admin/tax is what it says it is: the rate for the places the table does not name.

The arithmetic runs in one fixed order, every step an integer number of cents: goods, less the discount, plus shipping, plus tax on what is left of the goods, plus gift cards being bought — and only then is a card's balance taken off to leave what the gateway is asked for. bun test reconciles a cart with all three against the order it becomes.

A gift card is never allowed to leave a balance the gateway would refuse. Stripe will not take a charge under fifty cents in USD, so a $24.80 card against a $25.00 order would strand the shopper with twenty cents nothing will accept and no way to raise the card. The minimum is named per currency in gateway.minimumCents(currency), next to the gateway that has it — zero for the mock, which takes whatever it is handed — and the card either pays the whole total or holds back just enough that at least the minimum is left to pay. The cart and the checkout page say which of those happened in a sentence, with how much of the card was spent and how much stays on it — and when the total is itself at or under the minimum, that the card is staying out of the order altogether, which is true whether the order can be paid by card (a total exactly at the minimum) or not at all (one under it). The selected order in the terminal says the same split under its total, so an operator reading a $25.00 order against a $0.50 payment can see where the rest went (desk.giftCardSplit).

An account buys; browsing does not need one

/, a category, a product page, the cart, adding to it, changing it and typing a code all work signed out, exactly as they always did. Buying is where an account starts, because an order has to belong to somebody:

  • GET /checkout for a shopper the forge has not signed in shows the same

order summary with the address form replaced by Sign in to buy, pointing at /__forge/signin?to=/checkout. Nothing about the cart is touched, so the same cart is there on the way back. The cart page says it too, with a full cart in front of the shopper rather than after they have typed an address.

  • POST /checkout without an account writes nothing at all — no order row, no

stock taken, no code redeemed — and answers with a 303 back to /checkout.

  • An order records buyer_id and buyer_name from the verified identity. The

email column is still the address the shopper typed: a forge account name is not an email address and is never written there. The email box is prefilled only when what the forge gave us is itself an email.

The account is also who an order opens for. src/shop.ts's openOrder is the only place that decides, and it knows three ways in: an admin, the account the order belongs to, and whoever holds the order's own access token, compared in constant time. So /orders/<number>, its receipt and /pay/<number> open for their buyer with no ?token= at all, and a signed-in stranger gets the same 404 a stranger has always got.

Where a cart lives, and for how long

A cart follows the shopper, not the browser. Which of the two places it is in is decided once at the front door, in src/heldcart.ts, and no handler asks: ctx.cart is the same CartLine[] either way.

Who is askingWhere the cart isHow long it is kept
A guestA signed cart cookie, ids and quantities only, at most 20 lines30 days, or until the browser drops it
An accountRows in cart_line, keyed on (buyer_id, variant_id) — the same forge id that owns their ordersEach line 90 days from the last time it moved; swept at boot beside the sessions

Signing in is the one moment the two meet. On the first request after the forge starts vouching for a shopper, the cookie's lines are merged into the account's and the cookie is cleared, so from then on a signed-in cart writes rows and no cookie at all. A variant in both carts keeps the larger quantity, never the sum — so a reload, a prefetch or two tabs racing cannot double a cart, and the merge is safe to run from anywhere. It runs only for an identity src/who.ts verified; there is no other way to name a buyer.

Because the cart is the account's, the same account in two browsers is looking at one cart, and signing out leaves the browser empty without touching a line of what the account holds. Filling a cart while signed out and signing in again merges it back in beside what was waiting.

The 90 days are each line's own: only a quantity that actually moved rewrites updated_at, so a line put in on day 1 of a cart still being used on day 80 goes on day 91 with the rest of that cart untouched. That is a choice and not an oversight — the stamp is also the order the cart is read back in, so touching every row on every save would reshuffle the page each time a shopper changed a quantity. Ninety days is long after a cart has been abandoned or bought.

The boundary is the same wherever the lines are kept: a quantity is clamped to 1..99, a variant that has gone inactive or out of the catalog drops out of the quote when the cart is priced, and one buyer may hold at most 50 lines — a table a stranger can grow is a table with a cap. A row is ids and quantities and nothing else; what a cart costs is read from the catalog every time it is quoted, so a row can change what is in a cart and never what it is worth.

What the access token is still for

It is not dead code. The orders placed before accounts existed — buyer_id is '' on every one of them, added with addColumn and a default so they keep rendering — have nothing else, and the token is still what travels: in the receipt, in the mock gateway's forms, and in the success_url and cancel_url Stripe sends a shopper back to. to= and rel="noreferrer" keep it out of the sign-in links, as before.

Finding a guest order again

/orders is an account's own orders, newest first — number, date, status, total and a link that needs no token. A shopper who is not signed in is offered the way in and never a list of anything. Under the list are the two halves of the way back to a guest order:

  • Found in this browser. A guest order found here appends its number to a

signed mine cookie, at most ten deep — found through the form below, or by opening the order with its own token, which is the same browser finding the same order and is what makes signing in mid-purchase land somewhere useful. The cookie holds numbers and nothing else; the link is built from the access token read out of the row, so editing the cookie changes which number is being claimed and never what opens. A shared computer is not a shopper's, so Forget these orders drops the cookie. An order an account owns is listed from the account, so it is never put in the cookie. This half is shown to whoever is holding the cookie, signed in or not: a guest order belongs to a browser, and a list nobody can see is a list nobody can drop.

  • The form, for a guest order and nothing else. An order number and the

email it was placed with. An order that belongs to an account is not found this way — it has a door of its own, and a number and an email are not proof of being its buyer — so it is refused in the words a miss gets, after the same work, and leaves nothing in its trail. Both fields have to be right, the email is compared as a keyed digest so no letter of it times differently — and a number that matches nothing is digested against a fixed string, so a miss does the same work as a match — and every refusal is the same words whether the number does not exist, does not match, or is not this form's to hand over. This form is not a way to learn which order numbers are real, nor which of them are owned. It is rate limited like the other guessable things (see below), and a lookup that succeeds leaves a looked-up row in the order's events with the address it came from.

What a rate limit is keyed on

A limit only limits if the caller cannot choose which bucket it spends, so src/peer.ts decides where a request came from and a bucket is keyed on that and nothing else: the address the socket arrived on. X-Forwarded-For is not read on the strength of its own existence. It would be the useful thing — it names the shopper behind a proxy — but only if a proxy we trust appends to it, and nothing this app can see from the inside proves that one does. A client can send that header itself, and a hint nobody vouches for is the caller's own word; keyed on the leftmost entry, as this app used to be, every bucket here was ten tries per invented header.

The consequence is deliberate and worth knowing: behind the forge's proxy every shopper arrives on the same address, so these are the shop's limits rather than each shopper's, and the capacities in src/ratelimit.ts are set for a shop — 30 checkouts a minute, 30 codes and 30 balance checks every two minutes, 30 order lookups every five. Ordinary trade never meets them; guessing at a gift card code or an order number runs out of tries long before it runs out of guesses.

The one thing that can vouch for the header is the deployment, and only by counting. FORGE_APP_TRUSTED_HOPS says how many proxies of ours stand in front of the socket; a proxy appends the peer it saw to the right of the chain, so with 1 the shopper is the right-hand entry, with 2 the one before that, and everything further left — where a client writes what it likes — is never read. Set it and the limits above go back to being each shopper's. Unset, which is how the forge runs today, the header is not read at all, and nothing is inferred from the shape of an address: a guess about who is proxying is not a fact about it. src/peer.ts is the only file that reads the header.

The address is checked for the shape of one before it is used, which is also why the looked-up note on an order can only ever hold an address and not a sentence somebody chose for an auditor to read.

Payment

EnvironmentWhat happens
STRIPE_SECRET_KEY=sk_test_… (or rk_test_…)A Stripe Checkout Session in test mode is created from the order's own lines and the browser is sent to Stripe's hosted page. No card number is ever typed into this app. Test card 4242 4242 4242 4242.
STRIPE_SECRET_KEY=sk_live_…Refused. The store says so on the payment page and uses the mock instead.
unsetThe mock gateway, clearly labelled.
STRIPE_API_BASEWhere Stripe is. It exists so the tests can point at a fake Stripe and is only honoured when it names this machine (127.0.0.1, localhost, [::1]); anything else is ignored and real Stripe is used, so a key cannot be sent somewhere an environment variable chose.

With Stripe, an order settles either when the shopper comes back (the session is read once) or when the webhook arrives at POST /webhooks/stripe, verified against STRIPE_WEBHOOK_SECRET. Whichever is first wins: the charge carries an idempotency key, so it is recorded once. Refunds go to Stripe first and to the ledger second, with an idempotency key built from the order and what has already been refunded.

Both of those paths, and the mock gateway, settle through one function — orders.settleFromGateway — so there is one rule about what an amount means. The gateway's amount is recorded, never believed: the money that moved is a payment row whatever it was, and only an amount that covers what the order owes marks the order paid. Less than that leaves the order awaiting payment with a short-payment event saying what was asked for and what arrived; the order page tells the shopper in plain words, /admin/orders/<number> shows the shortfall beside the payments, and the payment step is closed so a second charge cannot be taken against a key that could not record it. Nothing is refunded or cancelled automatically — a short payment is a person's decision.

What Stripe is asked for is the order's total less whatever a gift card already paid — dueCents in src/money.ts, which the payment page, the receipt, the mock and Stripe all read, so no two of them can drift. Usually that is the order's lines plus one for shipping and tax; when a discount or a gift card applies it is a single line for the balance, because Checkout has no negative line and the money must not come off twice.

Pressing "Pay with card" is a form POST to this app that answers 303 to Stripe's hosted page, and the security floor says form-action 'self' — which Chromium applies to the redirect a form navigation follows. That one response, and only it, carries form-action 'self' <the Stripe origin it is sending the browser to>; every other response keeps the floor as it is.

The whole of that path is tested. src/stripe.test.ts stands a fake Stripe on 127.0.0.1 — the three endpoints this app calls, nothing else — points STRIPE_API_BASE at it and boots the app in its own process against it, then asserts what went down the wire: the amounts, the currency, the idempotency keys, the shopper's return, the webhook arriving before and after it, a return and a webhook that pay less than the order owes, a refund, Stripe answering 500, Stripe stopped, and a live key refused with no request made at all. No test in this repository talks to the network.

After it is paid: dispatch

An order's life used to end at paid. It has one more step, and the step is a column and a row rather than a state machine:

  • order.fulfilled_at — added with addColumn, null on every order placed

before it existed, which is the truth about them: nobody wrote down that they went. status goes on meaning what it meant. It is about the money — paid, partially_refunded, refunded — and fulfilled_at is about the goods, because those are two different facts: a refunded order may well have gone out, and a paid one may not have.

  • One order_event of kind dispatched, naming the admin who did it from the

actor columns every ledger row here carries, with the carrier and the tracking reference in its detail. It shows up in the order's history, in /admin/audit and in the terminal's audit tab like any other row.

  • One letter in the outbox: "your order is on its way", with the tracking

reference on it. The receipt already written is never touched — email.kind is what tells the two apart.

POST /admin/orders/<number>/fulfil takes a carrier and a tracking reference, both optional and both capped at the boundary; it is admin-only and same-origin like every other admin POST. Only a paid order goes: the UPDATE carries the rule in its WHERE, so two admins clicking at once write one row, one letter and one date between them, and the second is told it had already gone. Anything that is not paid is refused in words, and the refusal is itself a row on the order.

Cancelling an order that has been dispatched is refused in words — it can be refunded, but not cancelled — and refunding one works exactly as before, with restocking still the choice the admin makes.

The buyer sees the order's life in three plain steps on their own order page: placed, paid, dispatched, each with its date or a dash, and under them what happens next in words — pay for it, wait for the shop, or nothing, because nothing ships. The tracking reference is shown there, escaped like every other value. No JavaScript and no new dependency for any of it. /admin/orders and the terminal's orders tab tell dispatched from paid at a glance, and d at the terminal does the same job as the form.

Environment

VariableMeaning
FORGE_APP_IDENTITY_KEYThe key the forge signs x-forge-identity with, verified in src/who.ts. Unset means no request can be verified: everybody is anonymous and /admin is closed to everybody. There is no password and no way around this.
FORGE_APP_TRUSTED_HOPSHow many proxies of ours stand in front of the socket, for the address a rate limit counts. 1 means x-forwarded-for's right-hand entry is the client. Unset or 0, the header is not read at all and a bucket is the whole shop's.
APP_SECRETSigns cart cookies, sessions, CSRF tokens and order links. Generated into FORGE_APP_DATA/secret when unset.
STRIPE_SECRET_KEYA Stripe test key turns on real Stripe Checkout.
STRIPE_WEBHOOK_SECRETVerifies POST /webhooks/stripe.
STRIPE_API_BASEWhere Stripe is, for pointing the tests at a fake one on this machine. Ignored unless it is a loopback address; defaults to https://api.stripe.com.

The forge sets PORT, FORGE_APP_DATA, FORGE_APP_ENV (production or preview), FORGE_APP_URL, FORGE_APP_SSH and FORGE_APP_COMMIT when it starts the app, and FORGE_APP_USER, FORGE_APP_USER_ID and FORGE_APP_USER_ROLE when it opens the terminal. forge.json says how.

Who can do what

Identity is the forge's to assert and ours to verify. On every request the forge sends x-forge-identity — <userId>|<username>|<role>|<expiresMs> — and x-forge-signature, base64url of HMAC-SHA256 over the identity with FORGE_APP_IDENTITY_KEY. src/who.ts recomputes that signature and compares it in constant time; anything it cannot verify is { role: "anon" }. The session cookie is still there, but it only carries the CSRF token now.

WhoWhat
anonReads the shop: catalog, search, product pages, the cart with everything it does, and an order with the order's own token. Cannot buy.
userBuys: everything anon can do, and an order of their own — /orders lists it, their order, receipt and payment pages open without a token, and the terminal's orders tab shows the same list.
adminRuns the shop: every path under /admin, and any order without its token.

Anyone who is not an admin gets one page at /admin, status 403, whose only action is Sign in at /__forge/signin?to=<the page they asked for>; the site header offers the same link, and Sign out at /__forge/signout once somebody is signed in. That to is the page's path and query, so signing in from /search?q=beans comes back to the results — with one exception: an order's token is stripped, because it opens that order to whoever holds it and the sign-in path is handed to the forge. A page the token was what opened (/pay/:number, /orders/:number, its receipt) is a 404 without it, so those come back to /orders instead, which opens for anybody: the account's own orders once they are signed in, and under them the guest orders this browser has opened, each with its own token link — so a shopper who signs in part-way through paying is one click from finishing either way. Those links also carry rel="noreferrer": /__forge/* shares this origin, so the Referer would otherwise hand over the very URL to was careful to clean.

A page only a POST reaches is the same dead end: a missed lookup at POST /orders/find renders "Your orders", and a declined mock payment at POST /pay/:number/mock renders the payment page, but a GET of either is a

  1. So to is worked out once, at the front door, and handed to every page

as ctx.backTo: this URL when a GET renders a page for the route's own pattern — getAnswers in src/routes.ts, asked of the pattern, because /orders/find is shaped like /orders/:number — and /orders when it does not. No page chooses its own sign-in target, so none of them can choose a 404.

The token goes the other way too: nobody is handed one they did not need. src/orderlink.ts says where an order lives for the person being sent there — the bare path for its buyer and for an admin, ?token=… for whoever it took a token to get here — and openOrder reads the same predicate, so a buyer who opened their order by account never finds its secret written into their history by a redirect or a link on the page. Stripe's success_url and cancel_url still carry it: that browser leaves this origin, and a forge session that lapses while it is away must not make the way back a 404.

The terminal takes the same three roles from the environment, and gives each of them what the pages give it. Stock and the tax table read for anybody the forge names. The outbox, the codes, the cards and the changes read for an admin alone, because a receipt carries a gift card's code in full, the card list is every balance in the shop, and the changes name the people who run it. Orders are neither, because /orders is neither: whoever the forge named sees the orders on their own account and nobody else's, an admin sees the shop's, and an anonymous keyboard cannot open the tab at all. desk.ordersFor(who, filter) is the one place those rows are chosen — a function from Who to rows, so the rule is tested without a pty.

Every key that does anything — moving stock, dispatching, refunding, writing a code, issuing or voiding a card, setting a rate — is an admin's, and so are the two keys that only read something out: c, which prints a balance, and h, which prints the stock ledger. The exception is /, which narrows the rows already in front of whoever pressed it: it reaches no further than they can already see, so the buyer at the keyboard may press it on their own orders. src/desk.ts holds one table for all of this, and the tab bar and the foot of the screen are built from it, so nothing can be offered without being gated.

Rate limits count the address the socket came from, never the first entry of x-forwarded-for — see What a rate limit is keyed on for why, and for the one declaration that changes it. Reading a gift card's balance gets a bucket of its own there, separate from typing a code at the till: someone reading their card over and over must still be able to spend it.

The admin sees it happen

An admin page is true at the instant it was rendered, and an order placed by a stranger a second later would leave it quietly wrong. So the ledger says what it wrote, and the page listens.

  • src/live.ts is one bus: emit(type, data) writes a live_event row, the

last hundred of which are kept, and GET /admin/events is a server-sent event stream with a retry:, a heartbeat every 25 seconds, and a replay of everything after the Last-Event-ID (or ?since=) a reconnecting browser brings with it — row ids, so they still mean something after a restart. The stream is admin-only, and it is the only route this adds. A listener is dropped when the request aborts or the stream is cancelled, so a reload leaves nothing behind, and a connection that has stopped reading is dropped once a couple of hundred frames have queued on it rather than held.

  • A stream has a life. Every other door here proves who is asking on

every request; a stream is one request that lasts, so it would otherwise be checked once and then trusted for as long as the socket stayed open — an admin the forge has demoted, or an identity that has expired, going on hearing every order and every figure in cents. So /admin/events closes the connection at the earlier of four minutes and the expiry the forge signed into x-forge-identity. The browser reconnects by itself after the retry: and is checked again on the way back in; Last-Event-ID replays the two seconds in between, so a page that is still an admin's loses nothing and one that is not is answered 403 and backs off.

  • An event is a row and not a message in memory because **the app is two

processes over one database**: the web server and the terminal. emit writes the row; whichever process is serving streams hands out the rows it has not handed out yet — its own at once, the other process's within a second, both through one path in id order. A connection also remembers the highest id it has been given, so a replay that runs ahead of that cursor — rows the terminal wrote a moment ago — is not said a second time by the poll catching up behind it: an event reaches a page exactly once, and never out of order. The poll runs only while somebody is listening. So stock moved with s in the terminal, or an order refunded with r, shows up on an open /admin in about a second, the same as if it had been done in the browser.

  • emit is called where a ledger row is written and nowhere else: an order

placed, an order paid — through the gateway, through a gift card that covered the lot, or through Stripe's webhook, which all end in one settleFromGateway — a refund, a cancellation, a dispatch, and a stock movement. Only the branch that actually moves the row to paid says order.paid: a charge that falls short of what the order owes is written to the ledger and leaves the order awaiting payment, and the admin's screen is told nothing, because a screen that said paid there would be lying about the money. Every call happens after the transaction commits. A stock movement made inside a bigger transaction (an order taking its stock) is announced by whoever commits that transaction, because a rollback would otherwise have told an admin about an order that does not exist.

  • An event says an id, an order number, a figure in cents and a SKU. It

never says an address, an email, a gift card code or a gateway reference: the page is where the private things are, and the page is behind the admin's door.

  • static/live.js turns the stream into live and live:<type> events on

document and refetches the page it is already on, replacing the regions marked data-live by their id. One refetch is in flight at a time and the next event coalesces into it, so a busy shop is not a poll. A region someone is typing in is left alone until the caret moves on.

  • A region may add data-live-key="<subject>" and then it wants only the

events that name that subject — the page about one order ignores every other order's. The key is matched against the values the event carries, so the bus stays ignorant of what any page is showing.

Live pages: /admin (the totals and the latest orders), /admin/orders, /admin/inventory (both tables — stock on hand and the ledger) and /admin/orders/<number> (one order, keyed to its number: the status, the totals, what is left to refund, the payments and the history move together as a payment settles or a refund goes back). Their tables have fixed columns, so a row arriving cannot move the rows around it. No other page loads a script, and none of them has an inline one: the floor's script-src is 'self', with no unsafe-inline and no unsafe-eval, and static/live.js is served under a name made from its own contents (/static/live.<hash>.js) with a year-long cache, the same trick the stylesheet plays. With JavaScript off, every one of those pages is the page it always was — one reload behind.

The bus is a table, so the terminal (bun run src/tty.ts, or ssh) is not a blind spot: it is a second process on the same database, its emit writes a row like any other, and the web process notices it on its next pass — about a second. Nothing in the app can move stock or money without an open /admin saying so.

Shape

FileOne concept
src/server.tsThe front door: security headers on every response, same-origin and CSRF checks, the request's context
src/routes.tsThe table of paths
src/http.ts src/context.tsThe responses a handler may return, the capped read of a request body, and what a handler is given
src/db.tsThe schema, and open(dir): a directory in, a database out
src/fresh.tsA database of a test file's own, opened before the file imports anything that queries
src/catalog.ts src/inventory.ts src/orders.tsProducts, the stock ledger, orders and refunds
src/cart.ts src/heldcart.tsA guest's cart in a signed cookie and what it comes to; the cart an account carries in cart_line, and which of the two this request has
src/codes.ts src/mine.ts src/money.ts src/sign.tsThe codes cookie, the guest orders this browser found, integer cents, HMACs
src/discounts.ts src/giftcards.ts src/tax.tsWhat a code is worth, what a card holds, what the rate is
src/who.ts src/session.tsWho the forge says is asking; the session that carries the CSRF token
src/orderlink.tsWhere an order lives for the person being handed the link: with its token, or without it
src/changes.tsWhat an admin changed to the catalog, a code or a rate, and who they were
src/desk.tsThe terminal's gate and its typed commands: who may read which tab and press which key, whose orders they see, and a line of text into a code, a card or a rate
src/peer.ts src/ratelimit.tsWhere a request came from, as far as it can be proved; the buckets keyed on it
src/gateway.ts src/mail.tsStripe or the mock, the outbox
src/shop.ts src/admin.tsHandlers for the shop and for /admin
src/pages/*.ts src/html.ts src/style.tsRendering; the stylesheet is served under a name made from its own contents
src/pages/oops.tsThe page a refusal wears: a status and a sentence in, a page of the shop with a way out of it — or the line a machine asked for
src/live.ts static/live.jsThe bus: a live_event row, and the stream that hands rows out across both processes; the one script, which refetches the data-live regions of the page it is on
src/asset.tsA file under static/, addressed by its contents: the hash in the path has to be the hash of the bytes
src/units.test.ts src/contention.test.ts src/live.test.ts src/server.test.ts src/stripe.test.ts src/proxy.test.ts src/https.test.tsThe rules on their own, the last unit and the rollback of the attempt that missed it, the bus on its own, the shop over HTTP, the Stripe path against a fake Stripe, the app booted behind a declared proxy, the app booted on an https public address

What the door refuses

Every request body is read through readBody in src/http.ts, which counts the bytes as they arrive and stops at 64 KB. A declared Content-Length is a claim and a chunked request has none, so neither is believed; past the cap the answer is 413 and the rest is drained (up to eight times the cap, then cut off) so the connection stays usable. The cap says how much; a five-second wall-clock budget on the same read says how long, because a client that sends one byte a second is inside the cap for hours. One clock covers the whole body, the drain after a refusal included: when it runs out the read is let go and the answer is 408 (413 if the body was already past the cap), with Connection: close, since the rest of that body is still on the wire and is nobody's next request. That happens in one place — src/server.ts — for every route: a form is parsed from the capped bytes, and the one route that wants its body whole, Stripe's webhook, which verifies a signature over it, is capped before it is verified and reads the result as ctx.body. A POST that is not a form is 415, one that is not same-origin or is missing the session's CSRF token is 403.

What a shopper sees when one of those happens

A refusal is a page of this shop, not a line of plain text on a white page: the header, the stylesheet, one sentence saying what happened, and a way out of it. src/pages/oops.ts renders all of them from a status and a sentence, and src/server.ts sends every "no" through it — the cross-site 403, the stale-form 403, 415, 400, 413, 408 and 500 — as does the 429 a rate limit answers with. The shopper most likely to meet one is the one whose tab sat open until the session rolled over, mid-purchase: that page links back to /cart and to /, and it carries the new session cookie, so going back and sending the form again works rather than failing the same way twice. Its header counts that cart, and for a signed-in shopper the cart is rows, so the count is read with peek in src/heldcart.ts — the lines cartFor would answer with, cookie folded in, and not one row written: a request that was refused leaves the shop exactly as it found it. The 500 page says the shop failed and nothing else — no message, no path, no stack; console.error keeps the whole exception for the log.

A machine is not dressed up. A caller whose accept header does not mention text/html gets the same line it always got (forbidden, expected a form, that body was too large), and POST /webhooks/stripe — the one route that reads its own body — answers plain text however it asks. Every one of these keeps its status, cache-control: no-store, and the security floor, because they all leave through the same finish() as every other response.

The security floor on every response is in src/server.ts: a content security policy with script-src 'self' — the one loosening this app has made, for the one module under static/ — and form-action 'self' (widened for one response only when a form hands the shopper to Stripe), nosniff, strict-origin-when-cross-origin, a permissions policy, and — only when FORGE_APP_URL is https, as it is on the forge — strict-transport-security: max-age=31536000.

Tests

bun test. bun test runs every file in one process with one module registry, so src/db.ts is evaluated once: a test file that only set FORGE_APP_DATA would still be querying whichever database the first file to import it opened. Each file therefore starts with await freshData(name) (src/fresh.ts), which makes a temporary directory and opens a database there before anything that queries is imported. No file depends on another file's rows: every file passes alone with the same count as it does in the suite, and the suite passes in any order — bun test, bun test --isolate, bun test --parallel=4 and bun test --randomize --seed N are all green.

The terminal

The same shop over the same data, in a second process. The exact command is in FORGE_APP_SSH when the app runs; the footer of every page prints it verbatim, and says the terminal is off when it is unset. What is typed here is written to the same database, and — because an event is a row in it — a stock move or a refund made here reaches an admin's open /admin over the live stream within about a second, not on that page's next load. 1–7 switch between stock, orders, the outbox, discount codes, gift cards, the tax table, the changes an admin made to any of it and the audit trail — everything /admin can see — and j/k move. A tab whose rows are the shop's own business is an admin's to open: a non-admin gets stock, tax and their own orders, the other five are marked with a · in the tab bar and answer without leaving the tab they are on:

TabKeys
stocks moves stock with a reason, h shows the last of the ledger
ordersthe orders you may see — your account's, or the shop's for an admin. / narrows them by number, name or email; d (an admin's) dispatches a paid one (CARRIER / TRACKING, either may be left out), and r (an admin's) refunds what is left of one and puts it back on the shelf. A row says whether it has been dispatched, and the open row says when and with what
codesn writes a code (CODE 20% [min 25] [uses 100] [until 2026-12-31]), t turns one on or off, c looks a code up
cardsi issues a card (25 a sorry-for-the-wait card) and prints its code once, v voids or revives one, c looks a code up
taxe sets a rate (COUNTRY / REGION / 8.75 / what to call it), d sets the rate for anywhere the table does not name, x removes one
auditreads only: the five money ledgers as one timeline, newest first, the terminal's half of /admin/audit. / narrows it to one order by its number, and the foot says what that order's rows add up to against what the order says was paid
changesreads only: the last hundred changes an admin made to the catalog, the codes or the tax table, newest first — the terminal's half of /admin/changes. A change made here writes one of these rows too, naming the account the forge put at the keyboard

q leaves. The first line says who the forge named and what they may read; FORGE_APP_USER_ROLE has to be admin for every key above except / — c and h included, since one prints a card's balance and a code's full terms and the other the stock ledger. ssh gets you to the screen, not past the door, and what is on the screen is only ever what the same person could open on the web. The parsing, the gates, the choice of rows and the refusals are all src/desk.ts, so they are tested without a pty.

↑↓ navigate ↵ open esc close

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs