The server and the API
The server and the API
server/ holds the use cases and the /api/v1 HTTP API in front of them. The API and the jobs loop
call the same use cases, so a rule is written once. ADR 0012 has the
decisions; this page is how the code is laid out and how to add to it. The user-facing description
of the API is HTTP API.
Layout
server/
kernel/ Result and error kinds, Actor and resolveActor (`actorOf` binds it to the session rows), actingGuest, the unit of work
http/ the Hono app (app.ts), middleware, problem details, OpenAPI document and its reference page (docs.ts)
feed/ the snapshot and the SSE change feed (routes.ts), the hub of open connections, projection of a change to a viewer
composition.ts builds each module's use cases from the repositories and adapters
modules/<module>/
module.ts the only file anything outside the module imports
ports.ts its dependencies: a Pick of the repository container, plus adapters
application/ use cases and queries, gathered in use-cases.ts
http/ route declarations and handlers
The modules are sessions (sessions, RSVPs, room unavailability, attendee counts), proposals
(proposals and votes), comments, meetings, people (profiles and the admin guest list),
notifications, events (events and days), venue (locations), settings (site settings) and
updates (the daily GitHub look-up of the latest release, shown to the admin) and stats (the
read-only admin statistics, ADR 0019: one StatsRepository.getEventFacts
read, tables computed in the use case, CSV and ZIP from the routes).
server/main.ts runs the Hono app in server/http/web.ts on @hono/node-server, in its own Node
process, and starts the mailer and the jobs loop. scripts/build-server.ts bundles it with esbuild
into dist/server/main.mjs. The app gates /api/v1 (site password; a guest cookie for
/api/v1/admin/, whose use cases check the site-admin role), serves /media, the public files, the
manifest and the SPA, and adds the security headers. Repositories stay in db/.
db/container.ts opens the database in WAL mode with two connections. Repository methods named
find… (but not findOrCreate…), list…, search…, count… or get… run on a read-only
connection, which sees only committed rows. Every other method takes the in-process write lock and
runs on the write connection, so name a method that writes accordingly
(ADR 0013).
A use case writes in deps.uow.run(async (tx) => …) (server/kernel/unit-of-work.ts): one
transaction, which a throw or a failure Result rolls back. Inside it, read and write through tx,
never through deps.repos: their writes wait for the lock tx holds, and their reads do not see
its rows. tx.record(change) logs a change with the state, addressed to the audience audienceOf
(packages/domain/src/change.ts) gives (add a case for each new type; none is for changes logged
only for reactions, which no feed viewer sees) and marked with the request’s Idempotency-Key, if
any; after commit, each change goes to the subscribeToChanges subscribers in seq order, the jobs
loop among them. Keep I/O to other systems, such as mail, out of the callback. uow.read runs
several reads on one consistent view.
Reading for pages
Site pages do not call the repositories. pageReads() in composition.ts gives plain reads (no
actor: the pages sit behind the site password only). Each is a function in a module’s
application/page-reads.ts taking a Pick of the repositories. make arch enforces it, with the
auth flows allow-listed.
Choosing a name
PUT /api/v1/me/identity makes the browser act as an open guest name; GET answers who the browser
acts as now (null for a protected name not yet logged in, the SPA’s viewer); PUT sets the guest
cookie, which the route adapter builds from the use case’s Identity
(people/application/identity.ts). A protected name answers 403 guest.protected: the SPA sends
the user to the sign-in. POST /auth/site takes the shared site password (open to visitors without
the cookie; site.passwordWrong, and auth.tooManyAttempts per address) and sets the site cookie;
DELETE /auth/site clears it and ends the login session (not rate limited).
Signing in
POST /api/v1/auth/login-code emails a code, POST /auth/login takes the password or that code and
sets a verified session cookie, POST /auth/sign-out ends it (people/application/sign-in.ts).
/auth/* has its own per-address rate limit on top of the per-guest password limit and the daily
email cap, and a limit answers 429 (kind limited). Problem codes: auth.credentialWrong (wrong,
spent or expired password or code, indistinguishable on purpose), auth.tooManyAttempts,
auth.codeRecentlySent, auth.codeSpent, auth.tooManyEmails, auth.mailUnavailable,
auth.mailFailed. A limited refusal carries Retry-After. The web gate refuses a cross-site
/api/v1/auth/* request like the admin API’s (403 request.crossSite). The per-address limit
applies only with SB_TRUSTED_PROXIES set; its burst is SB_AUTH_RATE_BURST (default 30), and
sign-out is not limited. The SPA’s sign-in screens call these routes.
Credentials (people/application/credentials.ts): POST /auth/reset-link emails a reset link
(auth.linkRecentlySent), POST /auth/reset sets the password and protects the name without
signing anyone in, PUT /me/credentials/password, POST /me/credentials/unprotect and
PUT /me/credentials/username need the acting session and, where protected, its verified level.
/auth/login also takes an identifier (email or lowercase username) for guestId; an unknown one
answers auth.credentialWrong. GET /me/login-sessions lists the guest’s sessions and
DELETE /me/login-sessions/{id} ends one (loginSession.notFound). Also username.taken,
auth.notProtected.
Site admins (ADR 0017 point 7, 8): guests.site_admin;
actor.admin is also true for a verified session of one (SessionLookup.siteAdmin), and the role
makes the guest read as protected (isProtected in the guests repository).
PUT /admin/guests/{id}/site-admin never removes the last one (siteAdmin.last, also on delete).
bootstrap_tokens holds the hash of one 24 h link, logged at start by server/bootstrap-link.ts
while no site admin exists (SB_BOOTSTRAP_LINK=1 forces one); POST /auth/bootstrap spends it
(people/application/bootstrap.ts).
Join links (ADR 0017 point 9): join_tokens (hash, guest, event,
created; one per guest and event, no expiry) and guests.password_set_at, which setAuthProtection
stamps when the hash changes. POST /auth/join signs in and joins while the password predates the
link; later it answers joined: false unless the browser is already verified as that guest
(people/application/join-links.ts). POST /admin/events/{id}/guests/{guestId}/join-link returns
the token once; POST /admin/events/{id}/join-links/mail mails new links.
The feed and the snapshot
ADR 0014 has the decisions.
GET /api/v1/events/{slug}/snapshot returns what one viewer may see of an event, with the seq of
the newest change it includes and the server’s now (the dev fake clock included); the feed’s
ping events (one as the stream opens, so the browser sees it open at once on a quiet event, then
every 25 s) carry now too, so a client keeps its clock offset current. A ping reads the
time-override cookie once, when the stream opens; changing the fake clock takes a reconnect.
GET /api/v1/events/{slug}/feed?since= is a Server-Sent Events stream: it replays the changes
table after since, then follows live changes registered with the hub. project() in
server/feed/project.ts turns each recorded change into the message that viewer may see, or none; a
new change type needs a case there and in audienceOf. The message carries the command_key only
for the actor who made the change.
The client applies messages to its replica in apps/web/src/replica/
(packages/contracts/src/feed-apply.ts, a pure function from replica and message to replica;
store.ts; live-replica.ts for the connection). A new message type needs a case in
feed-apply.ts: unknown types only move seq.
A cascade change that carries tellAttendees (day and location deletes) makes the
changeNotifications reaction notify the session’s hosts and RSVPed guests; event and guest deletes
record none.
GET /api/v1/updates (the updates module) answers the admin with the running and the latest
release, and anyone else with null. The server asks GitHub at most daily; SB_UPDATE_CHECK=false
turns that off.
SB_API_RATE_BURST sets the burst of the per-address API rate limit (default 1000).
GET /api/v1/health also names the version and the buildId (dist/server/BUILD_ID, written by
scripts/build-server.ts; the version outside a production build). The SPA shell carries the build
id it was served by (<meta name="sb-build">, added by serveSpaShell); a client that sees
health report another one offers a reload, and a kiosk reloads by itself. It stays behind the site
password.
The in-app release notes live in packages/domain/src/release-notes.ts so the footer imports it.
Keys from the UI
A route that creates something runs under idempotencyMiddleware (server/http/idempotency.ts),
with a key the caller made for the attempt, sent as the Idempotency-Key header. The SPA’s command
runner makes one per command and reuses it on a retry. A success is stored and replayed for a
repeat; a refusal frees the key. The changes it records carry the key, and the feed echoes it to the
sender, which is how the runner’s optimistic prediction knows its command has arrived and drops it.
Adding a use case and its route
- Contract. Add the request and response zod schemas to
packages/contracts/src/<area>.ts; they are plainzod, with no Hono import. - Use case. In
application/, a function of the module’s deps returning(actor, input, now) => Promise<Result<T>>. Authorize first:actor.adminfor organizer actions,actingGuest(oractingAsNamedGuestwhen the request names the guest) fromserver/kernel/acting-guest.tsfor anything done as a guest. Refuse withnotFound,forbidden,conflict,invalid,goneorunavailablefromserver/kernel/result.tsand a dotted code named for what was refused (session.full);invalidFieldsnames several fields at once. Takenowas an argument, never read the clock. Add it to the module’suse-cases.ts; a new dependency goes inports.tsand is wired incomposition.ts. - Route. In
http/, declare it withcreateRoute: the contract for the request,json(schema, …)for each success status,...problemDefaultfor problems, andheaders: idempotencyHeaderson every mutation. The handler readsc.req.valid(…), calls the use case withc.var.actorandc.var.now, and returnsproblem(result.error)on failure. Admin routes go under/admin/, or the gate will not cover them. Register a new route function inserver/http/app.ts. make openapi. It rewritespackages/contracts/openapi.jsonand the client’s types inpackages/api-client/src/schema.ts; commit both.make openapi-check, inmake precommitand CI, fails while either is stale.
make arch enforces the layout (see Architecture rules): the kernel
reaches no module, HTTP code or framework, even indirectly; application/ imports no HTTP,
framework or db/ code beyond the container’s types, and never calls the container; a module is
imported only through its module.ts, by other modules, tests and app/ alike; app/ reaches
server/ only through module.ts, composition.ts, the kernel and http/app.ts; server/ never
imports app/; packages/api-client imports only openapi-fetch.
Testing
Two integration tiers, against the real database:
- Behaviour on the use case (
tests/integration/*-use-cases.test.ts): call the use case fromcomposition.tswith anActorbuilt in the test, and cover its rules there — who may, what is refused, what is stored. - HTTP only on the route (
tests/integration/api-*.test.ts): call the mount’s handlers with a request and cookies, and check what only HTTP adds: status codes, the problem body and itscode, the contract’s validation, the admin gate. Do not repeat the use case’s rules here.
tests/integration/idempotency.test.ts covers Idempotency-Key, and
tests/integration/api-client.test.ts calls the API through @schellingboard/api-client.
tests/integration/mutating-surface-guard.test.ts checks the whole mount through a single route, so
a new use case acting as a guest proves its own guest.protected refusal in its tests.
Two meanings of “use case” meet here: the functions in application/, and the user stories
catalogued in tests/use-cases.ts that every test is tagged with (Testing).