mason/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
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.
main
View all branches
- src
- static
- .gitignore 49 B · ~14 tok
- AGENTS.md 3.0 KB · ~763 tok
- forge.json 151 B · ~51 tok
- package.json 143 B · ~48 tok
- README.md 47.1 KB · ~12k tok
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 /checkoutfor 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 /checkoutwithout 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_idandbuyer_namefrom 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 asking | Where the cart is | How long it is kept |
|---|---|---|
| A guest | A signed cart cookie, ids and quantities only, at most 20 lines | 30 days, or until the browser drops it |
| An account | Rows in cart_line, keyed on (buyer_id, variant_id) — the same forge id that owns their orders | Each 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
| Environment | What 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. |
| unset | The mock gateway, clearly labelled. |
STRIPE_API_BASE | Where 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 withaddColumn, 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_eventof kinddispatched, 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
| Variable | Meaning |
|---|---|
FORGE_APP_IDENTITY_KEY | The 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_HOPS | How 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_SECRET | Signs cart cookies, sessions, CSRF tokens and order links. Generated into FORGE_APP_DATA/secret when unset. |
STRIPE_SECRET_KEY | A Stripe test key turns on real Stripe Checkout. |
STRIPE_WEBHOOK_SECRET | Verifies POST /webhooks/stripe. |
STRIPE_API_BASE | Where 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.
| Who | What |
|---|---|
| anon | Reads the shop: catalog, search, product pages, the cart with everything it does, and an order with the order's own token. Cannot buy. |
| user | Buys: 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. |
| admin | Runs 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
- So
tois 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.tsis one bus:emit(type, data)writes alive_eventrow, 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.
emitis 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.jsturns the stream intoliveandlive:<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
| File | One concept |
|---|---|
src/server.ts | The front door: security headers on every response, same-origin and CSRF checks, the request's context |
src/routes.ts | The table of paths |
src/http.ts src/context.ts | The responses a handler may return, the capped read of a request body, and what a handler is given |
src/db.ts | The schema, and open(dir): a directory in, a database out |
src/fresh.ts | A database of a test file's own, opened before the file imports anything that queries |
src/catalog.ts src/inventory.ts src/orders.ts | Products, the stock ledger, orders and refunds |
src/cart.ts src/heldcart.ts | A 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.ts | The codes cookie, the guest orders this browser found, integer cents, HMACs |
src/discounts.ts src/giftcards.ts src/tax.ts | What a code is worth, what a card holds, what the rate is |
src/who.ts src/session.ts | Who the forge says is asking; the session that carries the CSRF token |
src/orderlink.ts | Where an order lives for the person being handed the link: with its token, or without it |
src/changes.ts | What an admin changed to the catalog, a code or a rate, and who they were |
src/desk.ts | The 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.ts | Where a request came from, as far as it can be proved; the buckets keyed on it |
src/gateway.ts src/mail.ts | Stripe or the mock, the outbox |
src/shop.ts src/admin.ts | Handlers for the shop and for /admin |
src/pages/*.ts src/html.ts src/style.ts | Rendering; the stylesheet is served under a name made from its own contents |
src/pages/oops.ts | The 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.js | The 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.ts | A 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.ts | The 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:
| Tab | Keys |
|---|---|
| stock | s moves stock with a reason, h shows the last of the ledger |
| orders | the 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 |
| codes | n writes a code (CODE 20% [min 25] [uses 100] [until 2026-12-31]), t turns one on or off, c looks a code up |
| cards | i 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 |
| tax | e 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 |
| audit | reads 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 |
| changes | reads 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.