Queue Manager — Product Spec (Contract)
Virtual waiting room / traffic management, CrowdHandler & Queue-it class.
Stack (fixed — do not change)
- Node.js >= 18. The runtime is zero-dependency: nothing under
server.js,lib/,snippet/orpublic/may import a package fromnode_modules— node builtins only (node:http,node:crypto,node:fs, …).dependenciesinpackage.jsonstays empty. - Vitest is the dev-only test runner (
devDependencies, the single permitted npm package).npm test→vitest run,npm run test:watch→vitest. Config invitest.config.js; suites intest/*.test.jsuse the Vitest globals (describe/it/expect/beforeAll/afterAll), so no test file imports it either. Shipping or deploying the product never installs it. - Single process:
node server.js. Port8080(envPORT). - Realtime: Server-Sent Events (SSE). No websockets.
- Persistence:
STORE=valkey+SIDESTORE=pgin production (NODE_ENV=productionrefuses anything else): the live queue in Valkey, every change journaled per room to Postgres, everything else in Postgres. Survives restart.STORE=memory(development and tests) is in-process and diskless: nothing is written to disk, and every boot starts empty. The file journal (data/) was removed in step 5. - Event ordering (QM-449, normative for the port): every room's events MUST carry a monotonic per-room sequence number so replay can detect a gap or a reordering. The Valkey store does: every row of the Postgres journal carries its room's
seq, and a rebuild detects a gap. The memory store keeps no journal. - Admin auth:
ADMIN_KEYenv (defaultadmin-dev), sent asAuthorization: Bearer <key>. With the default key the server binds127.0.0.1whenHOSTis unset and refuses to start whenHOSTis not loopback. Other admin credentials: named operator keys (qmo_…, roleowner | operator | viewer,403 insufficient_rolewhen the role is short), the read-only monitor token, and the console session cookieqm_console(below). Wrong credentials count againstADMIN_AUTH_FAIL_PER_MIN; a locked-out address gets429 too_many_auth_failuresbefore any credential is read. See OPERATIONS.md, Two keys, two blast radii, and Operators, roles and the audit trail. A query-string credential lands in access logs, inRefererheaders, and — on an inline host, where/__qm/*is rewritten before auth runs — in the CUSTOMER's access and CDN logs. So no key is ever accepted from a URL. The SSE route is the one placeEventSourcecannot send a header:POST /api/admin/sse-ticket(Bearer) returns a single-use ticket, valid 30s, spent on first use, scoped to the minting credential, redeemed asGET /api/admin/events?ticket=<t>. The shareable monitor link carries its viewer token in the URL fragment, which no browser sends to any server. - Tokens:
base64url(JSON) "." base64url(HMAC-SHA256), root secret fromSECRETenv (default random per boot; required withSIDESTORE=pg). QM-352, two forms, both always verified. Legacy (signed by default): HMAC key SECRET, nokid, accepted for any purpose whose payload checks pass. v2 (signed whenTOKEN_SIGN_V2=1): key per purpose = HKDF-SHA256(SECRET, salt empty, infoqm:visitor|qm:monitor|qm:session|qm:operator, 32 bytes), payload carrieskid= hex(HKDF-SHA256(SECRET, salt empty,qm:kid, 4 bytes)), unknown kid refused, never valid for another purpose.SECRET_PREVIOUSkeeps the previous SECRET verifying (either form) during a rotation; new tokens useSECRET. Operator verifiers follow the same rule and are never downgraded from v2. See OPERATIONS.md, Signing keys and rotating SECRET. - Visitor token payload (purpose
visitor):{r, n, g, iat}for a place in line, or{r, q, g, iat}for a pre-queue entry — exactly one ofn/q, never both; v2 addskid.rroom id;nticket number (integer >= 0,0= bypass pass holding no slot);qpre-queue handle (integer >= 1, not a position);groom generation (integer >= 1; a deleted and re-created room gets a new one, so an old ticket cannot claim a place in the new line);iatissued-at epoch ms. Refused pastTOKEN_MAX_AGE_SEC(default 86400, minimum 60) afteriat, or withiatmore than 60 s in the future. The token carries no waiting/passed status: that lives in the engine, keyed byr+n. Other purposes (monitor,session,operator) have their own payloads and are never accepted as a visitor token under v2.
File ownership (builders stay in their lane)
| Path | Owner |
|---|---|
server.js, lib/engine.js, lib/roomstore/, lib/token.js | engine builder |
public/waiting.html (+ inline CSS/JS) | visitor-UI builder |
public/admin.html (+ inline CSS/JS) | dashboard builder |
snippet/qm.js, INTEGRATION.md | integration builder |
test/*.test.js | each builder adds own |
PROGRESS.html, SPEC.md | orchestrator only |
Core concepts
- Room (waiting room): id, name, target URL,
ratePerMinute(outflow),maxConcurrent(optional), configured state:active | paused | bypass(bypass = queue off, all pass). A room whoseopensAtis still in the future reports the effective statescheduledon the visitor wire while its configured state remains one of the three; admin listings carry both, plusopensAtand apreQueuecount. - Visitor token: signed; holds room id, ticket number or pre-queue handle, room generation and issued-at (payload above). Whether that ticket is waiting or has passed is engine state, not token state.
- Ownership cookie
qms_<roomId>: an opaque random session id (16–128 chars), set HttpOnly beside the token on/api/joinand bound to the ticket engine-side (only its hash is stored). A token presented without the browser that queued for it does not carry the place; a join without the cookie mints a fresh one. See OPERATIONS.md, How a pass is bound to a visitor. - Queue cookie scope (QM-504, QM-526, normative): the per-room cookies
qm_<roomId>,qms_<roomId>andqm_dk_<roomId>stayPath=/. Path is not narrowed: connectors on the customer origin read them onPath=/, and narrowing would leave duplicate cookies beside the oldPath=/ones. Instead the queue host keeps at most 8 rooms' cookies per browser (ROOM_COOKIE_CAP); setting a room's cookies when more are present expires the oldest rooms' cookies in the same response. Accepted residual risk (QM-539): a page that walks a visitor through more than 8 rooms can evict that visitor's older rooms' cookies. - Generation
g: bumped when a room is deleted and re-created. A token from an earlier generation is not honoured by/api/join(the caller gets a new place),/api/notifyanswers409 stale_ticket, and/api/statusand the event stream answer{expired:true, reason:"room_reset"}. - Fairness: strict FIFO by ticket number. Re-visits keep position (token in cookie/localStorage, plus the ownership cookie). Lost token = back of line.
- Token refresh (QM-391): a wait may outlast the token (
TOKEN_MAX_AGE_SEC) and the queue cookies (QUEUE_COOKIE_MAX_AGE_SEC) — above all a pre-queue for anopensAtdays away. Once a presented visitor token is at least half the shorter of the two old,/api/statusand the event stream re-sign the same place ({r, n|q, g}with a newiat, signed with the keyring in force, so v2 andkidfollowTOKEN_SIGN_V2) and re-set bothqm_<roomId>andqms_<roomId>(same session id), only when all hold: the room exists andgis its generation; the place is still waiting (status neitherexpirednorpassed; a pre-queue handle counts); and the request'sqms_<roomId>proves the place engine-side. A fingerprint match never earns one./api/statusreturns the new token astoken; a stream opened with a due token carries it astokenin every frame and sets the cookies on its response, and an open stream whose token falls due is ended so the browser reconnects and refreshes. The waiting page stores atokenonly when it names the place it already holds. A token already pastTOKEN_MAX_AGE_SECis refused as before: the refresh cannot resurrect one. - Outflow: engine promotes
ratePerMinutewaiting→passed per minute, smooth (per-second slices, not bursts). A passed ticket is validpassedTtlSec(default 600) to enter the target site. - Scheduled drop: a room may carry
opensAt. Before it there is no line at all — joiners are held in a pre-queue and issued an opaque handle, not a ticket number. AtopensAtthe pre-queue is drawn into FIFO order using a crypto-grade random permutation, round-robin across identities so a second entry from one identity never outranks any other identity's first. The permutation is recorded, so a restart across the open instant replays it instead of drawing again.preQueueMaxPerIp(default 16,null= unlimited, 1..100000) caps entries per identity; a join over it is429 prequeue_identity_limitwith no token, handle or cookie returned, and a pre-queue at its hard ceiling is503 prequeue_full. Invariant: arrival order within the pre-queue must not correlate with drawn position — arriving early buys nothing. - Ghost tickets (QM-345): a pass reserves a
maxConcurrentslot only for a holder seen (join,/api/status, or the/eventsheartbeat) withinpresenceSec(default 30,null= rule off). An absent holder's ticket is still promoted in FIFO order, but its pass owns no slot (a pass past its claim window: usable at the door withinpassedTtlSec), and on the clock it costs no outflow; a pass whose holder was never told they are through gives its slot back afterpresenceSecunseen. Thepasslog event lists those tickets innsso replay restores them slotless.queueMaxPerIp(default 16,null= unlimited, 1..100000) caps waiting-line places per client address; a new join over it is429 queue_identity_limitwith nothing spendable returned. Tickets with no recorded holder (engine-internal callers) are exempt from both.
HTTP API (contract — UI builders code against this)
Versioning policy
Every route below is served at /api/v1/<route> (canonical, pinned) and at the unversioned /api/<route> (permanent alias, same handler, byte-identical response). The snippet is deployed on third-party origins that cannot be upgraded in lockstep with this server, so it asks for /api/v1/* and falls back to /api/* once per page if the server 404s the prefix.
- Breaking changes ship as
/api/v2/*; v1 keeps its shapes. Additive optional fields stay in v1. - The unversioned aliases are not deprecated and are never removed; they track the newest contract, which is why pinned integrations must not use them.
- Unversioned surface (not part of the contract):
/healthz,/w/:roomId,/snippet/*,/,/progress,/metrics,/events(the permanent bare alias of the contract route/api/events— see Visitor below; listed here so a build generating its path set from this section does not drop it),/docs(+/docs/:name, which rendersINTEGRATION.md,OPERATIONS.mdand this file for readers who only ever see the running server),/docs/th(+/docs/th/:name, the same guides in Thai fromdocs/th/<file>). - Wrong verb on a contract path →
405+Allowheader +{"code":"method_not_allowed"}. OPTIONSpreflight runs the same CORS decision as the real method: it must never advertise an origin the actual response would refuse.
Visitor
POST /api/join {roomId, token?}→{token, position, ahead, etaSec, state}(sets cookieqm_<roomId>with the token and the HttpOnly ownership cookieqms_<roomId>). A same-generation token in the body or cookie keeps its place (a pre-queue handle is exchanged for its drawn ticket once the doors open). Refusals:429 rate_limited(JOIN_LIMIT_PER_MIN),400 invalid_room_id/invalid_token_field,404 room_not_found,403 blocked_by_protection(room protection in enforce mode),429 queue_identity_limit,429 prequeue_identity_limit,503 prequeue_full,503 storage_unavailable(the join could not be flushed to disk, so no ticket is handed out).GET /api/status?token=→{position, ahead, etaSec, state, passed, redirectUrl?}. Token from?token=(not on an inline host) or theqm_<roomId>cookie; none valid →401 invalid_token.GET /api/events?token=→ SSE: eventstatuswith same JSON as /api/status, pushed on change + every 5s heartbeat. Versioned like every other contract route (/api/v1/events), and also served at the bare/events, which the shipped waiting page asks for. That alias is permanent, not legacy: every waiting page currently open in a browser holds anEventSourceon it, and those reconnect by themselves after a restart or a deploy — removing the path would break the visitors who are in the queue at that moment, which is the one population this product exists for. Stream limits:503 sse_capacityatSSE_MAX_TOTALstreams,429 sse_per_ip_limitatSSE_PER_IPper address, both withRetry-After: 5.- Refresh frame (QM-469). When the stream's token is due to be re-signed (
TOKEN_REFRESH_AFTER_MSafter itsiat: half the shorter ofTOKEN_MAX_AGE_SECandQUEUE_COOKIE_MAX_AGE_SEC) and the refresh would be granted, the server sendsevent: refreshwith data{}and ends the stream. A stream cannot set cookies once its headers are sent. The page reopens the stream itself, once, and the new stream's response re-signs the place and re-sets both cookies. Itsstatusframes then carry the freshtoken. The end is delayed past the due time by a jitter fixed per place:uint32_be(sha256("r|n|q|g|iat")[0..4]) % (floor(TOKEN_REFRESH_AFTER_MS / 10) + 1)ms, over the token payload's fields (a field the token does not carry, such asqon a ticket, is the literal textundefined, as JavaScript renders it), so at most +10% of the window, and a burst of joins with oneiatdoes not reconnect together. A port must send the same frame and use the same jitter. A stream whose due refresh would not be granted is never ended for it. GET /w/:roomId→ serves waiting.html (injects roomId + branding JSON aswindow.QM_CONFIG). Aqm_returnthat the room's return policy refuses is stripped, not honoured.- The page's style and main script are linked, not inline (QM-467):
GET /assets/waiting-<hash>.cssand/assets/waiting-<hash>.js, also under/__qm/assets/…on an inline host.<hash>is the first 22 characters of the base64url SHA-256 of the file. They are served withCache-Control: public, max-age=31536000, immutable. A process serves only its own build's hashes, so with more than one instance, or during a rolling deploy, every instance must serve every hash that any instance is still handing out. See OPERATIONS.md, Waiting-page assets and live streams across a deploy. - A visitor arriving with a token that is no longer a place gets
lapsedin that config —"expired"when their own entry window ran out,"reset"when it was our doing (eject, purge, room reset), absent when nothing was lost. Both deployments derive it from the engine's own word for the refusal, and the page has the sentence for each in every locale; without it the visitor is told their ticket "could not be verified", which names a fault that did not happen. A token known to have lapsed is also treated as no token, so the replacement place the page promises is actually minted — including for a visitor with no JavaScript, who has no second way to recover. POST /api/notify {token?, email?}→ registers (or, with an empty/omittedemail, withdraws) the visitor's turn notification. Ticket comes from the body token or the queue cookie, never the URL.- A turn notification is filed against the ticket, so wherever the server replaces a ticket it carries the record over — the pre-queue draw, and a rejoin presenting a ticket the engine will not honour. The client sends the dead token on that rejoin for exactly this reason. A purge or an eject deletes it instead: the operator discarded that place in line.
POST /api/jointherefore also answersnotify—{emailMasked, requestedAt, delivery}ornull— stating what is held against the place it just named. Absent entirely whenEMAIL_NOTIFYis off; the page reads absence as "no statement" andnullas "nothing stored", and stops claiming a save the server does not have.- Before
opensAt,/api/joinand/api/statusanswer{state:"scheduled", scheduled:true, preQueued:true, preCount, opensAt, now}withpositionandaheadnull, and the token carries a pre-queue handleqinstead of a ticketn. - Every visitor error is
{error, code}; branch oncode. The full visitor-facing list is INTEGRATION.md, Error codes. - Any frame carrying a server instant carries
now(server epoch ms) beside it —opensAton a scheduled drop,messageAtwhen an operator message is set. Server instants are the only times on the visitor's wire that are not device-relative, and the page renders every clock time it shows in the DEVICE's frame (instant - (now - clientNow)), because that is the clock the visitor compares it against. Withoutnowa skewed device reads a skew-corrected countdown beside an uncorrected "doors open at", and the two disagree. A frame that names no server instant needs nonow.
Edge check (used by snippet)
GET /api/check?roomId=&token=&url=→{action: "pass"|"queue", reason?, waitingRoomUrl?, clearToken?, sess?, targeting?, …}— pass if room bypass/inactive or the token's ticket has passed and is unexpired. An unknown room answers200 {action:"pass", reason:"no_room"}(fail open), counted for the operator in/api/admin/healthand/metrics. Apassthat admits somebody is answered only once the admission is on disk;503 storage_unavailableif it could not be written, and the admission is taken back so a retry is admitted.- Door-session key
sess: returned on thepassthat opens (or reclaims) a door session, and replayed on every later check as theX-QM-Sessionheader (what the snippet sends: a header keeps the credential out of URLs and logs),?sess=, or on an inline host the HttpOnlyqm_dk_<roomId>cookie.clearToken: truemeans drop the stored token and key before redirecting. Because the body can carrysess, on a room with an origin policy/api/check(and itsOPTIONS) sendsAccess-Control-Allow-Originonly to a browser origin in the room's tag origins (tagOrigins, elsereturnOrigins, plus thetargetUrlorigin). See INTEGRATION.md, The door-session key. ?ip=&agent=vouches for the real client (server-side and edge connectors) and needsAuthorization: Bearer <PUBLIC_API_KEY>(orADMIN_KEY); without it401 unauthorized, or401 credential_in_querywhen the key was sent as?key=. A vouched check is rate-limited per vouched address, not per calling colo.url=is the page being visited. It decides scope: whether this page belongs to the room at all. Absent, theRefererheader is used; absent both, the check isunknownand fails open into the queue (never silently unprotected).?targeting=1adds the room's full rule list to the response.- A page the room's rules exclude answers
{action:"pass", reason:"out_of_scope"}without minting a place in line. targetingis present only on scoped rooms:{rev, rules, scope:"in"|"out"|"unknown", rule, action}. A room with no rules omits the key entirely and behaves exactly as it did before URL targeting existed.
Targeting (public, used by edge connectors)
GET /api/targeting?roomId=→{roomId, rev, scoped, updatedAt, patterns:[{pattern, action}]}— the room's URL scope, so a Worker/Lambda/nginx connector pulls the rules at runtime instead of hard-coding them.ETag+304so it is cheap to poll.
Install checks and connector registry (public)
GET /api/verify?roomId=&url=&origin=&connector=&version=→{ok, checks:[{id, status:"pass"|"fail"|"warn"|"info", title, detail?, fix?}], …}— the pre-deploy check every connector's CLI or settings screen runs.originfalls back to the origin ofurl, then the request'sOriginheader. ABearerPublic API key makes the report privileged (it also checks the key); a key in?key=is reported as a failing check.400 invalid_room_idwithoutroomId. Checks are recorded for the console's "connectors that checked in" list. See INTEGRATION.md, Installable connectors.GET /api/verify.gif?roomId=&origin=&connector=→ the same verdict as a status code, for the GTM sandbox, which can only see whether an image loaded:200 image/gifwhenok, else424 {ok:false, error:"verify_failed", checks}.X-QM-Verifynames the failing check ids;Cache-Control: no-store.GET /api/connectors→ the registry: each connector's version, install/upgrade/verify commands, download URL and SHA-256 integrity, as served by this server.GET /api/connectors/wordpress/update→ the same for WordPress's plugin-updater shape. The artifacts themselves are under/connectors/<name>/latest.{js,tgz,zip}(unversioned)./api/verify,/api/verify.gifand/api/connectorsshare theCHECK_LIMIT_PER_MINbudget (429 rate_limited).
Health (public, unauthenticated)
GET /healthz,GET /api/health→ readiness payload;503when the instance is notready(for example it cannot persist a join),200otherwise.GET /api/version→ the same payload, always200. Exempt from connection-ceiling shedding. See OPERATIONS.md, Readiness.
Admin (all under /api/admin/*; Bearer, or the console session cookie)
POST /api/admin/session {key}with headerX-QM-CSRF: 1→ signs the console in: setsqm_console(HttpOnly,SameSite=Strict,Path=/api/admin, 12 h), a signed session reference that never contains the key; answers{ok, name, role, expiresInSec}.DELETE /api/admin/session(same header) clears it and revokes the session server-side. A request carrying the cookie and noAuthorizationheader must sendX-QM-CSRFon every non-GET, or403 csrf_required; a request withAuthorizationis judged on that alone. See OPERATIONS.md, Two keys, two blast radii.- Grants:
viewerand the monitor token read (rooms, events, metrics, health, security, reports, alerts status, audit; the monitor token is refused audit, visitors and targeting);operatoralso changes queue state and room settings;owner/ADMIN_KEYalso manage operators, delete rooms and shut down. GET /api/admin/operators,POST /api/admin/operators {name, role}→ key returned once;DELETE /api/admin/operators/:idrevokes. No route changes an existing operator's role.GET /api/admin/audit→ who did what, when and from where.GET /api/admin/security→ bot-protection scoring and refusals.GET /api/admin/reports?roomId=&bucket=hour|day→ historical rows (CSV available for BI).GET /api/admin/alerts→ alert state;POST /api/admin/alertsfires a test page (400 alerts_disabledwithoutALERT_WEBHOOK_URL,502 webhook_failedif the webhook refuses).POST /api/admin/shutdown→{ok, stopping, uptimeSec}then a clean stop (the documented way to stop it on Windows).owneronly.POST /api/admin/sse-ticket→ single-use 30 s ticket forGET /api/admin/events?ticket=.GET /api/admin/rooms→ list with live stats{waiting, passedLastMin, rate, state, oldestWaitSec}estWaitSecis the wait for a visitor joining now, and it is only ever a number the room can back. A room admitting nobody — paused,ratePerMinute: 0, or measured outflow of zero — reportsnull(unbounded), whether or not anyone is queued. A room whose doors are still shut reports the time left on the clock plus the drain of anyone already holding a ticket, the same sum/api/statusquotes a waiting visitor, withestWaitFloor: true: the draw decides how much longer than that a pre-queue entry waits, so nothing is added for it.0means the room would admit immediately.POST /api/admin/roomscreate/update room. The writable set is exactly:{id, name, targetUrl, proxyOrigin?, ratePerMinute, maxConcurrent?, passedTtlSec?, sessionTtlSec?, sessionMaxSec?, branding?, returnOrigins?, tagOrigins?, autotune?, protection?, autoActivate?, urlPatterns?, opensAt?, preQueueMaxPerIp?, queueMaxPerIp?, presenceSec?, state?, onBackendDown?}- The nine beyond the obvious ones, because each is a whole feature reachable only through this body:
proxyOriginis inline mode (the internal address this server forwards an admitted visitor to);sessionTtlSec/sessionMaxSecbound a door session;autotuneis the automatic rate controller;protectionis the bot-scoring mode ({mode:"off"|"monitor"|"enforce"});autoActivateis threshold activation;opensAtandpreQueueMaxPerIpare the scheduled drop and its per-identity pre-queue cap;queueMaxPerIpandpresenceSecare the live line's per-address ceiling and presence window (see Core concepts, Ghost tickets);statesets active/paused/bypass in the same write as everything else. onBackendDown("closed", the default, or"open") is what an inline room's gate does while the backend is down (Valkey unreachable, or the room being rebuilt from the journal):closedanswers a browser navigation with the waiting page in retry mode (503+Retry-After, no place taken) and any other request with503 storage_unavailable;openforwards toproxyOriginunqueued. Anything else is400 invalid_on_backend_down. Stored but without effect onSTORE=memory, which has no backend to lose.redacted: trueis refused with400 redacted_shaperather than treated as an unknown field. A room read through a shared monitor link comes back with its internal address, origin allowlists and URL rules blanked, so posting that body straight back would wipe all of them; the error says so instead of naming a field.rateis accepted as an alias forratePerMinute— it is whatGET /api/admin/roomscalls the same field, so a read-edit-write round trip does not have to rename it. Sending both is fine if they agree; sending both with different values is400 conflicting_field. Any field name outside the documented set is400 unknown_field, not a silent no-op — a typo here used to look exactly like a successful edit that did nothing.returnOriginsis where a promoted visitor may be sent;tagOriginsis which pages may read a door check (the browser CORS allowlist). The room'stargetUrlorigin is always on both.tagOrigins: null(the default) inheritsreturnOrigins, so a room configured before the split is unchanged. They are separate because they fail in opposite directions: a wrong return policy is an open redirect, while a missing tag origin serves that page completely unqueued in silence.urlPatternsis the room's scope, as runtime config: at most 25 rules, each at most 300 characters and at most 10*wildcards. A rule starts with/(path),*(anywhere) or a fullhttps://host/path(pins the origin); a leading!makes it an exception. Matching is case-insensitive; a rule with no?is matched against the path only. Exceptions always win, whatever order they are listed in — order-dependent rules are an operator footgun, not a feature.[]means unscoped: every page carrying the tag is queued.branding.messageis the operator's message to queued visitors; the server stampsbranding.messageAtwhen the text changes, and the waiting page shows it in a timestamped pane in every state, including a scheduled drop's countdown.DELETE /api/admin/rooms/:idPOST /api/admin/rooms/:id/state {state}(active/paused/bypass)POST /api/admin/rooms/:id/rate {ratePerMinute}(rateaccepted as an alias, same rule as above) — takes effect immediatelyPOST /api/admin/rooms/:id/flush— promote N now{count}POST /api/admin/rooms/:id/schedule {opensAt?, preQueueMaxPerIp?}— set/clear the doors-open instant. Each field is applied only if present: sendingpreQueueMaxPerIpalone adjusts the cap and leaves the drop scheduled. Cancelling a drop takes an explicit{"opensAt": null}. A body with neither field is400 nothing_to_change, not a silent no-op.POST /api/admin/rooms/:id/eject {ticket}— revoke the pass, drop any live session, back of the linePOST /api/admin/rooms/:id/purge— discard the whole waiting backlog without admitting anybody. For a room that has outlived its event: durability keeps its line across every restart, and deleting the room used to be the only way out. No passes are minted and no outflow is recorded, so the chart gains no spike that never happened; purged tickets read asexpired, and the room, its settings and its history are kept. A scheduled room's pre-queue is emptied too. Answers{purged, stats}once the purge is on disk (503 storage_unavailableif it could not be written);purgedcounts pre-queue entries, which are also given aspreQueue.GET /api/admin/rooms/:id/visitors?ticket=or?ref=→ per-visitor lookup for support (state, position, TTLs, session, binding — one-way digests only, never raw identifiers).ref=resolves the short code the waiting page shows the visitor, so the string they read down the phone is the string that goes in the box; an ambiguous code returns the candidate tickets rather than guessing.GET /api/admin/rooms/:id/targeting?url=→ the room's rules with{matchCount, lastMatchedAt}per rule, plus counts of checks that matched nothing and checks that arrived with no URL. Optionalurl=is a dry run: which rule would win, without touching the counters. A rule that has never fired is the visible symptom of a room pointed at the wrong pages.GET /api/admin/events→ SSE stats every 2s for all rooms{rooms:[...], totals, sparkline data}- Delta frames (QM-468). A full frame (no
deltakey) is sent on connect and again on the next tick. After that each tick is{delta:true, rooms:[changed rooms], removed:[ids], totals, …}: only the rooms whose stats changed since the previous tick, plus the ids of rooms that are gone, with the same other keys as a full frame. Changed rooms replace theirs, new ones are appended (server order is creation order), and removed ones are dropped. A tick where rooms changed order is sent as a full frame (QM-480). A client that receives a delta with no full frame under it must reconnect, which is the resync. GET /api/admin/metrics?roomId=&range=→ time series (joins/min, passes/min, queue depth) for chartsGET /api/admin/health→ effective config, storage state, misconfiguration warningsPOST /api/admin/monitor-token→ mints the read-only monitor credential (observe only; neverADMIN_KEY).GETlists live share links (a read, but not open to a monitor token itself);DELETE ?id=revokes one (?all=1every one).GET /metrics→ Prometheus exposition, a read credential required (monitor token,vieweror above; room ids and depths are commercially sensitive)GET /→ admin.html.GET /progress→ PROGRESS.html.GET /docs→ the guides.
Quality bar (what critics judge)
- Waiting page: calm, branded, live position + honest ETA, progress bar, accessible (reduced-motion, aria-live), works on mobile, survives refresh/tab-close, auto-redirects on pass, no layout jank, offline/reconnect handling.
- Dashboard: live queue depth graphs, per-room controls that apply instantly, surge visible at a glance, keyboard accessible, dark UI acceptable, no fake data.
- Engine: 10k joins burst must not crash or reorder; FIFO invariant; rate change mid-surge honored; restart mid-surge loses nothing (
STORE=valkey). - Everything: no console errors, no dead buttons, no lorem ipsum.