Building Relay

Part 3 · Chapter 3.3

Errors that resolve

You will produce: An error registry that declares every code the platform can send, one place that builds the docs_url, and a typed thrower so a mistyped code stops compiling · about 30 minutes including the exercise

Source: SRS — Software Requirements Specification · Error reference

Every error this platform sends carries four fields. Three of them are true.

{
  "code": "not_found",
  "message": "no route for GET /v1/nope",
  "docs_url": "https://relay.example/docs/errors/not_found"
}

relay.example does not resolve, and constitution V calls docs_url a reachable-page promise. That is a debt with a due date somewhere in Part 4, and it is not what this chapter is about.

What this chapter is about is smaller and worse. Ask the platform which error codes it can send, and then ask the registry which ones it documents, and the two answers do not match.

Four codes the registry never admitted

ProtocolErrorFilter turns a status into a code whenever the thrower does not name one. It has done that since chapter 2.2, which widened the ladder because a 400 that calls itself internal_error is a lie a client cannot act on. The ladder can produce five codes. The registry declares one of them.

the ladder against the registry (excerpt)
filter can emit : forbidden  internal_error  invalid_request  not_found  unauthorized
registry has    : invalid_frame  rate_limited  unauthorized  unknown_frame_type
                  wrong_credential_type
 
emitted and undeclared : forbidden  internal_error  invalid_request  not_found

Four codes have been going out on the wire for a chapter and a half while the object that calls itself the failure vocabulary has never mentioned them. And because docs_url is derived from the code, each one has been shipping a link to a page that could not exist even after the docs site does — there is nothing to write the page from.

The fix is to write them down.

packages/protocol/src/codes.ts
@@ -25,9 +25,48 @@ export const ERROR_CODES = {
   // failure, so it gets its own code instead of a generic `unauthorized`: the
   // response has to say which class was presented and which the route wanted.
   // The MESSAGE names the class and never the credential — "the key rk_dev_abc…
   // is invalid" is how a live secret reaches a support ticket (NFR-SEC-06).
   wrong_credential_type:
     "the credential class presented cannot use this route; the message names presented and expected",
+
+  // ── THE FOUR THE FILTER SENDS AND THIS OBJECT NEVER DECLARED (FR-024) ────────
+  //
+  // `ProtocolErrorFilter` maps a status to a code when the thrower names none, and
+  // four of the five it can produce are absent here. They have gone out on the wire
+  // since chapter 2.2 widened that ladder, while this registry called itself the
+  // documented vocabulary — and `docs_url` is DERIVED from the code, so every one of
+  // them shipped a link to a page that cannot exist even in principle.
+  //
+  // Registering them is what makes the ladder typable. Annotated `ErrorCode`, a code
+  // that is not here stops compiling instead of reaching a customer as a dead link.
+  invalid_request:
+    "the request body, query or path failed validation; `field` names the first offending key",
+  forbidden: "the credential is valid and is not permitted to do this",
+  not_found:
+    "no such resource for this tenant — and DELIBERATELY the same answer as for a resource in another tenant (FR-TEN-05)",
+  internal_error:
+    "the platform failed in a way it did not anticipate; the request_id is what a support ticket needs",
 } as const;
 
 export type ErrorCode = keyof typeof ERROR_CODES;
+
+/** Where the published error reference lives, and THE ONE PLACE THE URL IS BUILT
+ * (FR-027, constitution V).
+ *
+ * THE DEBT THIS CLOSES. `docs_url` has been in the error envelope since chapter 1.3
+ * and constitution V calls it a reachable-page promise. Three sites build it with a
+ * template literal against a host that does not resolve, and a fourth would have
+ * been added by every chapter that adds a code.
+ *
+ * THE CODE IS THE ANCHOR, VERBATIM. No slug transform, no case change, no separator
+ * swap — the reference's headings ARE the codes. A transform here is a second
+ * vocabulary to keep in step with the first, and the registry already IS the
+ * vocabulary.
+ *
+ * The host stays a placeholder until the docs site exists. What stops being a
+ * placeholder is the NUMBER OF PLACES that have to change when it does: one. */
+export const ERROR_DOCS_BASE = "https://relay.example/docs/errors";
+
+export function docsUrl(code: ErrorCode): string {
+  return `${ERROR_DOCS_BASE}/${code}`;
+}

