Phần 3 · Chương 3.25
Bạn sẽ tạo ra: Một package tích hợp bị niêm phong về mặt cơ chế nên không thể import code trong workspace, và một phán quyết cho tiêu chí ra khỏi Phase 2 của SRS kèm những gì đã đo và những gì chỉ được giả định · khoảng 31 phút, bao gồm bài tập
Tài liệu gốc: SRS — Đặc tả yêu cầu phần mềm (tiếng Anh)
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í ra đòi một bản tích hợp được dựng chỉ từ tài liệu công khai. Tuyên bố thì dễ; tuyên bố ấy vô giá trị trừ khi cái thứ đưa ra nó không thể đọc source của platform. Nên nó là một package không thể đọ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 { 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("refuses a private channel, naming the field", async () => {
// Documented behaviour, not a guess: the reference says `type` accepts
// `public` and the error names the offending key. An integration that reads
// the reference should be able to rely on both.
const res = await post(
"/v1/channels",
{ external_id: `outsider-private-${Date.now()}`, type: "private" },
credential,
);
expect(res.status).toBe(400);
expect(res.body["code"]).toBe("invalid_request");
expect(res.body["field"]).toBe("type");
// And the docs_url is a URL, with the code as its fragment.
expect(String(res.body["docs_url"])).toContain("#invalid_request");
});
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("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 }, credential);
expect(sent.status).toBe(201);
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);
});
it("receives a message on a socket — SENT over the socket", async () => {
// THE SEND HAS TO BE ON THE SOCKET, and finding that out is one of the gaps
// this exercise recorded. A message sent over `POST /v1/channels/:id/messages`
// reaches no socket at all: the api publishes to no fan-out, and the public
// send attributes no user, so the row is dropped from resume for having no
// sender. Nothing in the published documentation said so.
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 the socket ${Date.now()}`;
socket.send(
JSON.stringify({
type: "message.send",
payload: { idem_key: `outsider-${Date.now()}`, channel: channelId, text },
}),
);
// The sender's own acknowledgement, then the event. Both are documented and
// both matter: the ack says it was committed, the event says it was delivered.
await waitFor((f) => f.type === "message.ack", "message.ack");
await waitFor(
(f) => f.type === "message.created" && (f as { payload?: { text?: string } }).payload?.text === text,
"message.created for the text just sent",
);
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");
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");
}
});
});Lớp niêm phong được chứng minh là chặn được, từng cấp một:
$ 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
Bộ test không khởi động gì. Nên phải có thứ gì khởi động platform, và phải có thứ gì đưa cho bộ test một credential — mà không có cách công khai nào lấy được một cái, bởi sign-up kết thúc ở một màn hình đồng thuận OAuth mà không bản tích hợp tự động nào hoàn thành được, còn việc quản lý key thì đã được gác lại cho 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à đoạn trích, và lý do là fence chain chứ không phải sự ngắn gọn.
resume.itest.ts, turbo.json và package.json đều có bản vá trong
fences/post-series.md, thứ mà checker áp dụng sau mọi chương — nên một chương
không thể vá một trạng thái mà một file sau nó mới dựng lên, và nó nói rất chính xác:
hunk pre-image matched 0 times. Các bản vá đầy đủ nằm trong post-series.md; chương
này là nơi chúng được giải thích.
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ĐẠT MỘT PHẦN, và phần còn thiếu không phải phần mà chương này đặt ra để vá.
Phần đạt, và cách nó được kiểm. Package niêm phong hoàn thành một lượt tích hợp đầy đủ vào một platform mà nó không khởi động: nó tạo một channel, gọi lại lần nữa và nhận về channel đã có, bị từ chối một channel private với field được gọi tên, thêm hai member chưa từng tồn tại, cấp một token cho một trong hai, gửi qua REST rồi đọc lại history, gửi qua socket rồi nhận được event, và xác nhận rằng một resource của người khác và một resource không tồn tại trả lời y hệt nhau. Tám test, tất cả xanh, chạy trong CI như một job riêng.
Phần không đạt. Hai thứ, khác loại nhau.
Thứ nhất là khoảng hở REST-tới-socket mà chương 3.13 ghi lại. Một bản tích hợp gửi qua REST rồi chờ trên socket thì không thể thành công, và không tài liệu nào nói thế. Bộ test xanh vì nó được một test fail sửa lại — mà đó đúng là sự trợ giúp mà tiêu chí cấm. Một người ngoài thật sẽ mở một bug hoặc bỏ cuộc.
Thứ hai là nửa khó hơn của tiêu chí, và không test nào với tới được. Đủ nội dung 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. Việc một người đọc nó mà không ai 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à chỉ con người là thiết bị đo được nó.
Sự phân biệt ấy cũng áp cho lớp niêm phong. Các rule về dependency là cơ chế: code trong workspace là không-thể-import, chứng minh được, theo ba đường. Việc không đọc source của repository là một kỷ luật, và không cấu hình nào thực thi được nó. Ba rule không được để ngụ ý cái thứ tư.