Part 3 · Chapter 3.25
You will produce: Three deliberate reintroductions — one of which stayed green and taught the suite's range — and tests for the instruments that had never produced output · about 13 minutes including the exercise
Source: SRS — Software Requirements Specification · SAD — Software Architecture Document
A suite that has never failed is an untested test. Three deliberate reintroductions, and the first one is the reason this section is worth reading.
Reintroduction 1. Drop environment_id from listMessages' scoping helper —
a repository SELECT on the history read path. Run the gauntlet. Seventy-one of
seventy-one passed.
flowchart TB
attack["GET /v1/channels/:id/messages<br/>with another tenant's channel id"]
attack --> exists["channelExists(id)<br/>SCOPED — refuses here"]
exists --> four["404, identical to an absent id"]
exists -. never reached .-> list["listMessages(id)<br/>scope REMOVED for the experiment"]
list --> leak["would have returned<br/>the other tenant's rows"]
four --> green["the suite stayed GREEN.<br/>21 of 21."]
green --> lesson["sensitive to the OUTERMOST check;<br/>blind to an inner one a live outer check masks"]
style green fill:#78350f,color:#fff,stroke:#d97706
style lesson fill:#1e3a8a,color:#fff,stroke:#3b82f6
style leak fill:#7f1d1d,color:#fff,stroke:#dc2626That is not a defect in the reintroduction and it was not reshaped until it failed.
It is a fact about the suite's range: GET /v1/channels/:channelId/messages refuses
a foreign id before listMessages is reached at all.
And the second attempt found nothing either, which is the sentence worth having.
The obvious next move is to unscope the predicate above it — channelExists, the
one chapter 2.8 added when the two doors disagreed. Dropping its scope too: still
seventy-one of seventy-one. channelExists is no longer on this path. The
channel-control chapter replaced existence with VISIBILITY, because an absent
channel answered 404 while a private channel a non-member read answered 200 with an
empty page, and one predicate now produces both refusals.
So the mask is channelVisibleTo, and it is scoped. Unscope THAT and five
assertions fire at once:
× GET /v1/channels/:channelId/messages — a foreign channel reads as an absent one
× GET .../messages/:messageId/edits — a foreign message's history reads as an absent one
× PATCH .../messages/:messageId — a foreign message is not edited, and says so like an absent one
× DELETE .../messages/:messageId — a foreign message is not tombstoned
× POST /auth/dev-token — a token minted for one environment is refused by another
"status 200 (foreign) vs 404 (absent)"
"body {"messages":[{"id":"c80ae99d-…","user":"victim-mtv023g0-user",
"text":"victim-mtv023g0 says something"…}]} (foreign) vs
{"code":"not_found","message":"channel not found"…} (absent)"
Five, not one. channelVisibleTo sits under four routes — the message read, the
edit, the deletion and the edit history — so one unscoped SELECT is four leaks. The
fifth is the credential attack, which mints a token in the attacker's environment and
then presents it against the victim's channel: its ORACLE is a message read, so it
inherits the leak from the route it probes with.
That last one is worth noticing. A suite of independent attacks is not independent where two of them share a target, and the count of red assertions is a count of assertions rather than of faults — there is one fault here, reported five ways.
Reintroduction 2 is where the write shape earned its keep. Drop
environment_id from one UPDATE — setEndpointEnabled's — and leave every read
scoped:
× POST /v1/webhooks/:id/disable — refused on a foreign endpoint, and nothing moves
AssertionError: the victim's endpoints moved: expected true to be false
differences stayed empty. The endpoint answered 404 to both halves of the
pair, because the read after the update is still scoped — so the response was
indistinguishable and the write went through anyway. A suite comparing only
responses passes this.
Reintroduction 3 taught the shape of the fault rather than confirming a guess. The task said "change one endpoint's 404 to a 403" — and doing that at the route level moves both halves of the pair to 403, which the oracle cannot see by construction. To make them differ, the code has to learn something the tenant scope hides:
// services/api/src/webhooks/webhooks.service.ts — DELIBERATELY UNTITLED.
// The fence chain replays every titled fence onto the repository, and this code
// was reverted the moment it was measured. A title here would put a
// reintroduction into the canonical tree, which is the one thing FR-015 forbids.
if (!row) {
// T066's REINTRODUCTION — reverted immediately after measuring.
if (await this.repo.anyEndpointExists(id)) {
throw new ForbiddenException("that endpoint belongs to another environment");
}
throw new NotFoundException("no such webhook endpoint");
}× POST /v1/webhooks/:id/enable — refused on a foreign endpoint, and nothing moves
× POST /v1/webhooks/:id/disable — refused on a foreign endpoint, and nothing moves
"status 403 (foreign) vs 404 (absent)"
Two, because both routes are one method: setEnabled(id, enabled) serves the enable
and the disable, so a fault planted in it is visible from either door.
Breaking indistinguishability requires an unscoped read. That is a more useful sentence than "the suite caught a 403", and it came out of the reintroduction going differently than planned.
Every reintroduction was reverted with git checkout against a committed tree,
which is the practice this project writes down and the reason the first one cost
five minutes instead of an afternoon.
Coverage put a number on something that reads as a paradox. Three of this chapter's new files sat well below the rest, and every uncovered arm was in code that only runs when the platform is broken.
attack.ts measured 61% branches because every assertion in the gauntlet asserts
that differences is empty — so the code that builds a difference string runs
only when something has leaked. targets.ts measured 50% because a real Nest
application has exactly one router shape, so the fallbacks for the others are
unreachable there. catalogue.ts measured 87.5% because the arm that returns
null — the unclassified table — cannot execute against a database that has no
unclassified table, which is the state the check exists to keep.
That is this chapter's own argument one layer down: an instrument that has never produced output has never had its output checked.
import { afterEach, describe, expect, it, vi } from "vitest";
import { comparePair, credentialAttack, rowsOf } from "./attack";
// The oracle's REPORTING arm, which a passing gauntlet cannot reach.
//
// Every assertion in `gauntlet.itest.ts` asserts that `differences` is empty, so
// the code that builds a difference string only ever runs when the platform is
// broken. That is the same problem Phase 7's reintroductions solve for the suite
// as a whole, one layer down: an instrument that has never produced output has
// never had its output checked.
const answer = (status: number, body: unknown) => ({ status, body });
describe("comparing a foreign answer against an absent one", () => {
it("reports nothing when both agree", () => {
expect(comparePair(answer(404, { code: "not_found" }), answer(404, { code: "not_found" }))).toEqual(
[],
);
});
it("ignores request_id, which differs on every request by design", () => {
expect(
comparePair(
answer(404, { code: "not_found", request_id: "a" }),
answer(404, { code: "not_found", request_id: "b" }),
),
).toEqual([]);
});
it("names the statuses when they differ", () => {
const [first] = comparePair(answer(403, {}), answer(404, {}));
expect(first).toBe("status 403 (foreign) vs 404 (absent)");
});
it("names both bodies when they differ", () => {
const differences = comparePair(answer(404, { code: "forbidden" }), answer(404, { code: "not_found" }));
expect(differences).toHaveLength(1);
expect(differences[0]).toContain("forbidden");
expect(differences[0]).toContain("not_found");
});
it("reports both when both differ", () => {
expect(comparePair(answer(200, { messages: [1] }), answer(404, { code: "not_found" }))).toHaveLength(
2,
);
});
it("compares a non-object body without throwing", () => {
// `withoutRequestId` returns a non-object unchanged, and an html error page or
// an empty string is a real answer a misconfigured route can give.
expect(comparePair(answer(502, "bad gateway"), answer(404, ""))).toHaveLength(2);
});
});
describe("counting the rows in a list answer", () => {
it("reads a bare array", () => {
expect(rowsOf([1, 2, 3])).toEqual([1, 2, 3]);
});
it("reads a paginated envelope", () => {
expect(rowsOf({ data: [1, 2], next_cursor: "x" })).toEqual([1, 2]);
});
it("returns nothing for a shape it does not recognise", () => {
// The arm that matters. Zero rows from an unknown shape looks exactly like
// zero rows from a correctly-scoped list, and only one of those is a pass.
expect(rowsOf({ code: "not_found" })).toEqual([]);
expect(rowsOf(null)).toEqual([]);
expect(rowsOf("an html error page")).toEqual([]);
expect(rowsOf({ data: "not an array" })).toEqual([]);
});
});
describe("a credential attack that could not even mint", () => {
// THE ARM THAT REPORTS A FAILED MINT, which is the third instrument in this file
// that a healthy lane never reaches. `credentialAttack` asks the api for a token
// with the ATTACKER's own credential — a legitimate request that succeeds every
// time the platform is up — and only then presents it against the victim.
//
// WHY IT MATTERS THAT THIS ARM IS RIGHT. If a broken mint returned the shape of a
// success, every credential attack in the gauntlet would read `minted: true` and
// compare a token that does not exist against a route that refused it for the wrong
// reason. The suite would be green and would be asserting nothing — which is the
// failure this whole chapter is about, arriving through the instrument rather than
// through the platform.
//
// Driven with a stubbed `fetch`, because the only way to fail a mint against a live
// api is to break the api.
afterEach(() => {
vi.unstubAllGlobals();
});
it("reports the mint's own status and body, and does not claim a token", async () => {
vi.stubGlobal(
"fetch",
vi.fn(async () =>
Promise.resolve({
ok: false,
status: 401,
json: async () => Promise.resolve({ code: "unauthorized" }),
}),
),
);
const verdict = await credentialAttack("http://unused.invalid", "rk_dev_bad", "mai", {
method: "GET",
path: "/v1/channels/whatever",
});
expect(verdict.minted, "a failed mint reported as a successful one").toBe(false);
expect(verdict.crossStatus).toBe(401);
expect(verdict.crossBody).toEqual({ code: "unauthorized" });
});
});import { describe, expect, it } from "vitest";
import { deriveTargets } from "./targets";
// THE DERIVATION'S SHAPE-HANDLING, driven with fakes.
//
// `targets.itest.ts` runs `deriveTargets` against a real Nest application, which
// is the assertion that matters — and a real application has exactly one router
// shape, so the fallbacks for the others are unreachable there. Express 4 exposed
// `_router`, Express 5 exposes `router`, and a future adapter may expose neither;
// the branch that reports `none` is the one a reader of a failure most needs to be
// working, because it is what turns "no routes found" into "no router found".
const route = (path: string, methods: Record<string, boolean>) => ({ route: { path, methods } });
describe("deriving targets from whatever the adapter exposes", () => {
it("reads Express 5's `router`", () => {
const result = deriveTargets({
router: { stack: [route("/v1/x", { get: true })] },
});
expect(result.property).toBe("router");
expect(result.targets).toEqual([{ method: "GET", path: "/v1/x" }]);
});
it("falls back to Express 4's `_router`", () => {
const result = deriveTargets({
_router: { stack: [route("/v1/y", { post: true })] },
});
expect(result.property).toBe("_router");
expect(result.targets).toEqual([{ method: "POST", path: "/v1/y" }]);
});
it("says `none` rather than pretending the surface is empty", () => {
// The distinction the suite depends on: an empty target list from a found
// router is a clean surface, and an empty list from no router at all is a
// broken derivation. Only this field tells them apart.
const result = deriveTargets({});
expect(result.property).toBe("none");
expect(result.targets).toEqual([]);
});
it("counts middleware layers, which have no route", () => {
const result = deriveTargets({
router: { stack: [{}, {}, route("/v1/z", { delete: true })] },
});
expect(result.middlewareLayers).toBe(2);
expect(result.targets).toEqual([{ method: "DELETE", path: "/v1/z" }]);
});
it("takes only the verbs a layer actually answers", () => {
// Express marks every verb on the layer and flags the live ones. Treating the
// keys as the answer would invent a target per HTTP verb per route.
const result = deriveTargets({
router: { stack: [route("/v1/w", { get: true, post: false, put: false })] },
});
expect(result.targets).toEqual([{ method: "GET", path: "/v1/w" }]);
});
it("handles a layer with a route but no methods, and one with no path", () => {
const result = deriveTargets({
router: { stack: [{ route: { path: "/v1/none" } }, { route: { methods: { get: true } } }] },
});
expect(result.targets).toEqual([{ method: "GET", path: "" }]);
});
});import { describe, expect, it } from "vitest";
import { classifyRow, SPINE_TABLES } from "./catalogue";
// The classification's four arms, driven with rows made up here rather than with
// rows a database happens to hold (FR-040).
//
// The one that matters is `null`. It executes only when somebody adds a table with
// no tenant path — which is the state `tenant-scope.itest.ts` exists to prevent —
// so against a healthy database it is the one arm nothing can reach. T042 proved it
// fires by creating a scratch table by hand; this proves it without a database and
// without a hand.
const row = (over: Partial<Parameters<typeof classifyRow>[0]>) => ({
table_name: "made_up",
has_environment_id: false,
fk_targets: null,
...over,
});
describe("a table's tenant path", () => {
it("is direct when it carries environment_id", () => {
expect(classifyRow(row({ has_environment_id: true }))).toEqual({
table: "made_up",
path: "direct",
via: [],
});
});
it("is a hop when a foreign key reaches a direct table", () => {
expect(classifyRow(row({ fk_targets: ["channels", "users"] }))).toEqual({
table: "made_up",
path: "hop",
via: ["channels", "users"],
});
});
it("prefers direct over hop when a table has both", () => {
// The ordering the function's own comment argues for: a spine table that
// gains `environment_id` should report `direct` so its SPINE entry becomes
// visibly wrong rather than quietly ignored.
const both = classifyRow(row({ has_environment_id: true, fk_targets: ["channels"] }));
expect(both.path).toBe("direct");
});
it("is spine for a listed table, and carries its reason", () => {
const result = classifyRow(row({ table_name: "organisations" }));
expect(result.path).toBe("spine");
expect(result.reason).toBeTruthy();
});
it("IS NULL for a table with no path at all", () => {
expect(classifyRow(row({ table_name: "t042_scratch" }))).toEqual({
table: "t042_scratch",
path: null,
via: [],
});
});
it("gives every spine table a reason", () => {
for (const name of SPINE_TABLES) {
expect(classifyRow(row({ table_name: name })).reason, name).toBeTruthy();
}
});
});import { describe, expect, it } from "vitest";
import { withoutRequestId } from "./compare";
// The oracle's own tests. Pure — no database, no HTTP — because the thing being
// checked is one comparison rule, and a rule that needs a stack to test is a rule
// nobody re-reads.
describe("withoutRequestId", () => {
it("makes two bodies differing only in request_id equal", () => {
const foreign = {
code: "not_found",
message: "channel not found",
docs_url: "https://relay.example/docs/errors/not_found",
request_id: "req_aaa",
};
const absent = { ...foreign, request_id: "req_bbb" };
expect(withoutRequestId(foreign)).toEqual(withoutRequestId(absent));
});
it("leaves two bodies differing in message unequal", () => {
// The case that matters: `messages.service.ts` keeps a CONSTANT message for
// exactly this reason — echoing the id back would make the foreign answer
// differ from the absent one, and "different" is itself a disclosure.
const foreign = { code: "not_found", message: "channel abc not found", request_id: "req_a" };
const absent = { code: "not_found", message: "channel not found", request_id: "req_b" };
expect(withoutRequestId(foreign)).not.toEqual(withoutRequestId(absent));
});
it("leaves two bodies differing in code unequal", () => {
const forbidden = { code: "forbidden", message: "no", request_id: "req_a" };
const missing = { code: "not_found", message: "no", request_id: "req_b" };
expect(withoutRequestId(forbidden)).not.toEqual(withoutRequestId(missing));
});
it("passes a non-object body through untouched", () => {
// A 204 has no body and a proxy may hand back a string. Neither is an error
// envelope, and neither should throw on the way to a comparison.
expect(withoutRequestId(null)).toBeNull();
expect(withoutRequestId("gateway timeout")).toBe("gateway timeout");
expect(withoutRequestId(undefined)).toBeUndefined();
});
it("does not mutate its argument", () => {
const body = { code: "not_found", request_id: "req_a" };
withoutRequestId(body);
expect(body.request_id).toBe("req_a");
});
});Closing them meant separating the decision from the transport in three places —
classifyRow from the catalogue query, comparePair and rowsOf from the HTTP
call. All three reached 100% branches afterwards, and the separation is the finding
rather than the number.
Two arms stay uncovered and are named rather than chased: send's empty-body arm
and credentialAttack's mint-failure arm, both of which need an HTTP fake in a
file whose subject is real HTTP.
The instruments above are tests. This one is a linter, and it had the failure mode this chapter is about: a rule that looks enforced and is not.
no-restricted-imports forbids importing the driver, the query engine and the
counter store outside the layers that own them — constitution I as a build error
rather than a convention. The global-drain restriction wants the same treatment: six
repository functions that claim or count rows across every environment, which an
integration test must not reach for.
Both are the same rule. In flat config a later block REPLACES an earlier block's
setting for a rule rather than merging into it, and the second block has to match
**/*.itest.ts — every one of which the first block already matched as **/*.ts.
So adding the drain restriction the obvious way switches the driver ban off for
every integration test in the workspace, and nothing says so: the config parses, the
lint passes, and the ban is gone.
Measured before it was written, on a file that is on no exemption list:
services/api/src/channels/channels.itest.ts, with `import { sql } from "drizzle-orm"`
one block error 'drizzle-orm' import is restricted no-restricted-imports
two blocks error 'sql' is defined but never used @typescript-eslint/no-unused-vars
The second run is the whole finding. The engine import is still there, still forbidden by a rule the config still contains, and the only complaint left is that the variable is unused.
The four sets are named and hoisted so the blocks can compose them, and each block sets the rule to the union it needs rather than to half of it:
@@ -1,11 +1,277 @@
import eslint from "@eslint/js";
import globals from "globals";
import tseslint from "typescript-eslint";
// One lint config for the whole workspace (ADR-01's consequence made literal).
+// ── THE RESTRICTION SETS, HOISTED SO THEY CAN BE COMPOSED ───────────────────────
+//
+// `no-restricted-imports` is ONE rule, and in flat config a later block REPLACES an
+// earlier block's setting for it rather than merging. Everything below exists because
+// of that sentence: a second block matching `**/*.itest.ts` — every one of which the
+// `**/*.ts` block already matched — switches the first block's rule OFF for every
+// integration test in the workspace, silently.
+//
+// MEASURED BEFORE IT WAS WRITTEN. `services/api/src/channels/channels.itest.ts` is on
+// no exemption list. Given `import { sql } from "drizzle-orm"` it fails lint under a
+// single block, and under a naively-added second block the only error left is
+// `'sql' is defined but never used`.
+const DRIVER_AND_ENGINE = {
+ paths: [
+ {
+ name: "pg",
+ message:
+ "Raw database access is forbidden outside services/api/src/db (constitution I).",
+ },
+ {
+ name: "drizzle-orm",
+ message:
+ "The query engine lives inside the repository layer only (constitution I, ADR-16).",
+ },
+ {
+ name: "ioredis",
+ message:
+ "The counter store lives in services/api/src/limits and services/gateway/src/limits.ts only (constitution I). Its keys are per environment; an unrestricted client is a cross-tenant read.",
+ },
+ ],
+ patterns: [
+ {
+ group: ["drizzle-orm/*"],
+ message:
+ "The query engine lives inside the repository layer only (constitution I, ADR-16).",
+ },
+ ],
+};
+
+// The paths excused from the driver and the engine. Two data-access LAYERS as
+// directories and everything else by path, each with the argument it needs.
+const DRIVER_EXEMPT = [
+ "services/api/src/db/**",
+ "services/api/src/limits/**",
+ // DRIVER_EXEMPT — every path below is exempt from all three restricted modules,
+ // the driver's name on the marker notwithstanding: `driver-exempt.test.ts` reads
+ // the module names out of the rule, so this list governs whatever the rule names.
+ //
+ // First the lane's own infrastructure. Reasons, one per path:
+ // global-setup.ts installs the guard against a database vitest names
+ // setup.ts rewrites the connection string to carry the exemption
+ // guard.itest.ts holds one exempt client and one plain one, and the
+ // difference between them is the whole test
+ "packages/test-harness/src/global-setup.ts",
+ "packages/test-harness/src/setup.ts",
+ "packages/test-harness/src/guard.itest.ts",
+ // AND THE LANE RESET'S OWN TEST, which arrives with the table it clears. It asserts
+ // what the SCRIPT DID — a planted stale delivery gone, an organisation count unmoved
+ // — and both are facts about rows the script reached through its own connection.
+ // Going through the repository layer would mean asserting the script's effect against
+ // the code the script does not use.
+ "packages/test-harness/src/reset-lane.itest.ts",
+ // AND TWO SUITES THAT WRITE A ROW THE TYPE SYSTEM FORBIDS.
+ //
+ // backfill.itest.ts asserts what `toFrame` does with a SENDERLESS message.
+ // Those rows exist — every one written through the socket
+ // before the sender was threaded looks like this — and the
+ // repository can no longer produce one, because `userId` is
+ // required. The fixture has to be raw SQL or the behaviour
+ // has no test at all.
+ // history.itest.ts reads the same row from the other end: a page whose
+ // `user` comes back null. FR-MSG-15 made `sendMessage`
+ // require a sender, so this suite lost the ability to build
+ // its own fixture in the same change that gave it the case.
+ //
+ // This is the exemption's honest case: not "the repository is inconvenient" but
+ // "the state under test is one the repository is now unable to reach". Both are
+ // listed by path rather than reached through a shared helper, because a helper in
+ // another file names none of these specifiers and this rule sees only imports —
+ // an invisible exemption is worse than a listed one.
+ "services/api/src/internal/backfill.itest.ts",
+ "services/api/src/messages/history.itest.ts",
+ // THE QUOTA CHAPTER'S PERIOD SUITE, and its case is the two above's in a third
+ // shape: the state under test is one the repository cannot reach. `periodOf`
+ // returns the month a timestamp falls in, and the property is that a row INSERTED
+ // under that value is FOUND by it — which needs a `usage_periods` row written
+ // directly, because every repository path that writes one derives the period from
+ // the clock and so cannot disagree with the function under test.
+ //
+ // A suite that used the repository here would be asserting that `periodOf` equals
+ // itself.
+ "services/api/src/quotas/period.itest.ts",
+ // AND THE QUOTA SUITE ITSELF, for a different reason from its sibling above.
+ // `period.itest.ts` writes a row the repository cannot; this one READS the two
+ // roll-up tables directly to check what a send left behind. Going through
+ // `usageFor` would mean asserting the roll-up against the function that reads
+ // it — the same circularity, one table over.
+ "services/api/src/quotas/quotas.itest.ts",
+ // AND THE CONNECTION-METERING CHAPTER'S, WHICH MAKES THE SAME CLAIM ONE
+ // DIMENSION OVER: a credited minute survives a `FLUSHALL` of the counter store,
+ // because a quota is about THIS MONTH and the rate limiter's store is allowed to
+ // lose things. Proving that needs the flush, and the flush needs a raw client.
+ //
+ // LISTED RATHER THAN DODGED. Published's version reached for
+ // `await import("ioredis")` inside the test, which this rule cannot see — an
+ // exemption that is invisible, which the note at the top of this block calls
+ // worse than a listed one. The static import puts it back under the rule and
+ // this entry is the answer.
+ "services/api/src/quotas/connections.itest.ts",
+ // ── AND EVERY OTHER REDIS CLIENT, BY PATH, WITH THE ARGUMENT IT NEEDS ──
+ //
+ // The rule arrives here and TWELVE files older than it already import `ioredis`.
+ // A missing exemption is not a silent one — this rule goes red on a chapter
+ // nobody is editing — so all of them land in the commit that adds the rule.
+ // FIVE DIFFERENT ARGUMENTS, and a blanket "the gateway's Redis files" would
+ // erase all five distinctions the rule exists to make.
+ //
+ // (1) NO KEY IS TOUCHED AT ALL — these name a pub/sub SUBJECT and never a key,
+ // which is the property, not whether they publish or subscribe. The subjects are
+ // `chan:{channel_id}`, `member:{channel_id}` and `typing:{channel_id}`: a channel
+ // UUID, and a subject is not readable at all, only listened to by whoever already
+ // subscribed. There is no key here for a cross-tenant read to reach. (The api's
+ // two publishers publish; the gateway's `membership.ts` only ever subscribes,
+ // because the api publishes that fabric — and the argument is the same either
+ // way.)
+ "services/api/src/fanout/publisher.ts",
+ "services/api/src/membership/publisher.ts",
+ //
+ // (2) THE COUNTER STORE'S OTHER HALF. `rl:{environment_id}:…` is the key shape
+ // the whole restriction is about, and this file composes it — so it is exempt as
+ // the rule's own subject, not against its reason. `limits.itest.ts` is listed
+ // beside it for something the rule cannot express at all: its subject is that
+ // the api and the gateway increment the SAME key, and the only way to check that
+ // is to read the key with NEITHER of their code.
+ "services/gateway/src/limits.ts",
+ "services/gateway/src/limits.itest.ts",
+ "services/gateway/src/fanout.ts",
+ // `member:{env}:{user}` — the principal-addressed half of that fabric — DOES
+ // carry an environment id, and that still does not make it the limiter's case:
+ // a subject is not readable, and the id is composed from the repository's own
+ // scope on the way out and from the authenticated connection's identity on the
+ // way in, never read from a payload.
+ "services/gateway/src/membership.ts",
+ // `typing.ts` both publishes and subscribes and composes no key at all — the
+ // environment travels INSIDE the payload, where the receiving gateway checks it
+ // against the connection it is about to act on.
+ "services/gateway/src/typing.ts",
+ //
+ // (2) KEYS ARE COMPOSED AND THEY ARE ENVIRONMENT-SCOPED — the limiter's own
+ // argument rather than the publishers'. `presence:{env}:{user}` is exactly the
+ // shape the restriction guards. Every key is composed from the environment id on
+ // the authenticated connection's own identity; no path takes one from a client,
+ // and there is no scan, `KEYS` or pattern read that could reach another tenant's.
+ "services/gateway/src/presence.ts",
+ //
+ // (3) THE ENVIRONMENT COMES FIRST IN THE KEY, which is the strongest case on
+ // this list rather than the weakest. `conn:{env}:{user}:{slot}` makes
+ // constitution I structural in the key itself: reaching across a tenant needs a
+ // caller to hand this module another environment's id, and the session layer
+ // takes that from the api's verified identity. The other entries argue about
+ // what they touch; this one cannot be wrong without being lied to.
+ "services/gateway/src/connections.ts",
+ //
+ // (4) THE SUBJECT IS WHAT REACHES THE FABRIC, so the oracle cannot be either
+ // service's own client. A spy on `createFanout` or on `createPresence` proves
+ // that an object was asked to publish, not that a frame arrived — and these
+ // suites' receive halves have rejection paths (a body that is not JSON, a body
+ // that is JSON and not a transition) that no module-level API can produce,
+ // because each only ever publishes payloads its own schema built.
+ "services/api/src/fanout/fanout.itest.ts",
+ "services/gateway/src/presence.itest.ts",
+ "services/gateway/src/membership.itest.ts",
+ "services/gateway/src/typing.itest.ts",
+ //
+ // (5) THE RAW CLIENT IS THE STIMULUS, NOT THE ORACLE — a fifth reason, and the
+ // rule cannot express it. This suite's subject is delivery and it asserts on
+ // sockets. It needs a client to CAUSE a membership change: `Membership` exposes
+ // `onChange`, `subscribeChannel` and `watch` and no `publish`, because the api
+ // publishes and the gateway only ever subscribes.
+ "services/gateway/src/connections.itest.ts",
+ //
+ // `services/gateway/src/connections.test.ts` IS DELIBERATELY ABSENT, and the
+ // ledger that owed these entries said to add it. It reads the module's own
+ // source off disk and imports nothing restricted, so the exemption would be one
+ // over nothing — and `driver-exempt.test.ts`'s stale-entry check is the half of
+ // this list that goes red when a listed file stops needing it.
+];
+
+// The suites that drive a global drain on purpose — derived by asking, not by
+// remembering: these are exactly the `*.itest.ts` files in this tree that import one of
+// the functions `GLOBAL_DRAINS` names, and `drain-exempt.test.ts` asserts that in both
+// directions against the tree rather than against a second list.
+const DRAIN_EXEMPT_TESTS = [
+ // `outboxDepth` — the relay's whole subject IS a global drain.
+ "services/api/src/outbox/outbox.itest.ts",
+ // `drainDueDeliveries`, `sweepDisabledEndpoints`, `pendingDeliveryDepth`.
+ "services/api/src/webhooks/deliveries.itest.ts",
+ // `drainDueDeliveries`.
+ "services/api/src/webhooks/attempts.itest.ts",
+ //
+ // THREE, AND PUBLISHED'S LIST IS SIX. `test-event.itest.ts`,
+ // `notifications.itest.ts` and `dispatcher.itest.ts` are on it there and import
+ // nothing restricted HERE: two name a drain only in prose explaining why they do not
+ // call one, and the dispatcher's suite declares `drainDueDeliveries` as a property on
+ // a stub it builds. Listing them would be three standing exemptions over nothing on
+ // the list's first day — the failure mode this file's own note calls out, arriving
+ // by inheritance rather than by drift.
+ //
+ // `drain-exempt.test.ts` found all three, which is the only reason the list is three
+ // long. It reads the names out of `DRAIN_NAMES` below and asserts both directions
+ // against the TREE.
+];
+
+/** The six functions, and the two counts that cannot be bounded. Named once so the
+ * two specifier spellings below cannot drift apart. */
+const DRAIN_NAMES = [
+ "drainOutbox",
+ "drainDueDeliveries",
+ "drainDisableNotifications",
+ "drainQuotaNotifications",
+ "sweepDisabledEndpoints",
+ "outboxDepth",
+ "pendingDeliveryDepth",
+];
+
+const DRAIN_MESSAGE =
+ "This claims or counts rows across EVERY environment. In an integration test that " +
+ "is a local assertion about a global operation, or a global operation over a " +
+ "neighbour's fixture. Scope the assertion to rows this test created, or add the " +
+ "suite to DRAIN_EXEMPT_TESTS with its reason.";
+
+// THE GLOBAL ADMIN FUNCTIONS, RESTRICTED IN INTEGRATION TESTS.
+//
+// Six recorded instances of one fault: a test asserts a local fact about a global
+// operation, or performs one and damages a neighbour's fixture. Each imported one of
+// these and called it as though the database held only its own rows.
+//
+// The two `*Depth` functions are here for a different reason from the other five. They
+// take no batch size and cannot — a count has nothing to bound — and a global count
+// compared against itself is the instance that appeared twice in one file, four
+// chapters apart.
+//
+// WHAT THIS DOES NOT CATCH, and must not be trusted to: an indirect call — a helper in
+// another file that calls the function, imported here under an innocent name — and raw
+// SQL, which names no import at all. Both are the sentinel trigger's job; it watches
+// STATEMENTS rather than imports. A rule trusted further than it goes is worse than no
+// rule.
+//
+// BOTH SPELLINGS, because `no-restricted-imports` matches the specifier as WRITTEN.
+// `../db/repository` and `./repository` are two rules, and the second is the one a
+// suite inside `services/api/src/db/` would use.
+const GLOBAL_DRAINS = {
+ paths: [
+ {
+ name: "../db/repository",
+ importNames: DRAIN_NAMES,
+ message: DRAIN_MESSAGE,
+ },
+ {
+ name: "./repository",
+ importNames: DRAIN_NAMES,
+ message: DRAIN_MESSAGE,
+ },
+ ],
+};
+
export default tseslint.config(
{ ignores: ["**/node_modules/**", "**/dist/**", "**/coverage/**"] },
eslint.configs.recommended,
...tseslint.configs.recommended,
{
// Dev scripts run on Node directly, outside any package's tsconfig —
@@ -39,185 +305,48 @@ export default tseslint.config(
// file that imports the driver; nothing here can catch a LISTED file that stopped
// importing it, so the list can only grow and a stale entry holds a standing
// exemption forever. `driver-exempt.test.ts` reads this array and asserts each
// path exists and still imports a module the rule below restricts — with those
// module names read out of the rule rather than restated.
files: ["**/*.ts"],
- ignores: [
- "services/api/src/db/**",
- "services/api/src/limits/**",
- // DRIVER_EXEMPT — every path below is exempt from all three restricted modules,
- // the driver's name on the marker notwithstanding: `driver-exempt.test.ts` reads
- // the module names out of the rule, so this list governs whatever the rule names.
- //
- // First the lane's own infrastructure. Reasons, one per path:
- // global-setup.ts installs the guard against a database vitest names
- // setup.ts rewrites the connection string to carry the exemption
- // guard.itest.ts holds one exempt client and one plain one, and the
- // difference between them is the whole test
- "packages/test-harness/src/global-setup.ts",
- "packages/test-harness/src/setup.ts",
- "packages/test-harness/src/guard.itest.ts",
- // AND THE LANE RESET'S OWN TEST, which arrives with the table it clears. It
- // asserts what the SCRIPT DID — a stale pending backlog gone, an organisation
- // count unmoved — and both are facts about rows the script reached through its
- // own connection. Going through the repository layer would mean asserting the
- // script's effect against the code the script does not use.
- "packages/test-harness/src/reset-lane.itest.ts",
- // AND TWO SUITES THAT WRITE A ROW THE TYPE SYSTEM FORBIDS.
- //
- // backfill.itest.ts asserts what `toFrame` does with a SENDERLESS message.
- // Those rows exist — every one written through the socket
- // before the sender was threaded looks like this — and the
- // repository can no longer produce one, because `userId` is
- // required. The fixture has to be raw SQL or the behaviour
- // has no test at all.
- // history.itest.ts reads the same row from the other end: a page whose
- // `user` comes back null. FR-MSG-15 made `sendMessage`
- // require a sender, so this suite lost the ability to build
- // its own fixture in the same change that gave it the case.
- //
- // This is the exemption's honest case: not "the repository is inconvenient" but
- // "the state under test is one the repository is now unable to reach". Both are
- // listed by path rather than reached through a shared helper, because a helper in
- // another file names none of these specifiers and this rule sees only imports —
- // an invisible exemption is worse than a listed one.
- "services/api/src/internal/backfill.itest.ts",
- "services/api/src/messages/history.itest.ts",
- // THE QUOTA CHAPTER'S PERIOD SUITE, and its case is the two above's in a third
- // shape: the state under test is one the repository cannot reach. `periodOf`
- // returns the month a timestamp falls in, and the property is that a row INSERTED
- // under that value is FOUND by it — which needs a `usage_periods` row written
- // directly, because every repository path that writes one derives the period from
- // the clock and so cannot disagree with the function under test.
- //
- // A suite that used the repository here would be asserting that `periodOf` equals
- // itself.
- "services/api/src/quotas/period.itest.ts",
- // AND THE QUOTA SUITE ITSELF, for a different reason from its sibling above.
- // `period.itest.ts` writes a row the repository cannot; this one READS the two
- // roll-up tables directly to check what a send left behind. Going through
- // `usageFor` would mean asserting the roll-up against the function that reads
- // it — the same circularity, one table over.
- "services/api/src/quotas/quotas.itest.ts",
- // AND THE CONNECTION-METERING CHAPTER'S, WHICH MAKES THE SAME CLAIM ONE
- // DIMENSION OVER: a credited minute survives a `FLUSHALL` of the counter store,
- // because a quota is about THIS MONTH and the rate limiter's store is allowed to
- // lose things. Proving that needs the flush, and the flush needs a raw client.
- //
- // LISTED RATHER THAN DODGED. Published's version reached for
- // `await import("ioredis")` inside the test, which this rule cannot see — an
- // exemption that is invisible, which the note at the top of this block calls
- // worse than a listed one. The static import puts it back under the rule and
- // this entry is the answer.
- "services/api/src/quotas/connections.itest.ts",
- // ── AND EVERY OTHER REDIS CLIENT, BY PATH, WITH THE ARGUMENT IT NEEDS ──
- //
- // The rule arrives here and TWELVE files older than it already import `ioredis`.
- // A missing exemption is not a silent one — this rule goes red on a chapter
- // nobody is editing — so all of them land in the commit that adds the rule.
- // FIVE DIFFERENT ARGUMENTS, and a blanket "the gateway's Redis files" would
- // erase all five distinctions the rule exists to make.
- //
- // (1) NO KEY IS TOUCHED AT ALL — these name a pub/sub SUBJECT and never a key,
- // which is the property, not whether they publish or subscribe. The subjects are
- // `chan:{channel_id}`, `member:{channel_id}` and `typing:{channel_id}`: a channel
- // UUID, and a subject is not readable at all, only listened to by whoever already
- // subscribed. There is no key here for a cross-tenant read to reach. (The api's
- // two publishers publish; the gateway's `membership.ts` only ever subscribes,
- // because the api publishes that fabric — and the argument is the same either
- // way.)
- "services/api/src/fanout/publisher.ts",
- "services/api/src/membership/publisher.ts",
- //
- // (2) THE COUNTER STORE'S OTHER HALF. `rl:{environment_id}:…` is the key shape
- // the whole restriction is about, and this file composes it — so it is exempt as
- // the rule's own subject, not against its reason. `limits.itest.ts` is listed
- // beside it for something the rule cannot express at all: its subject is that
- // the api and the gateway increment the SAME key, and the only way to check that
- // is to read the key with NEITHER of their code.
- "services/gateway/src/limits.ts",
- "services/gateway/src/limits.itest.ts",
- "services/gateway/src/fanout.ts",
- // `member:{env}:{user}` — the principal-addressed half of that fabric — DOES
- // carry an environment id, and that still does not make it the limiter's case:
- // a subject is not readable, and the id is composed from the repository's own
- // scope on the way out and from the authenticated connection's identity on the
- // way in, never read from a payload.
- "services/gateway/src/membership.ts",
- // `typing.ts` both publishes and subscribes and composes no key at all — the
- // environment travels INSIDE the payload, where the receiving gateway checks it
- // against the connection it is about to act on.
- "services/gateway/src/typing.ts",
- //
- // (2) KEYS ARE COMPOSED AND THEY ARE ENVIRONMENT-SCOPED — the limiter's own
- // argument rather than the publishers'. `presence:{env}:{user}` is exactly the
- // shape the restriction guards. Every key is composed from the environment id on
- // the authenticated connection's own identity; no path takes one from a client,
- // and there is no scan, `KEYS` or pattern read that could reach another tenant's.
- "services/gateway/src/presence.ts",
- //
- // (3) THE ENVIRONMENT COMES FIRST IN THE KEY, which is the strongest case on
- // this list rather than the weakest. `conn:{env}:{user}:{slot}` makes
- // constitution I structural in the key itself: reaching across a tenant needs a
- // caller to hand this module another environment's id, and the session layer
- // takes that from the api's verified identity. The other entries argue about
- // what they touch; this one cannot be wrong without being lied to.
- "services/gateway/src/connections.ts",
- //
- // (4) THE SUBJECT IS WHAT REACHES THE FABRIC, so the oracle cannot be either
- // service's own client. A spy on `createFanout` or on `createPresence` proves
- // that an object was asked to publish, not that a frame arrived — and these
- // suites' receive halves have rejection paths (a body that is not JSON, a body
- // that is JSON and not a transition) that no module-level API can produce,
- // because each only ever publishes payloads its own schema built.
- "services/api/src/fanout/fanout.itest.ts",
- "services/gateway/src/presence.itest.ts",
- "services/gateway/src/membership.itest.ts",
- "services/gateway/src/typing.itest.ts",
- //
- // (5) THE RAW CLIENT IS THE STIMULUS, NOT THE ORACLE — a fifth reason, and the
- // rule cannot express it. This suite's subject is delivery and it asserts on
- // sockets. It needs a client to CAUSE a membership change: `Membership` exposes
- // `onChange`, `subscribeChannel` and `watch` and no `publish`, because the api
- // publishes and the gateway only ever subscribes.
- "services/gateway/src/connections.itest.ts",
- //
- // `services/gateway/src/connections.test.ts` IS DELIBERATELY ABSENT, and the
- // ledger that owed these entries said to add it. It reads the module's own
- // source off disk and imports nothing restricted, so the exemption would be one
- // over nothing — and `driver-exempt.test.ts`'s stale-entry check is the half of
- // this list that goes red when a listed file stops needing it.
- ],
+ ignores: DRIVER_EXEMPT,
+
+ rules: {
+ "no-restricted-imports": ["error", DRIVER_AND_ENGINE],
+ },
+ },
+ {
+ // ── AND THE UNION, WHICH IS THE WHOLE REASON THE SETS ARE NAMED ─────────────
+ //
+ // Every `*.itest.ts` the block above matched as `**/*.ts` is matched again here, so
+ // this rule must be the UNION or the driver ban is switched off for all of them.
+ // The two exemption lists are ignored here and given their own single rule below,
+ // because `ignores` on the FIRST block cannot reach a rule the SECOND one sets.
+ files: ["**/*.itest.ts"],
+ ignores: [...DRAIN_EXEMPT_TESTS, ...DRIVER_EXEMPT],
rules: {
"no-restricted-imports": [
"error",
{
- paths: [
- {
- name: "pg",
- message:
- "Raw database access is forbidden outside services/api/src/db (constitution I).",
- },
- {
- name: "drizzle-orm",
- message:
- "The query engine lives inside the repository layer only (constitution I, ADR-16).",
- },
- {
- name: "ioredis",
- message:
- "The counter store lives in services/api/src/limits and services/gateway/src/limits.ts only (constitution I). Its keys are per environment; an unrestricted client is a cross-tenant read.",
- },
- ],
- patterns: [
- {
- group: ["drizzle-orm/*"],
- message:
- "The query engine lives inside the repository layer only (constitution I, ADR-16).",
- },
- ],
+ paths: [...DRIVER_AND_ENGINE.paths, ...GLOBAL_DRAINS.paths],
+ patterns: DRIVER_AND_ENGINE.patterns,
},
],
},
},
+ {
+ // The driver-exempt paths keep their exemption and gain the drain rule. Without
+ // this block the union above would restore the ban they were excused from.
+ files: DRIVER_EXEMPT,
+ rules: {
+ "no-restricted-imports": ["error", GLOBAL_DRAINS],
+ },
+ },
+ {
+ // And the drain-exempt suites get the driver rule alone. They drive a global drain
+ // on purpose; they are excused from nothing else.
+ files: DRAIN_EXEMPT_TESTS,
+ rules: {
+ "no-restricted-imports": ["error", DRIVER_AND_ENGINE],
+ },
+ },
);Six probes, because a composed rule has six directions to get wrong and a config that passes its own tree proves none of them:
| the file | the import | expected |
|---|---|---|
a plain .itest.ts | drizzle-orm | banned |
| a drain-exempt suite | drizzle-orm | banned |
| a driver-exempt suite | drainOutbox | banned |
a plain .itest.ts | outboxDepth | banned |
| a drain-exempt suite | its own drain | allowed |
| the tree as it stands | — | clean |
And the exemption list arrived stale. Six suites were excused from the drain
rule; drain-exempt.test.ts reads the restricted names out of the rule itself and
asserts both directions against the tree, and it found that three of the six import
nothing restricted here — two name a drain only in prose explaining why they call
one instead, and the third names it as a property on a stub it builds. Three
standing exemptions over nothing, on the list's first day.
That is the shape the rest of this chapter is about, arriving through the instrument rather than through the platform: a list copied from somewhere it was true is a list nobody has checked here.
The suite says so itself, at the top of the file a reader meets first:
// TIMING. A foreign id answering in 3 ms and an absent id in 30 ms is a disclosure
// this suite cannot see. Measuring that stably in CI is a different discipline and
// is not attempted here; the chapter names it as unaddressed rather than implying
// it is covered.
//
// A LEAKED PLATFORM CREDENTIAL. FR-044 narrowed which routes each internal service
// may call, and that is all it did. There is no rotation, and `service` is
// self-reported by which variable matched — so the change shrinks the blast radius
// of a leak and does not make one survivable.
//
// MESSAGE CONTENT BEYOND EQUALITY. The pair proves the two answers match. It does
// not prove that what they say is wise: a constant message leaking a schema detail
// would pass every assertion below.The scattered ones are still there, and one of them is sharper than it was — though not
because of anything this chapter does. repository.itest.ts's membership assertion used to
read false for three different refusals: the channel is not yours, the user is not yours,
and you asked twice. Conflating the first two is the property; conflating the third with
them is a bug waiting for an endpoint — and the endpoints chapter answered it seventeen
chapters back, which is why the assertion arrives here already reading three words instead
of one boolean.
The endpoint that needed the distinction is the endpoints chapter's, and it is
already built — that chapter runs eight movements ahead of this one now. The
isolation half of it belongs here, because not_found covering both foreign cases
is the property FR-TEN-05 asks for and it is now written down as a word rather than
as a boolean somebody has to interpret.
A defence trusted past its range is worse than none, and this is the difference
between this suite and the scattered assertions it joins. Twenty-five of those
predate it — counted by title across every integration suite outside
src/isolation/, eleven when this chapter was first written and twenty-five by the
time the reorder put eleven more chapters in front of it. They are all still there
and still passing: the gauntlet adds to the isolation surface rather than relocating
it off the code the coverage run measures.