Chapter 22
REST API
The HTTP endpoints grew one at a time, whenever a chapter needed to show something. /api/status in Chapter 6. /api/rooms in Chapter 9. /api/crash in Chapter 10. /api/health in Chapter 15.
They are not an API. They are a pile of endpoints. And every single one of them was public:
curl http://localhost:8080/api/history200. Every message. Every room. No credential of any kind.Chapter 17 shut the door on the chat protocol - sessions, tokens, a requireAuth middleware, the whole thing - and left this wide open. That is not an oversight in Chapter 17; it is the shape of Chapter 17. Auth was built as a middleware over ClientMessage, and an HTTP request is not a ClientMessage.
Authentication that is bolted onto one protocol is not authentication. It is a habit.
Make the check impossible to forget
The obvious fix is a line at the top of every handler:
.on("GET", "/api/rooms", (req) => {
const session = requireSession(req); // ...and if somebody forgets this line?
return json(200, ...);
})That works, and it works for exactly as long as everybody remembers. So instead the router carries a context type, and there are two of them:
private readonly open: Router<void>;
private readonly secure: Router<Session>;A Router<void> hands its handlers nothing. A Router<Session> hands its handlers a session:
.on("GET", "/api/users/me", (_req, _params, session) =>
json(200, { name: session.user.name, admin: isAdmin(session.user) }))An authenticated handler cannot forget to check for a session, because it could not have been called without one. The check is not a line of code somebody has to remember to write. It is the type of the argument.
This is the same move as Chapter 16's state machine, and Chapter 9's assertNever, and Chapter 10's Result: make the bad state unrepresentable, rather than validating against it in twelve places and hoping.
export type RouteHandler<P extends string, C> = (
request: HttpRequest,
params: PathParams<P>,
context: C,
) => HttpResponse | Promise<HttpResponse>;The router from Chapter 13 needed one extra type parameter. That is all.
One policy, two protocols
private async authenticate(req: HttpRequest): Promise<Session> {
const header = req.headers.get("authorization");
const token = header?.match(/^Bearer (.+)$/i)?.[1];
if (token === undefined) {
throw new AuthError("Send an Authorization: Bearer <token> header.", ErrorCode.Unauthenticated);
}
// The same function the chat protocol's `auth` message uses.
const session = await resume(this.deps.accounts, token, this.deps.config.jwtSecret);
if (!session.ok) throw session.error;
return session.value;
}resume() is Chapter 17's, unchanged. The alg:none forgery is refused here for the same reason it is refused there - because it is the same code, not because somebody remembered to write the check twice.
Order matters, and it leaks if you get it wrong
const publicRoute = this.open.match(req.method, path);
if (publicRoute !== undefined) return await publicRoute.handler(req, publicRoute.params, undefined as void);
const secureRoute = this.secure.match(req.method, path);
if (secureRoute === undefined) {
// ...405 or 404
}
const session = await this.authenticate(req); // only nowLook at where authenticate is: after we know the route exists.
Do it the other way round and an unauthenticated request to /api/secrets gets a 401 - which tells a stranger that the path exists and they simply cannot see it. Do it this way and it gets a 404, which tells them nothing.
GET /api/rooms (no token) -> 401
GET /api/secrets (no token) -> 404It is a small leak. It is also free not to make it.
Status codes are an interface
201 + Location | you made a thing, and here is where it now lives |
|---|---|
| 204 | it worked, and there is nothing to say |
401 + WWW-Authenticate | I do not know who you are |
| 403 | I know exactly who you are, and the answer is still no |
| 404 | there is no such thing |
405 + Allow | there is such a thing, and not by that verb |
| 422 | I understood you perfectly, and no |
The 400/422 line is the one people skip, and it is Chapter 10's distinction wearing an HTTP hat: {"text": 123} is a 400 (I could not read you), and {"text": "<1001 characters>"} is a 422 (I read you, and no).
And a 401 without WWW-Authenticate is not a 401, it is a 401-shaped noise. The header is what tells a client how to authenticate:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: BearerPagination: a cursor, not an offset
page(room: RoomName, limit: number, before?: number): Promise<MessageSummary[]>;LIMIT 20 OFFSET 40 looks equivalent, and is not. Messages arrive while somebody is reading. Every new row shifts the old ones down, so page 3 shows you two messages you already saw on page 2 - and if a row is deleted, page 3 skips one entirely.
A cursor says "before this moment", and a moment does not move:
WHERE room = ? AND at < ? ORDER BY at DESC LIMIT ?It walks the same (room, at) index from Chapter 21, so page 40 costs exactly what page 1 did - which OFFSET cannot promise, because OFFSET has to count past every row it is skipping.
The server builds the next link so the client never computes anything:
{
"room": "general",
"messages": [ ... ],
"next": "/api/rooms/general/messages?limit=10&before=1015"
}And when there is no more history, next is null - which is how a client knows to stop without guessing.
The test asserts the property that matters:
// A new message arriving now must NOT shift the next page - which is exactly
// what OFFSET would have done.
await store.append({ room: "general", sender: "bob", text: "brand new", at: 99999 });
const third = parse(await call(rest, "GET", second.next, { token }));
expect(third.messages.map((m) => m.text)).toEqual(["m0","m1","m2","m3","m4"]);The nicest thing in the chapter took no new code
.on("POST", "/api/rooms/:room/messages", (req, params, session) => {
const room = registry.requireRoomNamed(params.room);
// ...validate...
const message = new ChatMessage(session.user.name, text, room.name);
bus.emit("message", message);
return jsonWith(201, { Location: ... }, { ... });
})bus.emit("message", ...) - that is it. The same three listeners run that have run since Chapter 8: the log, the archive, and the broadcast.
So a curl in a terminal appears instantly in a browser's chat window:
bob is sitting in #general over WebSocket, waiting...
a curl POST /api/rooms/general/messages happened; bob (WebSocket) received:
{"type":"chat","sender":"alice","text":"hello from a REST call","room":"general","at":1783958804307}Nothing was written to make that work. Chapter 8 decoupled what happened from everyone who cares about it, and a REST POST is just one more thing that happened. That is what the abstraction was for, and this is the invoice being paid four hundred pages later.
And http.ts got smaller
Routing moved out to rest.ts. What is left in http.ts is what that module was always actually about: turning bytes into an HttpRequest, and an HttpResponse back into bytes.
Parsing is not routing. For six chapters they lived in one file because there was not enough of either to notice.
Putting It Together
src/rest.ts closes the hole: the HTTP API had no auth at all. It is on the chapter22 branch.
Two routers, and the split is the point. A Router<Session> hands each handler a session - so an authenticated handler cannot forget to check for one, because it could not have been called without one:
private readonly open: Router<void>;
private readonly secure: Router<Session>;
constructor(private readonly deps: RestDeps) {
this.open = this.publicRoutes();
this.secure = this.authenticatedRoutes();
}And the gate. The Bearer token is verified by the same resume the chat protocol uses - one policy, two protocols:
private async authenticate(req: HttpRequest): Promise<Session> {
const header = req.headers.get("authorization");
const token = header?.match(/^Bearer (.+)$/i)?.[1];
if (token === undefined) {
throw new AuthError("Send an Authorization: Bearer <token> header.", ErrorCode.Unauthenticated);
}
// The same function the chat protocol's `auth` message uses. One policy, two
// protocols - which is the thing that was missing.
const session = await resume(this.deps.accounts, token, this.deps.config.jwtSecret);
if (!session.ok) {
throw session.error;
}
return session.value;
}src/rest.ts on the chapter22 branch. You are not meant to paste it wholesale - build your own as you follow along, and use the reference to check yourself.Try It
npm run build && npm start# The hole is closed.
curl -i localhost:8080/api/rooms
# 401 Unauthorized
# WWW-Authenticate: Bearer
# A path that does not exist tells you nothing.
curl -i localhost:8080/api/secrets # 404, not 401
# Log in.
TOKEN=$(curl -s -X POST -d '{"name":"alice","password":"correct-horse"}' \
localhost:8080/api/login | jq -r .token)
curl -s -H "Authorization: Bearer $TOKEN" localhost:8080/api/users/me
# { "name": "alice", "admin": true, ... }
# Post a message, and watch it land in an open browser tab.
curl -i -H "Authorization: Bearer $TOKEN" \
-d '{"text":"hello from a REST call"}' \
localhost:8080/api/rooms/general/messages
# 201 Created
# Location: /api/rooms/general/messages?before=...&limit=1
# Page backwards through history.
curl -s -H "Authorization: Bearer $TOKEN" \
"localhost:8080/api/rooms/general/messages?limit=5" | jq '{next, count: (.messages|length)}'Exercise
- Move
authenticate()to the top ofhandle(), before the route lookup. Nowcurl /api/secretsreturns 401. Explain, to somebody who does not think it matters, exactly what you just told an attacker. - Register a handler on
this.securethat ignores itssessionargument. Now try to register one onthis.openthat uses a session. Read the error. That is the chapter. - Implement
GET /api/rooms/:room/messageswithOFFSETinstead of a cursor. Then write a test that posts a message between page 1 and page 2, and watch it fail. - Add
ETagandIf-None-MatchtoGET /api/rooms/:room, returning304 Not Modified. What do you hash, and what happens when a member joins? POST /api/rooms/:room/messagesis not rate-limited - Chapter 17'srateLimitmiddleware is on the chat pipeline. Fix it, and notice that you are about to write the same "one policy, two protocols" fix a second time.
What's Next
The API has auth that cannot be forgotten, status codes that mean things, pagination that does not lie, and a POST that lands in everybody's chat window.
The chat itself, though, has not moved since Chapter 16. There is no way to tell that somebody is typing, no way to know who is actually there rather than merely connected, and a client whose network drops leaves a ghost sitting in the room forever, because nothing ever checks.
Next: real-time features.