One place the URL is built

The same change adds docsUrl, and the reason is in the count. Before this chapter three files build that string with a template literal:

who builds a docs_url (excerpt)
services/gateway/src/session.ts:47
services/api/src/protocol-error.filter.ts:61
packages/service-kit/src/index.ts:85

Three is not a crisis. Three is the number before every chapter that adds a code also adds a construction site, which is the shape this series has watched go wrong twice already — a value declared in one place and rebuilt in several is a value that drifts the first time somebody changes the wrong copy.

The code is the anchor and it goes in verbatim. No slug transform, no case change, no separator swap: the reference page's headings are the codes. A transform would be a second vocabulary to keep in step with the first, and the registry already is the vocabulary.

The host stays a placeholder. What stops being a placeholder is the number of places that have to change when it is not: one.

A code you cannot mistype

The credentials chapter introduced the convention that a thrower may name its own code, because wrong_credential_type is a distinction a status cannot carry. A 403 that calls itself forbidden tells an integrator they lack a permission, when what they did was present the wrong kind of credential.

What that convention could not introduce was any check on the string.

One function fixes it, because a function is a place where ErrorCode is the only thing that fits:

services/api/src/protocol-error.ts
import { HttpException } from "@nestjs/common";
 
import type { ErrorCode } from "@relay/protocol";
 
/** An HTTP failure that NAMES ITS OWN CODE, typed (FR-025, FR-026).
 *
 * The credentials chapter introduced the convention that a thrower may name its
 * code, because `wrong_credential_type` is a distinction a status cannot carry: a
 * 403 that calls itself "forbidden" tells an integrator they lack a permission,
 * when what they did was present the wrong kind of credential.
 *
 * WHAT IT COULD NOT INTRODUCE WAS ANY CHECK ON THE STRING. `HttpException`'s
 * response is `unknown`, so `code: "wrong_credental_type"` compiles, ships, and
 * becomes a `docs_url` pointing at a page that does not exist. Nothing in the
 * toolchain reads that string; the first reader is a customer.
 *
 * This is one function so `ErrorCode` is the only thing that fits. The value is
 * exactly what `ProtocolErrorFilter` already reads — `code`, `message` and the
 * optional `field` — so nothing about the envelope changes. What changes is that a
 * typo stops compiling. */
export function protocolError(
  code: ErrorCode,
  message: string,
  status: number,
  field?: string,
): HttpException {
  return new HttpException(
    { code, message, ...(field !== undefined ? { field } : {}) },
    status,
  );
}

The value it builds is exactly what the filter already reads, so nothing about the envelope changes. What changes is that the typo above stops compiling:

the typo, now (excerpt)
probe.ts(2,32): error TS2345: Argument of type '"wrong_credental_type"'
                is not assignable to parameter of type 'ErrorCode'.

And the filter gets the other half. The ladder is annotated, and the code a thrower named is checked against the registry rather than trusted — protocolError makes an unregistered string hard to produce, not impossible, because HttpException is still public. The filter is the last place that can notice before the string becomes a URL.

services/api/src/protocol-error.filter.ts
@@ -1,8 +1,10 @@
 import type { ServerResponse } from "node:http";
 
+import { docsUrl, ERROR_CODES, type ErrorCode } from "@relay/protocol";
+
 import {
   Catch,
   HttpException,
   type ArgumentsHost,
   type ExceptionFilter,
 } from "@nestjs/common";
