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
Mọi lỗi mà nền tảng này gửi đều mang bốn field. Ba trong số đó là đúng.
{
"code": "not_found",
"message": "no route for GET /v1/nope",
"docs_url": "https://relay.example/docs/errors/not_found"
}relay.example không phân giải được, còn hiến pháp V thì gọi docs_url là một lời hứa về một
trang với tới được. Đó là một khoản nợ có ngày đáo hạn đâu đó trong Phần 4, và nó không phải
thứ mà chương này nói về.
Thứ chương này nói về thì nhỏ hơn và tệ hơn. Hãy hỏi nền tảng xem nó gửi được những mã lỗi nào, rồi hỏi registry xem nó ghi tài liệu cho những mã nào, và hai câu trả lời không khớp.
Bốn mã mà registry chưa bao giờ thừa nhận
ProtocolErrorFilter biến một status thành một mã bất cứ khi nào bên ném không gọi tên
một cái. Nó đã làm thế từ chương 2.2, chương nới rộng cái thang bởi một 400 tự
gọi mình là internal_error là một lời nói dối mà một client chẳng hành động được theo. Cái thang tạo ra được
năm mã. Registry khai báo một trong số đó.
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_foundBốn mã đã đi ra đường truyền suốt một chương rưỡi trong khi cái object
tự gọi mình là bộ từ vựng thất bại thì chưa bao giờ nhắc tới chúng. Và bởi
docs_url được suy ra từ cái mã, nên mỗi cái đều ship một liên kết tới một trang mà
ngay cả sau khi site tài liệu tồn tại thì cũng không thể có — chẳng có gì để viết trang đó
ra từ đấy.
Cách sửa là viết chúng ra.
@@ -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}`;
+}Một chỗ dựng cái URL
Cùng thay đổi đó thêm docsUrl, và lý do nằm ở con số. Trước chương này có
ba file dựng cái chuỗi ấy bằng một template literal:
services/gateway/src/session.ts:47
services/api/src/protocol-error.filter.ts:61
packages/service-kit/src/index.ts:85Ba thì chưa phải khủng hoảng. Ba là con số trước khi mọi chương thêm một mã cũng thêm một chỗ dựng, và đó là cái hình dạng mà loạt bài này đã chứng kiến hỏng hai lần rồi — một giá trị khai báo ở một chỗ mà dựng lại ở nhiều chỗ là một giá trị trôi dạt ngay lần đầu tiên có ai đó đổi nhầm bản sao.
Cái mã là cái neo và nó được đưa vào nguyên văn. Không biến đổi thành slug, không đổi hoa thường, không đổi dấu phân cách: các tiêu đề của trang tham chiếu chính là các mã. Một phép biến đổi sẽ là một bộ từ vựng thứ hai phải giữ đồng bộ với cái thứ nhất, mà registry thì vốn đã là bộ từ vựng.
Cái host vẫn là một chỗ giữ chỗ. Thứ thôi là chỗ giữ chỗ là số lượng các chỗ phải đổi khi nó không còn là chỗ giữ chỗ nữa: một.
Một mã mà bạn không gõ sai được
Chương credentials đưa vào quy ước rằng một bên ném có thể gọi tên mã của chính nó,
bởi wrong_credential_type là một sự phân biệt mà một status không mang được. Một 403 tự
gọi mình là forbidden thì nói với một người tích hợp rằng họ thiếu một quyền, trong khi thứ họ làm là
trình ra sai loại credential.
Thứ mà quy ước ấy không đưa vào được là bất kỳ phép kiểm nào trên cái chuỗi.
Một hàm sửa được nó, bởi một hàm là một chỗ mà ErrorCode là thứ duy nhất
vừa khít:
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,
);
}Giá trị nó dựng ra chính xác là thứ mà bộ lọc vốn đã đọc, nên chẳng gì về cái phong bì thay đổi. Thứ thay đổi là cái lỗi gõ ở trên thôi biên dịch được:
probe.ts(2,32): error TS2345: Argument of type '"wrong_credental_type"'
is not assignable to parameter of type 'ErrorCode'.Và bộ lọc nhận nửa còn lại. Cái thang được chú kiểu, và cái mã mà một bên ném
gọi tên thì được kiểm đối chiếu với registry chứ không được tin — protocolError làm cho một
chuỗi chưa đăng ký trở nên khó tạo ra, chứ không phải bất khả, bởi HttpException vẫn
công khai. Bộ lọc là chỗ cuối cùng có thể nhận ra trước khi cái chuỗi thành một URL.
@@ -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),
}),
);
}
}Cái gói không được biết về registry
Ba chỗ dựng cái URL. Hai chỗ giờ import docsUrl. Chỗ thứ ba thì không thể.
@relay/service-kit khai báo không phụ thuộc nào cả — đó là cái tính chất
cho phép mọi service dùng nó mà chẳng phải tranh cãi về việc nó kéo theo thứ gì khác. Import
@relay/protocol chỉ để dựng một cái chuỗi sẽ là đánh đổi mất điều đó lấy một sự tiện lợi.
Nên sự phụ thuộc đảo ngược lại. serve() nhận cái URL như một tuỳ chọn còn bên gọi, thứ
vốn đã phụ thuộc vào registry, thì cung cấp nó:
@@ -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,@@ -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,
});Các bài test giữ gì
Hai thứ, và chẳng thứ nào là "registry có các mục".
Thứ nhất là bốn mã mà cái thang phát ra được thì vẫn đang được khai báo. Danh sách đó là một
bản sao thứ hai của cái thang, và đó thường là khiếm khuyết chứ không phải bài test — nên nó được
khẳng định theo đúng cái hướng bắt được sự trôi dạt. Bản thân cái thang được gắn kiểu ErrorCode, nên
một mã nó phát ra mà registry thiếu thì không biên dịch được trong api. Bài test này phủ
hướng còn lại: rằng ai đó xoá một mục khỏi registry thì phải nhìn vào chính
file này.
Thứ hai là cái quy tắc về URL, trên mọi mã chứ không phải trên một mã lấy mẫu.
@@ -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);
+ });
+});Bán kính vụ nổ, từng file một
Frame lỗi của gateway nhận một mã đã gắn kiểu rồi dựng URL của nó theo cùng cách:
@@ -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;Và ba bên gọi serve() còn lại, đều là các bài test:
@@ -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,@@ -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;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();
});
});@@ -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(() => {Thứ chương này để lại cho sau, một cách có chủ đích
Cái host vẫn không phân giải được. docs_url sẽ trỏ vào một trang thật khi có một
site để mà trỏ vào, và đó là của Phần 4 — khoản nợ mà chương này đóng lại không phải cái site
còn thiếu mà là khả năng sửa nó ở một chỗ duy nhất vốn còn thiếu.
Registry có năm mã bởi nền tảng có năm kiểu thất bại đáng gọi tên. Mọi chương sau chương này đều thêm mã của riêng nó, và mỗi lần thêm giờ là ba dòng và một bài test chứ không phải một chỗ dựng mới. Đó là cái hình dạng đáng để ý: một hợp đồng thì rẻ nhất khi được thiết lập trước khi có thứ gì để mà lắp ngược trở lại.