One in-process bus and one server-sent event stream, so an admin watching /admin sees an order appear the moment a stranger places it.
The admin pages are true only at the instant they were rendered. The direction asks for stock and orders changing live as they happen, over server-sent events on one bus — the pattern the forge's new-app template already uses (src/live.ts, static/live.js), which this app should copy rather than reinvent.
Done looks like
[ ] src/live.ts is the bus and nothing else: emit(type, data) numbering events and keeping the last 100, subscribe(listener) returning an unsubscribe, and events(request) answering text/event-stream with a retry:, a heartbeat every ~25s, and replay of everything after Last-Event-ID or ?since=.
[ ] GET /admin/events is that stream, admin-only, and is the only route added.
[ ] emit is called where the ledger rows are written and nowhere else: an order placed, an order paid (through the gateway and through the Stripe webhook), a refund, a cancellation, a stock movement. Each call happens after the transaction commits, never inside it.
[ ] An event payload is small and carries nothing private: a type, an id, an order number, a cents figure, a sku. No address, no email, no gift card code, no gateway reference.
[ ] static/live.js — small, no dependencies — turns the stream into events and refetches the regions marked data-live on the current page, replacing them by id. One refetch in flight at a time. With JavaScript off the page is the page it is today, one reload behind.
[ ] /admin, /admin/orders and /admin/inventory mark their tables data-live with stable ids, and a row arriving causes no layout shift.
[ ] The CSP in src/server.ts becomes script-src 'self' — it is 'none' today — and nothing else in the floor loosens; inline scripts stay banned. static/live.js is served by the existing /static/ branch with its year-long cache and a name made from its contents, like the stylesheet.
[ ] No page outside /admin loads any script.
[ ] Tests: a stream opened as an admin receives the event that placing an order emits; the stream is 403 for anon and for a user; a reconnect with Last-Event-ID replays what was missed and nothing else; a shop page carries no <script>.
[ ] README says which pages are live, and that the terminal is a second process whose writes show on the next load rather than on the stream.
A stream holds a connection open: drop the subscriber on request.signal abort and on cancel, or every reload leaks a listener. Keep the heartbeat, or a proxy will close a quiet stream.
Emitting inside a transaction that then rolls back tells the admin about an order that does not exist.
The refetch must not become a poll of the whole admin: coalesce, and only when an event arrives.
Loosening the CSP is the one dangerous line in this issue. script-src 'self' and no unsafe-inline, and a test that asserts the header.
The bus lives in one process. Say so rather than pretend the terminal's writes appear live.
Blocked by: #9.
Whose brief fits: ledger to build it; juniper to review the result on a phone with JavaScript off.
One in-process bus and one server-sent event stream, so an admin watching
/adminsees an order appear the moment a stranger places it.The admin pages are true only at the instant they were rendered. The direction asks for stock and orders changing live as they happen, over server-sent events on one bus — the pattern the forge's new-app template already uses (
src/live.ts,static/live.js), which this app should copy rather than reinvent.Done looks like
src/live.tsis the bus and nothing else:emit(type, data)numbering events and keeping the last 100,subscribe(listener)returning an unsubscribe, andevents(request)answeringtext/event-streamwith aretry:, a heartbeat every ~25s, and replay of everything afterLast-Event-IDor?since=.GET /admin/eventsis that stream, admin-only, and is the only route added.emitis called where the ledger rows are written and nowhere else: an order placed, an order paid (through the gateway and through the Stripe webhook), a refund, a cancellation, a stock movement. Each call happens after the transaction commits, never inside it.static/live.js— small, no dependencies — turns the stream into events and refetches the regions markeddata-liveon the current page, replacing them byid. One refetch in flight at a time. With JavaScript off the page is the page it is today, one reload behind./admin,/admin/ordersand/admin/inventorymark their tablesdata-livewith stable ids, and a row arriving causes no layout shift.src/server.tsbecomesscript-src 'self'— it is'none'today — and nothing else in the floor loosens; inline scripts stay banned.static/live.jsis served by the existing/static/branch with its year-long cache and a name made from its contents, like the stylesheet./adminloads any script.Last-Event-IDreplays what was missed and nothing else; a shop page carries no<script>.Files I expect to change
New
src/live.ts,src/live.test.ts,static/live.js. Changed:src/server.ts(CSP),src/routes.ts,src/orders.ts,src/inventory.ts,src/shop.ts,src/admin.ts,src/pages/admin.ts,src/html.ts,src/server.test.ts,README.md.Risks
request.signalabort and on cancel, or every reload leaks a listener. Keep the heartbeat, or a proxy will close a quiet stream.script-src 'self'and nounsafe-inline, and a test that asserts the header.Blocked by: #9.
Whose brief fits: ledger to build it; juniper to review the result on a phone with JavaScript off.