@@ -34,32 +36,42 @@ export class ProtocolErrorFilter implements ExceptionFilter {
     const named =
       typeof response === "object" &&
       response !== null &&
       typeof (response as { code?: unknown }).code === "string"
         ? (response as { code: string }).code
         : null;
-    const code =
-      named ??
-      (status === 400
+    // TYPED AS `ErrorCode` (FR-025). Four of the five codes this ladder can emit were
+    // not in the registry, and `docs_url` is derived from the code, so each shipped a
+    // link to a page that could not exist. With the annotation an unregistered code
+    // stops compiling here instead of reaching a customer.
+    //
+    // AND `named` IS CHECKED AGAINST THE REGISTRY RATHER THAN TRUSTED. A thrower can
+    // put any string in `code` — `protocolError` makes that hard, not impossible,
+    // because `HttpException` is still public — and this filter is the last place that
+    // can notice before the string becomes a URL.
+    const ladder: ErrorCode =
+      status === 400
         ? "invalid_request"
         : status === 401
           ? "unauthorized"
           : status === 403
             ? "forbidden"
             : status === 404
               ? "not_found"
-              : "internal_error");
+              : "internal_error";
+    const code: ErrorCode =
+      named !== null && named in ERROR_CODES ? (named as ErrorCode) : ladder;
     const message =
       exception instanceof HttpException
         ? exception.message
         : "unexpected internal error";
     res.statusCode = status;
     res.setHeader("content-type", "application/json");
     res.end(
       JSON.stringify({
         code,
         message,
-        docs_url: `https://relay.example/docs/errors/${code}`,
+        docs_url: docsUrl(code),
       }),
     );
   }
 }

The package that must not know about the registry

Three sites built the URL. Two of them now import docsUrl. The third cannot.

@relay/service-kit declares no dependencies at all — that is the property that lets every service use it without arguing about what else it drags in. Importing @relay/protocol to build one string would trade that away for a convenience.

So the dependency inverts. serve() takes the URL as an option and the caller, which already depends on the registry, supplies it:

