Part 3 · Chapter 3.26
Milestone: an outsider integrates
You will produce: A sealed integration package mechanically unable to import workspace code, and a verdict on the SRS Phase 2 exit criterion with what was measured and what was assumed · about 31 minutes including the exercise
Người ngoài
flowchart TB
want["packages/outsider muốn<br/>ERROR_CODES"]
want --> l1["CẤP 1 — không phải rule nào cả.<br/>Không có dependency @relay/*, và node_modules<br/>cô lập của pnpm không có @relay ở gốc"]
l1 --> r1["Cannot find package '@relay/protocol'"]
want --> l2["CẤP 2 — no-restricted-imports.<br/>../../protocol/src/codes.js"]
l2 --> r2["không được với ra ngoài chính nó"]
want --> l3["CẤP 3 — no-restricted-syntax.<br/>join(dirname, '..', …) và createRequire"]
l3 --> r3["không được dựng một path ra khỏi package"]
l3 --> why["một rule về import không thấy được path<br/>dựng từ chuỗi — packages/e2e<br/>dựng một cái và spawn từ đó"]
want --> l4["KHÔNG CẤP NÀO CHẶN ĐƯỢC:<br/>đọc source bằng mắt người"]
l4 --> disc["một kỷ luật, không phải một cơ chế.<br/>Ba rule không được ngụ ý cái thứ tư."]
style r1 fill:#7f1d1d,color:#fff,stroke:#dc2626
style r2 fill:#7f1d1d,color:#fff,stroke:#dc2626
style r3 fill:#7f1d1d,color:#fff,stroke:#dc2626
style disc fill:#78350f,color:#fff,stroke:#d97706Tiêu chí thoát cần một bản tích hợp dựng chỉ từ tài liệu đã xuất bản. Tuyên bố có một cái thì dễ; tuyên bố ấy chẳng đáng gì trừ khi thứ đưa ra nó không đọc được mã nguồn của nền tảng. Nên nó là một gói không đọc được:
{
"name": "@relay/outsider",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"typecheck": "tsc --noEmit",
"test:integration": "vitest run --config vitest.integration.config.mts"
}
}{
"extends": "../../tsconfig.base.json",
"include": ["src"]
}import { defineConfig } from "vitest/config";
// THE SEALED INTEGRATION (FR-030, FR-031).
//
// This package holds one suite that behaves like a customer: it reads two URLs
// and a credential from the environment, speaks HTTP and WebSocket, and knows
// nothing else about Relay. It is the SRS Phase 2 exit criterion — "an external
// developer integrates using only public documentation, with no assistance" —
// made into something that either passes or fails.
//
// WRITTEN FROM SCRATCH, NOT COPIED FROM A SIBLING, and that was a deliberate
// instruction rather than a preference. Every other integration config in this
// workspace points `globalSetup` and `setupFiles` at
// `../../packages/test-harness/src/…` — so copying one reaches into another
// package on its second line, which is exactly the thing this package exists to
// be unable to do. It needs neither: it touches no database, so there is nothing
// to migrate, no guard to arm and no bait to plant.
//
// NO `test` SCRIPT in package.json either. The Docker-free unit lane must not
// look here: with no platform running, every test in this suite fails, and it
// should — "the platform is not up" is the correct answer to a request to
// integrate against it, not a reason to soften the suite.
//
// AND THE DEFAULT INTEGRATION LANE SKIPS IT TOO. `pnpm test:integration` is
// `turbo run test:integration --filter=!@relay/outsider`, with `pnpm test:outsider`
// as the way in. That lane needs stores and spawns what it talks to; this suite
// needs the api and gateway ALREADY SERVING, from images that were built, with a
// tenant already seeded. Folding it in would make every developer's integration run
// depend on a compose profile they did not ask for — and the honest failure this
// suite gives when the platform is absent would become noise everyone learns to
// scroll past.
export default defineConfig({
test: {
include: ["src/**/*.itest.ts"],
// A socket handshake and a fan-out hop against a real stack, not a stub.
testTimeout: 30_000,
hookTimeout: 30_000,
},
});import { randomUUID } from "node:crypto";
import { beforeAll, describe, expect, it } from "vitest";
// AN INTEGRATION BUILT FROM PUBLISHED DOCUMENTATION ALONE (FR-031, SC-009,
// SC-030).
//
// This file is the SRS Phase 2 exit criterion as a test: "an external developer
// integrates using only public documentation, with no assistance." It knows three
// things about Relay — two URLs and a credential — and everything else it does is
// HTTP and WebSocket against a running platform it did not start.
//
// IT STARTS NOTHING. No `spawn`, no compose invocation, no process launch of any
// kind. Every other integration suite in this workspace boots what it talks to,
// which is right for them and would destroy the claim here: a package that can
// start the platform is a package that knows how the platform is built. If the
// platform is absent this fails saying so, which is the correct answer.
//
// THREE MECHANICAL SEALS keep it honest, and none of them is this comment:
//
// 1. `package.json` declares no `@relay/*` dependency, and pnpm's isolated
// `node_modules` has no `@relay` directory at the workspace root — so
// `import { ERROR_CODES } from "@relay/protocol"` does not resolve. No rule
// is involved; the module simply is not there.
// 2. `no-restricted-imports` in `eslint.config.mjs` refuses any specifier that
// climbs out of this package.
// 3. `no-restricted-syntax` refuses the `".."` string literal and
// `createRequire`, because an import rule cannot see a path built from
// strings — `packages/e2e/src/harness.ts` builds one and spawns from it.
//
// WHAT NONE OF THE THREE CLOSES: reading the repository's source with human eyes.
// The seals make it impossible to IMPORT workspace code; they cannot make it
// impossible to look. That is a discipline, and the chapter says so rather than
// letting three rules imply a fourth (FR-034).
//
// AND IT IMPORTS NOTHING AT ALL BEYOND VITEST. The socket uses Node's GLOBAL
// `WebSocket`, not the `ws` package every suite in this workspace uses — which
// was not the plan and is the better answer. `ws` resolves from the workspace root
// by the ordinary parent walk, so the suite could have used it while declaring
// nothing; its TYPES do not, and the choice was between borrowing `@types/ws`
// through a parent walk, writing a local ambient declaration, or using the
// platform's own client. Node 22 has had a standards-compliant `WebSocket` since
// 22.4, so an outsider in 2026 needs no library — and the API is the browser's,
// which is what the series' own examples show. A dependency list that is empty
// because nothing is needed is a stronger claim than one that is empty because
// three things were reached for sideways.
const API = process.env["RELAY_API_URL"];
const WS = process.env["RELAY_WS_URL"];
const CREDENTIAL = process.env["RELAY_DEMO_CREDENTIAL"];
/** Read from the environment and checked ONCE, with a message that says what to do.
*
* An outsider's first failure should not be `fetch failed` against `undefined`. It
* should be a sentence naming the three things this suite needs and where they come
* from — which is itself part of what the exit criterion measures. */
function required(): { api: string; ws: string; credential: string } {
const missing = [
API ? null : "RELAY_API_URL",
WS ? null : "RELAY_WS_URL",
CREDENTIAL ? null : "RELAY_DEMO_CREDENTIAL",
].filter(Boolean);
if (missing.length > 0) {
throw new Error(
`this suite integrates against a RUNNING platform and starts nothing. ` +
`Missing: ${missing.join(", ")}. Bring the platform up and seed a tenant:\n` +
` RELAY_POSTGRES_PORT=15432 docker compose up -d --wait\n` +
` DATABASE_URL=postgres://relay:relay@localhost:15432/relay node services/api/dist/db/migrate.js\n` +
` RELAY_POSTGRES_PORT=15432 docker compose --profile services up -d --wait\n` +
` export RELAY_DEMO_CREDENTIAL=$(node scripts/seed-demo-tenant.mjs)\n` +
` export RELAY_API_URL=http://localhost:4000 RELAY_WS_URL=ws://localhost:4001`,
);
}
return { api: API!, ws: WS!, credential: CREDENTIAL! };
}
describe("integrating with Relay from the outside", () => {
let api: string;
let ws: string;
let credential: string;
let channelId: string;
let token: string;
const post = async (path: string, body: unknown, auth: string) => {
const res = await fetch(`${api}${path}`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${auth}` },
body: JSON.stringify(body),
});
return { status: res.status, body: (await res.json()) as Record<string, unknown> };
};
beforeAll(() => {
({ api, ws, credential } = required());
});
it("reaches the platform at all", async () => {
// Before anything else, and separately, so a platform that is not there says
// so once instead of failing eight times with eight different messages.
const res = await fetch(`${api}/healthz`);
expect(res.status, `no healthy api at ${api}`).toBe(200);
});
it("creates a channel, and creating it twice is not an error", async () => {
const external = `outsider-${Date.now()}`;
const first = await post("/v1/channels", { external_id: external, type: "public" }, credential);
expect(first.status).toBe(201);
expect(first.body["external_id"]).toBe(external);
channelId = first.body["id"] as string;
// The documentation says a repeat returns the existing channel. 200 rather
// than 201 is how a client tells which happened without reading the body.
const again = await post("/v1/channels", { external_id: external, type: "public" }, credential);
expect(again.status).toBe(200);
expect(again.body["id"]).toBe(channelId);
});
it("creates a PRIVATE channel, which the route began accepting in the channel-control chapter", async () => {
// THIS TEST WAS RED FOR TWO CHAPTERS AND NOBODY SAW IT (T065).
//
// It asserted `400` with `field: "type"`, which was true when it was written: the
// create route took `public` only. The channel-control chapter (`43899e3`, "the private type decides
// something, on every read") widened the enum to `["public","private"]` and this
// suite was not run at that chapter's close — `pnpm test:outsider` is its own lane,
// outside `pnpm test:integration`, so nothing in the twenty-run battery touches it.
//
// The one suite that stands for an external developer was wrong about the API for two
// chapters. That is the outsider milestone's unmet half showing itself: a sealed suite proves
// nothing about the documentation if nobody runs it.
const res = await post(
"/v1/channels",
{ external_id: `outsider-private-${Date.now()}`, type: "private" },
credential,
);
expect(res.status).toBe(201);
expect(res.body["type"]).toBe("private");
});
it("adds two members, creating the users on first membership", async () => {
const res = await post(
`/v1/channels/${channelId}/members`,
{ user_ids: ["ana", "ben"] },
credential,
);
expect(res.status).toBe(200);
const members = res.body["members"] as { external_id: string; status: string }[];
expect(members.map((m) => m.external_id)).toEqual(["ana", "ben"]);
expect(members.every((m) => m.status === "added")).toBe(true);
});
it("mints a token for one of those members", async () => {
const res = await post("/auth/dev-token", { user: "ana", ttl_seconds: 3600 }, credential);
expect(res.status).toBe(200);
token = res.body["token"] as string;
expect(typeof token).toBe("string");
});
it("creates a bot, because a key send must name one", async () => {
// FOLLOWED FROM THE README, which says an application key carries no user of its own
// and may name only a bot — and that `kind` and `description` travel together. This
// suite is sealed from workspace code, so what it knows is what the documentation
// says.
const res = await post(
"/v1/users",
{
users: [
{
external_id: "outside-bot",
display_name: "Outside Bot",
kind: "bot",
description: "the outsider's own software, posting from a script",
},
],
},
credential,
);
expect(res.status).toBe(200);
const data = res.body["data"] as {
external_id: string;
kind: string;
description: string;
}[];
expect(data[0]).toMatchObject({
external_id: "outside-bot",
kind: "bot",
description: "the outsider's own software, posting from a script",
});
});
it("refuses a send that names nobody, and says which field", async () => {
// The refusal an integrator meets first if they skip the step above. Worth asserting
// from out here: a 400 that did not name the field would leave a developer guessing,
// and the README promises this one.
const res = await post(
`/v1/channels/${channelId}/messages`,
{ text: "who is this from?" },
credential,
);
expect(res.status).toBe(400);
expect(res.body["field"]).toBe("user");
});
it("refuses a send that names a person, with its own code", async () => {
// "ana" was created by the member-add above, so she is a PERSON. A key may not post
// as her — and the code is specific rather than a generic 403, which is what tells an
// integrator to create a bot instead of to go looking for a permission.
const res = await post(
`/v1/channels/${channelId}/messages`,
{ text: "posting as a human", user: "ana" },
credential,
);
expect(res.status).toBe(403);
expect(res.body["code"]).toBe("sender_not_permitted");
});
it("sends a message over REST and reads it back from history", async () => {
const text = `from the outside ${Date.now()}`;
const sent = await post(
`/v1/channels/${channelId}/messages`,
{ text, user: "outside-bot" },
credential,
);
expect(sent.status).toBe(201);
// The response echoes the sender it recorded, which the README promises.
expect(sent.body["user"]).toBe("outside-bot");
const history = await fetch(`${api}/v1/channels/${channelId}/messages?limit=10`, {
headers: { authorization: `Bearer ${credential}` },
});
expect(history.status).toBe(200);
const page = (await history.json()) as { messages: { text: string }[] };
expect(page.messages.map((m) => m.text)).toContain(text);
});
// WAS `it.fails` FOR THE LENGTH OF THIS CHAPTER'S PHASE 1 AND 2.
//
// A red lane is not the same as a recorded failure, so the gap was asserted
// rather than left broken: 10,114 ms to the deadline having seen only
// `connection.ack`, with a 201 in hand. The publish landed in Phase 3 and this
// became a plain `it` — the body now succeeds in about 150 ms.
it("receives a message on a socket — sent over REST", async () => {
// THE SEND NO LONGER HAS TO BE ON THE SOCKET, and that is this chapter.
//
// The gap this exercise recorded had TWO causes. The sender chapter removed the first:
// a public send attributes a sender, so the row is no longer dropped from a
// resume. The fan-out chapter removes the second, which was the whole of what remained
// — the api published to no fan-out, so a REST-sent message reached no live
// socket. The title of this test used to say "SENT over the socket" in capitals,
// because a REST send could not work; it now sends over REST on purpose.
//
// The send is the one an integrating developer's backend actually makes.
const socket = new WebSocket(`${ws}/v1/ws?token=${token}`);
const frames: { type: string; payload?: { text?: string; seq?: number } }[] = [];
// Listeners attached BEFORE the open await. `connection.ack` arrives the
// instant the upgrade completes, and awaiting `open` first yields to the event
// loop — the frame lands with no listener and is gone.
socket.addEventListener("message", (event) => {
frames.push(JSON.parse(String(event.data)) as { type: string });
});
socket.addEventListener("error", () => undefined);
await new Promise<void>((resolve, reject) => {
socket.addEventListener("open", () => resolve());
socket.addEventListener("close", (event) =>
reject(new Error(`closed ${(event as CloseEvent).code}`)),
);
setTimeout(() => reject(new Error(`no socket at ${ws} within 10s`)), 10_000);
});
const waitFor = async (predicate: (f: { type: string }) => boolean, what: string) => {
const deadline = Date.now() + 10_000;
for (;;) {
const found = frames.find(predicate);
if (found) return found;
if (Date.now() > deadline) {
throw new Error(`no ${what}; saw ${frames.map((f) => f.type).join(", ") || "nothing"}`);
}
await new Promise((r) => setTimeout(r, 50));
}
};
await waitFor((f) => f.type === "connection.ack", "connection.ack");
const text = `over REST ${Date.now()}`;
// NOT `socket.send`. A POST, with the credential a customer's server holds, to
// the route their backend calls — and then the socket is watched for the frame.
// `user: "outside-bot"` is not optional and not decoration. The sender chapter made an
// application credential speak only as a bot user of its tenant, so a POST without
// it is a 400 naming `user` — which is how the first run of this inverted test
// failed, for a reason that had nothing to do with delivery.
const posted = await post(
`/v1/channels/${channelId}/messages`,
// A UUID, because the REST body demands one: `idempotency_key: z.string().uuid()`
// on this route, where the socket frame's `idem_key` is any string up to 255.
// Two entrances, two idempotency contracts — the second run of this inverted
// test failed on it, with `invalid_request` naming the field.
{ text, user: "outside-bot", idempotency_key: randomUUID() },
credential,
);
expect(posted.status).toBe(201);
// The REST response is the acknowledgement — there is no `message.ack` frame on
// this path, because the sender is not holding a socket. What has to arrive is
// the delivery, on a socket that was already open before the send.
await waitFor(
(f) => f.type === "message.created" && (f as { payload?: { text?: string } }).payload?.text === text,
"message.created for the text just sent",
);
socket.close();
});
/** T033. ATTACHMENTS THROUGH THE SHIPPED BINARY.
*
* This file is the only instrument in the repository that boots what customers run and
* drives it the way they do — Node's global `WebSocket`, no workspace import, the REST
* credential a customer's server holds. The revisions chapter's plan scheduled a title audit
* over this file and no task wrote to it; this chapter writes.
*
* TWO ATTACHMENTS AND THE ORDER, for the reason every other test in this chapter gives:
* one cannot show an order, and FR-006 says order holds on every path that returns a
* message. */
it("delivers two attachments to a socket, in order, sent over REST", async () => {
const socket = new WebSocket(`${ws}/v1/ws?token=${token}`);
const frames: { type: string; payload?: { text?: string; attachments?: { url?: string }[] } }[] =
[];
socket.addEventListener("message", (event) => {
frames.push(JSON.parse(String(event.data)) as { type: string });
});
socket.addEventListener("error", () => undefined);
await new Promise<void>((resolve, reject) => {
socket.addEventListener("open", () => resolve());
socket.addEventListener("close", (event) =>
reject(new Error(`closed ${(event as CloseEvent).code}`)),
);
setTimeout(() => reject(new Error(`no socket at ${ws} within 10s`)), 10_000);
});
const waitFor = async (predicate: (f: { type: string }) => boolean, what: string) => {
const deadline = Date.now() + 10_000;
for (;;) {
const found = frames.find(predicate);
if (found) return found;
if (Date.now() > deadline) {
throw new Error(`no ${what}; saw ${frames.map((f) => f.type).join(", ") || "nothing"}`);
}
await new Promise((r) => setTimeout(r, 50));
}
};
await waitFor((f) => f.type === "connection.ack", "connection.ack");
const text = `with pictures ${Date.now()}`;
const posted = await post(
`/v1/channels/${channelId}/messages`,
{
text,
user: "outside-bot",
idempotency_key: randomUUID(),
attachments: [
{ type: "url", kind: "image", url: "https://example.test/outside-first.png" },
{ type: "url", kind: "video", url: "https://example.test/outside-second.mp4" },
],
},
credential,
);
expect(posted.status).toBe(201);
const delivered = (await waitFor(
(f) =>
f.type === "message.created" &&
(f as { payload?: { text?: string } }).payload?.text === text,
"message.created carrying the attachments",
)) as { payload: { attachments: { url?: string }[] } };
expect(delivered.payload.attachments.map((a) => a.url)).toEqual([
"https://example.test/outside-first.png",
"https://example.test/outside-second.mp4",
]);
socket.close();
});
/** T100a — **the first `socket.send` in this file's history.**
*
* `grep -c "\.send(" packages/outsider/src/integrate.itest.ts` read **0** across
* eleven tests before this one: ten REST, and one socket test whose title says
* "sent over REST" because the fan-out chapter corrected it. This file is the only
* check in the repository that uses the public surface as a customer does —
* Node's global `WebSocket`, no workspace import — and until now it had never
* exercised the inbound seam at all.
*
* That matters for this chapter in particular: **every other check on the
* inbound frame is in-workspace, using the `ws` package this file refuses to
* import.** A protocol a customer cannot drive is a protocol nobody has tested
* from outside. */
it("says it is typing, and a second member's socket hears it", async () => {
const second = await post("/auth/dev-token", { user: "ben", ttl_seconds: 3600 }, credential);
expect(second.status).toBe(200);
const benToken = second.body["token"] as string;
const open = async (
forToken: string,
): Promise<{
socket: WebSocket;
frames: { type: string; payload?: { channel?: string; user?: string } }[];
}> => {
const socket = new WebSocket(`${ws}/v1/ws?token=${forToken}`);
const frames: { type: string; payload?: { channel?: string; user?: string } }[] = [];
socket.addEventListener("message", (event) => {
frames.push(JSON.parse(String(event.data)) as { type: string });
});
socket.addEventListener("error", () => undefined);
await new Promise<void>((resolve, reject) => {
socket.addEventListener("open", () => resolve());
setTimeout(() => reject(new Error(`no socket at ${ws} within 10s`)), 10_000);
});
return { socket, frames };
};
const until = async (
frames: { type: string; payload?: { channel?: string; user?: string } }[],
predicate: (f: { type: string; payload?: { channel?: string; user?: string } }) => boolean,
what: string,
): Promise<void> => {
const deadline = Date.now() + 10_000;
for (;;) {
if (frames.some(predicate)) return;
if (Date.now() > deadline) {
throw new Error(`no ${what}; saw ${frames.map((f) => f.type).join(", ") || "nothing"}`);
}
await new Promise((r) => setTimeout(r, 50));
}
};
const ana = await open(token);
const ben = await open(benToken);
await until(ana.frames, (f) => f.type === "connection.ack", "ana's ack");
await until(ben.frames, (f) => f.type === "connection.ack", "ben's ack");
ana.socket.send(JSON.stringify({ type: "typing.send", payload: { channel: channelId } }));
await until(
ben.frames,
(f) => f.type === "typing" && f.payload?.channel === channelId && f.payload?.user === "ana",
"a typing frame naming ana",
);
// And the signaller hears nothing of their own — checked here rather than only
// in-workspace, because it is the half a customer would notice.
expect(ana.frames.filter((f) => f.type === "typing")).toEqual([]);
ana.socket.close();
ben.socket.close();
});
/** T100b — the refusal, from outside.
*
* `docs/08-error-reference.md` tells a customer *"send `message.send` … Do not
* send events; receive them."* **Nothing had ever checked what happens when they
* do.** This is that correction in bytes rather than in prose. */
it("holds five connections and is refused a sixth with 4004 (FR-RTM-09)", async () => {
// T048. **THE ONLY INSTRUMENT THAT BOOTS THE SHIPPED BINARY**,
// and the reason this task is a plan requirement rather than a polish item.
//
// The typing chapter built a module, awaited its `close()` so lint saw a used
// variable, and never passed it to `attachSessions`. The feature was inert in
// the product while 1,174 coverage tests and 174 gateway integration tests
// were green — `**/main.ts` is excluded from the ratchet, so no number could
// have shown it — and this file is what found it. A chapter that adds an
// argument to `attachSessions` owes a test here.
//
// Nothing in this file is stubbed: the api and the gateway are the built
// artifacts, the token came from the real dev-token endpoint, and the socket
// is a browser `WebSocket`.
const sockets: WebSocket[] = [];
const openOne = async (): Promise<WebSocket> => {
const socket = new WebSocket(`${ws}/v1/ws?token=${token}`);
sockets.push(socket);
socket.addEventListener("error", () => undefined);
await new Promise<void>((resolve, reject) => {
socket.addEventListener("open", () => resolve());
socket.addEventListener("close", (event) =>
reject(new Error(`closed ${(event as CloseEvent).code}`)),
);
setTimeout(() => reject(new Error(`no socket at ${ws} within 10s`)), 10_000);
});
return socket;
};
try {
for (let i = 0; i < 5; i += 1) await openOne();
const sixth = new WebSocket(`${ws}/v1/ws?token=${token}`);
sockets.push(sixth);
const frames: { type: string; payload?: { code?: string } }[] = [];
sixth.addEventListener("message", (event) => {
frames.push(JSON.parse(String(event.data)) as { type: string });
});
sixth.addEventListener("error", () => undefined);
const code = await new Promise<number>((resolve, reject) => {
sixth.addEventListener("close", (event) =>
resolve((event as CloseEvent).code),
);
setTimeout(() => reject(new Error("the sixth was not closed within 10s")), 10_000);
});
// The code a client branches on, and the frame that carries the detail.
expect(code).toBe(4004);
expect(frames.find((f) => f.type === "error")?.payload?.code).toBe(
"connection_limit_reached",
);
} finally {
for (const socket of sockets) socket.close();
}
}, 60_000);
it("is refused with unknown_frame_type for a frame only the server may send", async () => {
const socket = new WebSocket(`${ws}/v1/ws?token=${token}`);
const frames: { type: string; payload?: { code?: string } }[] = [];
socket.addEventListener("message", (event) => {
frames.push(JSON.parse(String(event.data)) as { type: string });
});
socket.addEventListener("error", () => undefined);
const closed = new Promise<number>((resolve) => {
socket.addEventListener("close", (event) => resolve((event as CloseEvent).code));
});
await new Promise<void>((resolve, reject) => {
socket.addEventListener("open", () => resolve());
setTimeout(() => reject(new Error(`no socket at ${ws} within 10s`)), 10_000);
});
// `message.ack` is the server's word. A client sending it is claiming to be the
// server, which is a protocol violation rather than a malformed frame.
socket.send(JSON.stringify({ type: "message.ack", payload: { seq: 1 } }));
const deadline = Date.now() + 10_000;
for (;;) {
const error = frames.find((f) => f.type === "error");
if (error) {
expect(error.payload?.code).toBe("unknown_frame_type");
break;
}
if (Date.now() > deadline) throw new Error("no error frame within 10s");
await new Promise((r) => setTimeout(r, 50));
}
expect(await closed).toBe(4002);
});
/** An edit, over the shipped binary, seen on somebody else's socket.
*
* **WRITTEN BECAUSE THIS FILE IS THE ONLY THING THAT BOOTS THE PRODUCT.** CLAUDE.md
* records what that bought: the typing chapter built a module, awaited its `close()`, never
* passed it to `attachSessions`, and shipped it inert past 1,174 coverage tests and
* 174 gateway integration tests. This file found it. The rule it left behind — a
* chapter that adds an argument to `attachSessions` owes an outsider test — applies
* here for the same reason one level out: the revisions chapter adds a second Redis subject, a second
* callback on the fan-out and a second frame kind, and every in-workspace test of
* that path uses a stub fan-out or the `ws` package this file refuses to import.
*
* **NO TASK CREATED THIS TEST.** T090 lists this file among "eleven files this
* chapter adds tests to" and nothing in the plan added one; the audit task was
* scheduled over work no task did. `baseline.txt` records it.
*
* What it proves that nothing else does: the api's `publishRevision` reaches a real
* Redis, on the subject ADR-24 took, and a real gateway process routes it by prefix
* to a real socket as `message.updated` — not as `message.created`, which is the
* failure the whole ADR exists to prevent and which no shape check can see, because
* the updated arm's payload IS a `Message`. */
it("edits a message over REST, and a member's socket hears message.updated exactly once, with no second creation", async () => {
const minted = await post(
"/auth/dev-token",
{ user: "watcher", ttl_seconds: 3600 },
credential,
);
expect(minted.status).toBe(200);
const token = minted.body["token"] as string;
// The watcher has to be a member to be delivered to — the channel is public, so
// this is about subscription rather than permission.
const joined = await post(
`/v1/channels/${channelId}/members`,
// `user_ids`, and it takes a LIST. The first draft posted `{ user: "watcher" }`
// and got a 400 — `addMembersBodySchema` is a `strictObject` over
// `user_ids: [...]`, and the entry may be a bare identifier or an object with a
// role. An outsider test guessing a body shape is the whole reason this file
// exists; two earlier tests in it were written twice for the same reason.
{ user_ids: ["watcher"] },
credential,
);
expect([200, 201]).toContain(joined.status);
const socket = new WebSocket(`${ws}/v1/ws?token=${token}`);
const frames: Array<{ type: string; payload?: Record<string, unknown> }> = [];
socket.addEventListener("message", (event) => {
frames.push(JSON.parse(String(event.data)) as { type: string });
});
socket.addEventListener("error", () => undefined);
const waitFor = async (
predicate: (f: { type: string; payload?: Record<string, unknown> }) => boolean,
what: string,
) => {
const deadline = Date.now() + 10_000;
for (;;) {
const found = frames.find(predicate);
if (found) return found;
if (Date.now() > deadline) {
throw new Error(
`no ${what}; saw ${frames.map((f) => f.type).join(", ") || "nothing"}`,
);
}
await new Promise((r) => setTimeout(r, 50));
}
};
await waitFor((f) => f.type === "connection.ack", "connection.ack");
// SENT BY THE WATCHER'S OWN TOKEN, because only an author may edit (FR-013) and
// the edit route accepts no application credential at all (FR-013a). So the send
// uses the token too — a POST with a user token is attributed to its subject and
// must not name a `user` in the body.
const before = `outsider edit ${Date.now()}`;
const posted = await fetch(`${api}/v1/channels/${channelId}/messages`, {
method: "POST",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ text: before }),
});
expect(posted.status).toBe(201);
const sent = (await posted.json()) as { id: string; seq: number };
await waitFor(
(f) => f.type === "message.created" && f.payload?.["text"] === before,
"message.created for the text just sent",
);
const after = `${before} (corrected)`;
const edited = await fetch(
`${api}/v1/channels/${channelId}/messages/${sent.id}`,
{
method: "PATCH",
headers: { "content-type": "application/json", authorization: `Bearer ${token}` },
body: JSON.stringify({ text: after }),
},
);
expect(edited.status).toBe(200);
const frame = await waitFor(
(f) => f.type === "message.updated" && f.payload?.["text"] === after,
"message.updated for the corrected text",
);
// THE SEQUENCE IS THE ONE IT HAD (FR-002), on the wire and not only in the row.
expect(frame.payload?.["seq"]).toBe(sent.seq);
expect(frame.payload?.["id"]).toBe(sent.id);
// AND NO SECOND CREATION. This is the assertion ADR-24 is for: route the revision
// to the old callback and the edit arrives as `message.created`, indistinguishable
// from a new message to every client. Counting is what sees it — a `waitFor` that
// resolves on the first match cannot.
expect(frames.filter((f) => f.type === "message.created")).toHaveLength(1);
expect(frames.filter((f) => f.type === "message.updated")).toHaveLength(1);
socket.close();
});
it("cannot see another tenant's channel, and cannot tell it apart from an absent one", async () => {
// The documented isolation property, exercised the only way an outsider can:
// with an id that is well formed and is not theirs. The reference says both
// answer identically, so this checks that rather than taking it on faith.
const nowhere = "00000000-0000-4000-8000-000000000000";
const a = await fetch(`${api}/v1/channels/${nowhere}/messages`, {
headers: { authorization: `Bearer ${credential}` },
});
const b = await fetch(`${api}/v1/webhooks/${nowhere}`, {
headers: { authorization: `Bearer ${credential}` },
});
expect(a.status).toBe(404);
expect(b.status).toBe(404);
for (const res of [a, b]) {
const body = (await res.json()) as Record<string, unknown>;
expect(body["code"]).toBe("not_found");
// A PATH PER CODE, not a fragment on one page. `docsUrl` here is
// `${ERROR_DOCS_BASE}/${code}`; published later moved to an anchor on a single
// error-reference page, which is part of the debt the error-registry chapter
// opens and puts in Part 4. Asserted as this platform actually answers.
expect(String(body["docs_url"])).toContain("/not_found");
// Every error carries one, and it is what a support request quotes.
expect(typeof body["request_id"]).toBe("string");
}
});
});Cái niêm phong được chứng minh là đỏ, từng tầng một — và một dòng thứ tư được thêm vào
khối sau ba dòng kia, bởi khối ấy là khối CUỐI còn một khối cuối chỉ đặt
các pattern của riêng nó thì tắt lệnh cấm driver cho đúng cái gói cần nó
nhất. pg phân giải được ở đây theo phép đi ngược lên cha thông thường dù @relay/* thì không,
và đó cũng là cùng cái cơ chế mà tầng 1 dựa vào:
$ import { ERROR_CODES } from "@relay/protocol"
Error: Cannot find package '@relay/protocol' imported from …/integrate.itest.ts
→ level 1: no rule involved, the module is not there
$ import { ERROR_CODES } from "../../protocol/src/codes.js"
error '../../protocol/src/codes.js' import is restricted from being used by a
pattern. packages/outsider may not reach outside itself no-restricted-imports
$ readFileSync(join(import.meta.dirname, "..", "..", "protocol", "src", "codes.ts"))
error packages/outsider may not build a path out of the package no-restricted-syntax
error packages/outsider may not build a path out of the package no-restricted-syntax
$ createRequire(import.meta.url)
error node:module is only useful here for createRequire, which is banned above
error createRequire turns a computed path into a module no-restricted-syntax
Một thứ để mà tích hợp vào
Suite chẳng khởi động gì. Nên phải có thứ gì đó khởi động nền tảng, và phải có thứ gì đó đưa cho suite một credential — mà chẳng có cách công khai nào để có một cái, bởi việc đăng ký kết thúc ở một màn hình đồng ý OAuth mà chẳng bản tích hợp tự động nào hoàn tất nổi và việc quản lý key thì đã bị hoãn tới chương của dashboard.
// A tenant an outsider can integrate against (FR-032).
//
// The constitution asks that `docker compose up` yield a working local platform
// "including a seeded demo tenant". Nothing seeded one, and until this chapter
// nothing needed to: every suite mints its own environment through the repository
// layer. `packages/outsider` cannot — it is mechanically forbidden from importing
// workspace code, which is the whole point of it — so it needs a credential that
// already exists before it starts.
//
// A SCRIPT AND NOT AN ENDPOINT, and the reason is worth stating rather than
// deferring. Creating an organisation is the sign-up flow's job, and
// the sign-up flow ends at an OAuth consent screen that no automated integration
// can complete. Minting a key is the dashboard's job, which the credentials chapter deferred
// by name. Inventing either as an API for a test would be inventing product — the
// rule chapter 2.8 set for `listMessagesRaw` and every seam since.
//
// RELAY_POSTGRES_PORT=15432 docker compose up -d --wait
// DATABASE_URL=postgres://relay:relay@localhost:15432/relay \
// node services/api/dist/db/migrate.js
// node scripts/seed-demo-tenant.mjs
//
// ORDER IS LOAD-BEARING: this writes rows the api's schema must already accept, so
// the migration comes first. The suite then needs the credential this prints, so
// the seed comes before the suite. Stores, migrations, services, seed, suite.
//
// IDEMPOTENT ON THE NAME. Re-running it is the ordinary case — a developer runs it
// twice, CI runs it once per job — and a second organisation called `demo` with a
// second key would leave two credentials where the printed one is whichever the
// script happened to make last. So an existing demo environment is reused and its
// key is reissued, because a key's plaintext exists only at the moment it is
// minted: the row keeps a hash, by design, so there is nothing to
// print for a key that already exists.
import { createDb, createPool } from "../services/api/dist/db/client.js";
import {
createApiKey,
createEnvironment,
} from "../services/api/dist/db/repository.js";
const NAME = process.env.RELAY_DEMO_TENANT_NAME ?? "demo";
// The POOL for the lookup and the repository's helpers for the writes. Drizzle
// is not importable from here — pnpm's isolated `node_modules` puts it under the
// api's tree, not the workspace root — and the pool is what `createDb` was given
// anyway, so this borrows no dependency the api does not already own.
const pool = createPool();
const db = createDb(pool);
const existing = (
await pool.query(
`SELECT e.id FROM environments e
JOIN applications a ON a.id = e.application_id
JOIN organisations o ON o.id = a.organisation_id
WHERE o.name = $1
ORDER BY a.created_at
LIMIT 1`,
[NAME],
)
).rows;
const environmentId =
existing.length > 0
? existing[0].id
: (await createEnvironment(db, { name: NAME })).id;
const key = await createApiKey(db, { environmentId });
// STDOUT IS THE INTERFACE. A caller in a shell wants the credential and nothing
// else on the pipe, so everything a human wants to read goes to stderr and the
// key goes to stdout on its own line:
//
// RELAY_DEMO_CREDENTIAL=$(node scripts/seed-demo-tenant.mjs)
console.error(
existing.length > 0
? `reusing environment ${environmentId} (organisation "${NAME}")`
: `created organisation "${NAME}", one application, one development environment`,
);
console.error(`environment_id ${environmentId}`);
console.log(key.credential);
process.exit(0); "test": {
"dependsOn": ["^build"],
- "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/compose.yaml"]
+ "inputs": ["$TURBO_DEFAULT$", "$TURBO_ROOT$/compose.yaml"],
+ "env": ["RELAY_DOCS_BASE_URL"]
},
"RELAY_QUOTA_RELAY",
+ "RELAY_DOCS_BASE_URL",
+ "RELAY_API_URL",
+ "RELAY_WS_URL",
+ "RELAY_DEMO_CREDENTIAL"
]- "test:integration": "turbo run test:integration --concurrency=1",
+ "test:integration": "turbo run test:integration --concurrency=1 --filter=!@relay/outsider",
+ "test:outsider": "turbo run test:integration --filter=@relay/outsider",Ba cái cuối đó là các đoạn trích, và lý do là chuỗi fence chứ không phải
sự ngắn gọn. resume.itest.ts, turbo.json và package.json đều có phần sửa đổi trong
fences/post-series.md, thứ mà bộ kiểm áp dụng sau mọi chương — nên một
chương không sửa đổi được một trạng thái mà một file sau đó dựng nên, và nó nói thế rất chính xác: hunk
pre-image matched 0 times. Các phần sửa đổi đầy đủ nằm trong post-series.md; chương này
là nơi chúng được giải thích.
Phán quyết
flowchart TB
crit["Tiêu chí ra khỏi Phase 2 của SRS:<br/>một developer bên ngoài tích hợp<br/>chỉ bằng tài liệu công khai, không ai trợ giúp"]
crit --> met["ĐẠT — đã đo"]
crit --> not["KHÔNG ĐẠT — hai thứ, khác loại nhau"]
met --> m1["8 test, một lượt tích hợp đầy đủ<br/>vào một stack mà nó không tự khởi động"]
met --> m2["niêm phong ba lớp, mỗi lớp đều được chứng minh"]
met --> m3["một CI job riêng, trên mọi build"]
not --> n1["bộ test được một test fail SỬA LẠI<br/>về đường REST-tới-socket —<br/>đó đúng là sự trợ giúp mà tiêu chí cấm"]
not --> n2["đủ nội dung không phải là dễ hiểu.<br/>Chỉ con người là thiết bị đo được điều đó,<br/>và chương này không dùng một người nào."]
style met fill:#064e3b,color:#fff,stroke:#059669
style n1 fill:#7f1d1d,color:#fff,stroke:#dc2626
style n2 fill:#78350f,color:#fff,stroke:#d97706ĐÁP ỨNG MỘT PHẦN, và phần còn thiếu thì không phải cái phần mà chương này đặt ra để sửa.
Thứ được đáp ứng, và nó được kiểm thế nào. Gói đã niêm phong hoàn tất một bản tích hợp trọn vẹn với một nền tảng mà nó không khởi động: nó tạo một channel, lặp lại lời gọi rồi nhận lại chính cái đã có, tạo một channel riêng tư, thêm hai thành viên chưa từng tồn tại, mint một token cho một trong hai, tạo một bot rồi để một lệnh gửi không có bot bị từ chối với field được gọi tên, gửi qua REST rồi đọc lại history, gửi qua REST rồi nhận event trên một socket, nhận hai attachment đúng thứ tự, nói rằng nó đang gõ rồi được một thành viên thứ hai nghe thấy, giữ năm kết nối rồi bị từ chối cái thứ sáu với 4004, có một frame chỉ-dành-cho-server bị từ chối đích danh, và xác nhận rằng một tài nguyên lạ và một tài nguyên không tồn tại trả lời y hệt nhau. Mười sáu bài test, tất cả đều xanh, trong CI như một job riêng của nó.
VÀ CÁI LỖ HỔNG MÀ CỘT MỐC NÀY NỔI TIẾNG VÌ NÓ THÌ ĐÃ ĐƯỢC ĐÓNG — BỞI LẦN SẮP XẾP LẠI, KHÔNG PHẢI BỞI CHƯƠNG NÀY. Lần chạy đầu tiên của bài tập ghi lại rằng một message gửi bằng REST chẳng tới được socket sống nào: một bản tích hợp gửi qua REST rồi chờ trên một socket thì không thể thành công, và chẳng tài liệu nào nói ra điều đó. Đó là hai cái lỗi đội chung một triệu chứng. Chương sender gỡ bỏ cái thứ nhất, nên một lệnh gửi công khai giờ ghi danh một người gửi và cái dòng sống sót qua một lần resume; chương message-delivery gỡ bỏ cái thứ hai, và đó là toàn bộ phần còn lại — api chẳng publish vào fan-out nào cả.
Trong thứ tự đã xuất bản thì cả hai cách sửa đều tới SAU bài tập này, nên cột mốc báo cái lỗ hổng rồi lên lịch cho nó. Giờ cả hai đều nằm sau chương này. Bài test từng viết "GỬI qua socket" bằng chữ in hoa, bởi một lệnh gửi REST không chạy được, giờ gửi qua REST một cách có chủ đích — đúng cái lệnh gửi mà backend của một lập trình viên đang tích hợp thực sự thực hiện.
Đó chính là thứ mà một thứ tự theo chủ đề mua được, nói thẳng thừng nhất có thể: cùng một suite, cùng một nền tảng, và một phán quyết tốt lên bởi cái chương đo một lỗ hổng thì không còn chạy trước các chương đóng nó.
Thứ không được đáp ứng. Một thứ, và chẳng bài test nào với tới được. Nội dung đủ đầy thì không phải là dễ hiểu. Chương này đo xem tài liệu có chứa những gì một bản tích hợp cần hay không. Liệu một con người đọc nó mà không cần trợ giúp có dựng được một bản tích hợp hay không là một câu hỏi khác, và một con người là cân đo duy nhất cho nó.
Cùng sự phân biệt ấy áp cho cái niêm phong. Các quy tắc phụ thuộc thì cơ học: code trong workspace là không import được, chứng minh được, theo ba cách. Việc không đọc mã nguồn của repository thì là một kỷ luật, và chẳng cấu hình nào thực thi được nó. Ba quy tắc không được để ngụ ý ra một quy tắc thứ tư.