packages/service-kit/src/index.ts
@@ -52,22 +52,37 @@ export function newRequestId(): string {
 
 export interface ServeOptions {
   service: string;
   /** Extra fields merged into the /healthz payload. */
   health: () => Record<string, unknown>;
   logger?: Logger;
+  /** The `docs_url` for the not-found envelope this server answers unknown routes
+   * with (FR-027).
+   *
+   * REQUIRED, AND THE DEPENDENCY INVERTS RATHER THAN BEING ADDED. The obvious move
+   * is to import `docsUrl` from `@relay/protocol` here — and this package declares
+   * NO dependencies at all, which is the property that lets anything use it. So the
+   * caller supplies the URL, and because the field is required the compiler makes it
+   * do so.
+   *
+   * Optional would have been a third instance of this chapter's own subject: a
+   * default host is a placeholder with a longer life. */
+  notFoundDocsUrl: string;
 }
 
 /** Build (but do not start) a service's HTTP server: every response carries
  * X-Request-Id (EIR-API-05), every request logs exactly one structured line
  * carrying the same id (NFR-OBS-06's grep-ability starts here), GET /healthz
  * answers with the service's health payload, and unknown routes get the
- * EIR-API-04 error shape. The docs_url host is a placeholder until the docs
- * site exists — constitution V's reachable-page promise lands with it. */
+ * EIR-API-04 error shape.
+ *
+ * The docs_url is no longer built here. It is a required option and the caller
+ * derives it from the registry, which is how a package with no dependencies can
+ * still emit a URL that registry owns. */
 export function serve(options: ServeOptions): Server {
-  const { service, health } = options;
+  const { service, health, notFoundDocsUrl } = options;
   const logger = options.logger ?? createLogger(service);
   return createServer((req, res) => {
     const requestId = newRequestId();
     const path = req.url ?? "/";
     res.setHeader("X-Request-Id", requestId);
     res.setHeader("content-type", "application/json");
@@ -79,13 +94,13 @@ export function serve(options: ServeOptions): Server {
       body = { status: "ok", service, ...health() };
     } else {
       status = 404;
       body = {
         code: "not_found",
         message: `no route for ${req.method ?? "?"} ${path}`,
-        docs_url: "https://relay.example/docs/errors/not_found",
+        docs_url: notFoundDocsUrl,
       };
     }
     res.statusCode = status;
     res.end(JSON.stringify(body));
     logger.log("info", "request", {
       request_id: requestId,
services/gateway/src/main.ts
@@ -1,7 +1,7 @@
-import { CLOSE_CODES, frameSchema } from "@relay/protocol";
+import { CLOSE_CODES, docsUrl, frameSchema } from "@relay/protocol";
 import { createLogger, serve, type Logger } from "@relay/service-kit";
 
 import { createApiClient } from "./api-client.js";
 import { createFanout } from "./fanout.js";
 import { attachSessions } from "./session.js";
 
@@ -18,12 +18,13 @@ const closeCodes = Object.keys(CLOSE_CODES).map(Number);
 export const DEFAULT_API_URL = "http://localhost:4000";
 
 export function createServer(logger?: Logger) {
   const log = logger ?? createLogger("gateway");
   const server = serve({
     service: "gateway",
+      notFoundDocsUrl: docsUrl("not_found"),
     health: () => ({
       uptime_s: Math.round(process.uptime()),
       protocol: { frames, close_codes: closeCodes },
     }),
     logger: log,
   });

What the tests hold

Two things, and neither is "the registry has entries".

The first is that the four codes the ladder can emit are still declared. That list is a second copy of the ladder, which is normally the defect rather than the test — so it is asserted in the direction that catches drift. The ladder itself is typed ErrorCode, so a code it emits and the registry lacks fails to compile in the api. This test covers the other direction: that somebody deleting an entry from the registry has to look at this file.

The second is the URL rule, over every code rather than a sampled one.

packages/protocol/src/codes.test.ts
@@ -1,9 +1,9 @@
 import { describe, expect, it } from "vitest";
 
-import { CLOSE_CODES, ERROR_CODES } from "./codes.js";
+import { CLOSE_CODES, docsUrl, ERROR_CODES, ERROR_DOCS_BASE, type ErrorCode } from "./codes.js";
 
 // The failure vocabulary stays coherent: EIR-WS-06's four classes are all
 // present, exactly once, with distinct meanings — and error codes never
 // collide or go blank as chapters add to the registry.
 
 describe("close codes cover EIR-WS-06's four classes", () => {
@@ -30,6 +30,44 @@ describe("error codes stay unique and described", () => {
   it("uses snake_case machine-readable keys (EIR-API-04)", () => {
     for (const code of Object.keys(ERROR_CODES)) {
       expect(code).toMatch(/^[a-z][a-z_]*$/);
     }
   });
 });
+
+describe("every code the REST filter can emit is registered (FR-024)", () => {
+  // The filter maps a status to a code when the thrower names none. Four of these
+  // were absent from the registry and went out on the wire from chapter 2.2, each
+  // with a `docs_url` derived from a code the reference could not document.
+  //
+  // NAMED HERE RATHER THAN IMPORTED, because `@relay/protocol` must not depend on a
+  // service. That makes this list a second copy of the ladder — so it is asserted in
+  // the direction that catches drift: the ladder is TYPED `ErrorCode`, which means a
+  // code it emits and this registry lacks stops compiling in the api. This test
+  // covers the other direction, that the four are not quietly deleted from here.
+  const EMITTED_BY_STATUS = [
+    "invalid_request",
+    "unauthorized",
+    "forbidden",
+    "not_found",
+    "internal_error",
+  ] as const;
+
+  it.each(EMITTED_BY_STATUS)("registers %s", (code) => {
+    expect(ERROR_CODES).toHaveProperty(code);
+    expect(ERROR_CODES[code as ErrorCode]).not.toBe("");
+  });
+});
+
+describe("the docs URL is built in one place, with the code as the anchor", () => {
+  it("appends the code VERBATIM — no slug transform, no case change", () => {
+    for (const code of Object.keys(ERROR_CODES) as ErrorCode[]) {
+      expect(docsUrl(code)).toBe(`${ERROR_DOCS_BASE}/${code}`);
+      expect(docsUrl(code).endsWith(`/${code}`)).toBe(true);
+    }
+  });
+
+  it("gives every code a distinct URL", () => {
+    const urls = (Object.keys(ERROR_CODES) as ErrorCode[]).map(docsUrl);
+    expect(new Set(urls).size).toBe(urls.length);
+  });
+});

The blast radius, file by file

The gateway's error frame takes a typed code and builds its URL the same way:

services/gateway/src/session.ts
@@ -1,15 +1,10 @@
 import { randomUUID } from "node:crypto";
 import type { IncomingMessage, Server } from "node:http";
 
-import {
-  CLOSE_CODES,
-  frameSchema,
-  type Frame,
-  type Message,
-} from "@relay/protocol";
+import { CLOSE_CODES, type ErrorCode, type Frame, type Message, docsUrl, frameSchema } from "@relay/protocol";
 import type { Logger } from "@relay/service-kit";
 import { WebSocketServer, type WebSocket } from "ws";
 
 import { ApiError, type ApiClient } from "./api-client.js";
 import { authenticate, type Identity } from "./auth.js";
 import type { Fanout } from "./fanout.js";
@@ -35,19 +30,19 @@ const MAX_MISSED_PINGS = 2;
 
 function send(socket: WebSocket, frame: Frame): void {
   socket.send(JSON.stringify(frame));
 }
 
 /** EIR-API-04's envelope, wearing its WebSocket clothes. */
-function sendError(socket: WebSocket, code: string, message: string): void {
+function sendError(socket: WebSocket, code: ErrorCode, message: string): void {
   send(socket, {
     type: "error",
     payload: {
       code,
       message,
-      docs_url: `https://relay.example/docs/errors/${code}`,
+      docs_url: docsUrl(code),
     },
   });
 }
 
 export interface SessionServerOptions {
   server: Server;

And the three remaining serve() callers, which are tests:

services/gateway/src/session.test.ts
@@ -9,12 +9,13 @@ import type { Frame } from "@relay/protocol";
 
 import type { InternalSendResponse, Message } from "@relay/protocol";
 
 import type { ApiClient } from "./api-client.js";
 import type { Fanout } from "./fanout.js";
 import { attachSessions } from "./session.js";
+import { docsUrl } from "@relay/protocol";
 
 // The door, the frames, and the liveness clock — all provable without a
 // database, because the gateway has no database (ADR-05). The api is a
 // stub here for exactly that reason: if these tests needed Postgres, the
 // gateway would be doing something it is not allowed to do.
 
@@ -130,12 +131,13 @@ async function boot(
   pingIntervalMs?: number,
   fanout?: Fanout,
   resumeDeadlineMs?: number,
 ): Promise<Harness> {
   const server: Server = serve({
     service: "gateway",
+      notFoundDocsUrl: docsUrl("not_found"),
     health: () => ({}),
     logger: silent,
   });
   const sessions = attachSessions({
     server,
     api,
services/gateway/src/resume.itest.ts
@@ -8,12 +8,13 @@ import type { AddressInfo } from "node:net";
 import { createLogger, serve, type Logger } from "@relay/service-kit";
 import type { Frame, Message } from "@relay/protocol";
 
 import type { ApiClient } from "./api-client.js";
 import { createFanout } from "./fanout.js";
 import { attachSessions } from "./session.js";
+import { docsUrl } from "@relay/protocol";
 
 // Chapter 2.7's race, run against a REAL broker. The unit suite proves the
 // ordering with a stub whose timing the test controls; this file proves it
 // with Redis in the middle, where the publish is a network round trip on
 // another connection and nobody is faking the interleaving.
 //
@@ -57,12 +58,13 @@ interface Harness {
 }
 
 async function boot(api: ApiClient): Promise<Harness> {
   const fanout = createFanout({ url, logger: silent });
   const server: Server = serve({
     service: "gateway",
+      notFoundDocsUrl: docsUrl("not_found"),
     health: () => ({}),
     logger: silent,
   });
   const sessions = attachSessions({ server, api, logger: silent, fanout });
   await new Promise<void>((resolve) => server.listen(0, resolve));
   const { port } = server.address() as AddressInfo;

The file the diff below amends, as it stands when this chapter opens. It is published whole here because five later chapters change it, and every one of their diffs is anchored on these bytes.

services/gateway/src/session.itest.ts
import { spawn, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import { fileURLToPath } from "node:url";
 
import { createLogger, serve, type Logger } from "@relay/service-kit";
import { WebSocket } from "ws";
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
 
import { createApiClient } from "./api-client.js";
import { attachSessions } from "./session.js";
 
// The socket's credential cases, against a REAL api.
//
// The unit suite stubs the api, which is right for the ordering and framing
// questions it asks — but it cannot prove the thing this chapter changed: that
// a token the api minted opens a socket, that an API key does not, and that the
// gateway's refusals are the api's refusals rather than its own opinion.
// Nothing in the unit suite would fail if `authenticate` simply believed a stub.
//
//   docker compose up -d --wait postgres
//   pnpm build
//   RELAY_POSTGRES_PORT=… pnpm --filter @relay/gateway test:integration
//
// The api runs as a CHILD PROCESS, not as an in-process module. Importing it
// would make the gateway depend on the api's framework to test itself, and the
// one thing this service is not allowed to know about is how the api is built
// (ADR-05). A port and a health check are the whole contract — which is also
// all the gateway has in production.
 
const silent: Logger = createLogger("gateway", () => {});
 
interface ApiUnderTest {
  url: string;
  environmentId: string;
  credential: string;
  channelId: string;
  stop: () => void;
}
 
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = join(HERE, "..", "..", "..");
const require_ = createRequire(import.meta.url);
 
/** Seeding goes through the api's own repository, imported from its build
 * output — the test-only seam 2.8 established, for the same reason: there is no
 * admin API for environments or keys yet, and inventing one for a test would be
 * inventing product. */
interface Seeder {
  createEnvironment: (
    db: unknown,
    input: { name: string },
  ) => Promise<{ id: string }>;
  createApiKey: (
    db: unknown,
    input: { environmentId: string },
  ) => Promise<{ credential: string }>;
  Repository: new (
    db: unknown,
    environmentId: string,
  ) => {
    createUser: (externalId: string, name?: string) => Promise<{ id: string }>;
    createChannel: (
      externalId: string,
      type: string,
      name?: string,
    ) => Promise<{ id: string }>;
    addMember: (channelId: string, userId: string) => Promise<boolean>;
  };
}
 
async function waitForHealth(url: string): Promise<void> {
  const deadline = Date.now() + 30_000;
  for (;;) {
    try {
      if ((await fetch(url)).ok) return;
    } catch {
      // not up yet
    }
    if (Date.now() > deadline) throw new Error(`api never became healthy`);
    await new Promise((resolve) => setTimeout(resolve, 100));
  }
}
 
async function startApi(): Promise<ApiUnderTest> {
  const dist = join(REPO, "services", "api", "dist");
  if (!existsSync(join(dist, "main.js"))) {
    throw new Error(
      "the api is not built — run `pnpm build` before this lane " +
        "(the suite talks to the real service, not a stub)",
    );
  }
  const client = require_(join(dist, "db", "client.js")) as {
    createDb: (pool: unknown) => unknown;
    createPool: () => unknown;
  };
  const seeder = require_(join(dist, "db", "repository.js")) as Seeder;
  const db = client.createDb(client.createPool());
 
  const environment = await seeder.createEnvironment(db, {
    name: `session-itest-${randomUUID().slice(0, 8)}`,
  });
  const repo = new seeder.Repository(db, environment.id);
  const user = await repo.createUser("tuan", "Tuan");
  const channel = await repo.createChannel("fleet", "public");
  await repo.addMember(channel.id, user.id);
  const key = await seeder.createApiKey(db, {
    environmentId: environment.id,
  });
 
  const port = Number(process.env.RELAY_SESSION_ITEST_API_PORT ?? 4123);
  const child: ChildProcess = spawn("node", [join(dist, "main.js")], {
    env: { ...process.env, PORT: String(port) },
    stdio: ["ignore", "pipe", "pipe"],
  });
  const url = `http://127.0.0.1:${port}`;
  await waitForHealth(`${url}/healthz`);
 
  return {
    url,
    environmentId: environment.id,
    credential: key.credential,
    channelId: channel.id,
    stop: () => child.kill(),
  };
}
 
async function closeCode(socket: WebSocket): Promise<number> {
  return new Promise((resolve, reject) => {
    socket.on("close", (code) => resolve(code));
    socket.on("error", () => undefined);
    setTimeout(() => reject(new Error("no close within 5s")), 5_000);
  });
}
 
async function firstFrame(socket: WebSocket, type: string): Promise<unknown> {
  return new Promise((resolve, reject) => {
    socket.on("message", (raw) => {
      const frame = JSON.parse(raw.toString()) as { type: string };
      if (frame.type === type) resolve(frame);
    });
    socket.on("close", (code) => reject(new Error(`closed ${code}`)));
    setTimeout(() => reject(new Error(`no ${type} within 5s`)), 5_000);
  });
}
 
describe("the socket's credentials", () => {
  let api: ApiUnderTest;
  let server: Server;
  let url: string;
  const sockets: WebSocket[] = [];
 
  const mintToken = async (user = "tuan", ttlSeconds = 3600) => {
    const res = await fetch(`${api.url}/auth/dev-token`, {
      method: "POST",
      headers: {
        "content-type": "application/json",
        authorization: `Bearer ${api.credential}`,
      },
      body: JSON.stringify({ user, ttl_seconds: ttlSeconds }),
    });
    if (!res.ok) throw new Error(`dev-token: ${res.status}`);
    return ((await res.json()) as { token: string }).token;
  };
 
  const connect = (token: string) => {
    const socket = new WebSocket(`${url}/v1/ws?token=${token}`);
    sockets.push(socket);
    return socket;
  };
 
  beforeAll(async () => {
    api = await startApi();
    server = serve({ service: "gateway", health: () => ({}), logger: silent });
    attachSessions({ server, api: createApiClient(api.url), logger: silent });
    await new Promise<void>((resolve) => server.listen(0, resolve));
    url = `ws://127.0.0.1:${(server.address() as AddressInfo).port}`;
  }, 60_000);
 
  afterEach(() => {
    for (const socket of sockets.splice(0)) socket.close();
  });
 
  it("opens for a token the api minted, and knows the user's channels", async () => {
    const frame = (await firstFrame(
      connect(await mintToken()),
      "connection.ack",
    )) as { payload: { user: string; channels?: string[] } };
    // The identity in the ack came from the api's verification, not from
    // anything the gateway decided.
    expect(frame.payload.user).toBe("tuan");
  });
 
  it("refuses an API KEY presented as a socket token (4001)", async () => {
    // The chapter's mistake, at the other door: the credential is perfectly
    // valid, and it is the wrong kind for a socket. The upgrade closes 4001
    // rather than opening a session for an application.
    expect(await closeCode(connect(api.credential))).toBe(4001);
  });
 
  it("refuses a token this environment did not sign (4001)", async () => {
    const foreign = await fetch(`${api.url}/auth/dev-token`, {
      method: "POST",
      headers: { "content-type": "application/json" },
      body: JSON.stringify({ user: "tuan" }),
    });
    // No credential at all: the api refuses to mint, so there is nothing to
    // present. The socket's refusal is the same one either way.
    expect(foreign.status).toBe(401);
    expect(await closeCode(connect("eyJhbGciOiJIUzI1NiJ9.forged.signature"))).toBe(
      4001,
    );
  });
 
  it("keeps an established connection alive past its token's expiry (invariant 10)", async () => {
    // FR-AUT-11's first clause, and the reason it is worth a test: verification
    // happens AT CONNECT. A socket is not re-authenticated on a timer, so a
    // token that ages out mid-conversation does not drop the call.
    const socket = connect(await mintToken("tuan", 2));
    await firstFrame(socket, "connection.ack");
    await new Promise((resolve) => setTimeout(resolve, 2_500));
    expect(socket.readyState).toBe(WebSocket.OPEN);
 
    // What it can no longer do is WRITE, and this is the honest half of the
    // requirement rather than a bug: the internal hop forwards the user's own
    // token, so a write after expiry presents an expired credential and the api
    // refuses it. Delivery is unaffected — fan-out never asks the api anything.
    //
    // FR-AUT-11's SECOND clause is what would close this gap: a frame that lets
    // the client hand over a refreshed token on the open connection. This
    // chapter does not build it, so the socket says what a client can actually
    // do instead.
    socket.send(
      JSON.stringify({
        type: "message.send",
        payload: {
          idem_key: randomUUID(),
          channel: api.channelId,
          text: "still here",
        },
      }),
    );
    const refusal = (await firstFrame(socket, "error")) as {
      payload: { code: string; message: string };
    };
    expect(refusal.payload.code).toBe("unauthorized");
    expect(refusal.payload.message).toMatch(/expired/);
    expect(refusal.payload.message).toMatch(/reconnect/);
    // Still open after the refusal: refusing a write is not closing a socket.
    expect(socket.readyState).toBe(WebSocket.OPEN);
  }, 20_000);
 
  it("a reconnect with a fresh token can send again", async () => {
    // The recovery path the refusal above names, proven rather than asserted.
    const socket = connect(await mintToken("tuan", 3600));
    await firstFrame(socket, "connection.ack");
    socket.send(
      JSON.stringify({
        type: "message.send",
        payload: {
          idem_key: randomUUID(),
          channel: api.channelId,
          text: "back with a fresh token",
        },
      }),
    );
    const ack = (await firstFrame(socket, "message.ack")) as {
      payload: { seq: number };
    };
    expect(ack.payload.seq).toBeGreaterThan(0);
  });
 
  afterAll(async () => {
    api?.stop();
    server?.close();
  });
});
services/gateway/src/session.itest.ts
@@ -10,12 +10,13 @@ import { fileURLToPath } from "node:url";
 import { createLogger, serve, type Logger } from "@relay/service-kit";
 import { WebSocket } from "ws";
 import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
 
 import { createApiClient } from "./api-client.js";
 import { attachSessions } from "./session.js";
+import { docsUrl } from "@relay/protocol";
 
 // The socket's credential cases, against a REAL api.
 //
 // The unit suite stubs the api, which is right for the ordering and framing
 // questions it asks — but it cannot prove the thing this chapter changed: that
 // a token the api minted opens a socket, that an API key does not, and that the
@@ -172,13 +173,18 @@ describe("the socket's credentials", () => {
     sockets.push(socket);
     return socket;
   };
 
   beforeAll(async () => {
     api = await startApi();
-    server = serve({ service: "gateway", health: () => ({}), logger: silent });
+    server = serve({
+      service: "gateway",
+      health: () => ({}),
+      logger: silent,
+      notFoundDocsUrl: docsUrl("not_found"),
+    });
     attachSessions({ server, api: createApiClient(api.url), logger: silent });
     await new Promise<void>((resolve) => server.listen(0, resolve));
     url = `ws://127.0.0.1:${(server.address() as AddressInfo).port}`;
   }, 60_000);
 
   afterEach(() => {

What this chapter leaves for later, on purpose

The host still does not resolve. docs_url will point at a real page when there is a site to point at, and that is Part 4's — the debt this chapter closes is not the missing site but the missing ability to fix it in one place.

The registry has five codes because the platform has five failures worth naming. Every chapter after this one adds its own, and each addition is now three lines and a test rather than a new construction site. That is the shape to notice: the contract is cheapest to establish before there is anything to retrofit.