Part 3 · Chapter 3.2
Keys and tokens — two credentials, one mistake
You will produce: API keys (prefix, hash, rotation); user JWTs; the dev-token endpoint · about 100 minutes including the exercise
Chương tenancy dựng lên các khoang chứa mà thế giới của một khách hàng sống trong đó và lệnh đăng ký tạo ra chúng. Nó không dựng một cách nào để chứng minh rằng bạn sở hữu một khoang. Mọi request kể từ 2.2 đều gọi tên tenant của nó bằng một header mà nó đơn giản là tự khẳng định:
x-relay-environment: 3f2a…Bất kỳ ai với tới được api đều có thể gõ cái header đó, gọi tên bất kỳ environment nào, rồi đọc nó. Lúc ấy tôi gọi nó là một đường nối chế độ dev và hứa rằng Phần 3 sẽ thay nó. Đây chính là chương ấy, và cái đường nối không sống sót qua nó.
Có một đường nối thứ hai cũng cùng tuổi. Gateway vẫn tự xác minh các token
WebSocket, bằng HS256 trên một RELAY_DEV_JWT_SECRET dùng chung mà
mọi service và mọi bài test đều biết. Cả hai đường nối đều chết ở đây, trong hai
bước tăng riêng biệt, bởi chúng có bán kính vụ nổ khác nhau và tôi muốn bạn thấy cả hai
con số.
Thứ thay thế chúng là hai credential — và chủ đề của chương thật ra chẳng phải cái nào trong hai. Nó là sai lầm mà người ta mắc với cái cặp ấy.
Hai quần thể, hai credential
Chương tenancy kẻ một đường qua dữ liệu: humans trên ranh giới tenant
là những người đăng nhập vào Relay, users bên dưới nó là những người bên trong
sản phẩm của một khách hàng, và hai thứ đó chẳng bao giờ được trộn (ADR-18). Đường kẻ đó kéo theo một
đường thứ hai. Có hai loại bên gọi, và chúng đang xin hai thứ khác
nhau.
Một ứng dụng — backend của một công ty, giữ một credential trong các biến môi trường của chính nó — nói tôi là environment này. Nó gửi message thay mặt bất kỳ ai nó thích, bởi nó chính là khách hàng. Một người dùng cuối — một tài xế trong một chiếc xe tải, một tab trình duyệt — nói tôi là người này trong environment kia. Nó chỉ được hành động với tư cách chính nó.
Một credential không mang được cả hai tuyên bố một cách lương thiện, nên có hai cái.
flowchart TB
subgraph app["APPLICATION — backend của một công ty"]
key["API key<br/>rk_dev_<public_id>_<secret><br/>(FR-AUT-01, FR-AUT-03)"]
end
subgraph user["END USER — một người trong product của công ty đó"]
tok["End-user token<br/>HS256, signed bằng secret riêng của environment<br/>(FR-AUT-06, FR-AUT-08)"]
end
rest["REST: POST · GET /v1/channels/:id/messages<br/>chấp nhận cả hai class"]
dev["POST /auth/dev-token<br/>CHỈ API key, CHỈ development (FR-AUT-09)"]
ws["WebSocket upgrade /v1/ws?token=<br/>CHỈ end-user token (EIR-WS-05)"]
key --> rest
key --> dev
tok --> rest
tok --> ws
key -. "403 wrong_credential_type" .-> ws
tok -. "403 wrong_credential_type" .-> dev
note["Cả hai resolve thành MỘT principal mang environment_id.<br/>Không phần downstream nào hỏi nó thuộc class nào — trừ<br/>những route bắt buộc phải hỏi (research R6)"]
rest ~~~ noteCả hai đều phân giải về một hình dạng duy nhất, và đó là điểm mấu chốt. Việc xác thực tạo ra một
principal, principal mang một environmentId, và mọi thứ
ở hạ nguồn đều đọc đúng cái field đó. Hai lớp chỉ khác nhau ở đúng hai chỗ: mỗi cái
được với tới đâu, và chuyện gì xảy ra khi có người lẫn lộn chúng.
// What authentication produces. Never persisted, never sent —
// this is the shape the rest of a request reasons about instead of reading a
// header somebody asserted.
//
// Two classes, because the platform has two populations to authenticate: the
// APPLICATION integrating with Relay, and the END USER inside one of its
// environments. ADR-18 drew that line through the data; this draws it through
// the request.
export interface ApplicationPrincipal {
kind: "application";
/** Resolved from the key, never from the caller's word for it. */
environmentId: string;
/** For last_used_at, and for the retry-and-disable chapter's quota accounting. */
keyId: string;
}
export interface UserPrincipal {
kind: "user";
environmentId: string;
userExternalId: string;
}
export type Principal = ApplicationPrincipal | UserPrincipal;
export type PrincipalKind = Principal["kind"];
/** The request as everything downstream of the middleware sees it. The
* principal is optional at the type level for one honest reason: a request that
* presented nothing has none, and pre-credential routes (signup) are reached
* exactly that way. */
export interface RequestWithPrincipal {
headers: Record<string, string | string[] | undefined>;
principal?: Principal;
}
/** How a credential class is named to a human. Used by the wrong-credential
* error, which must say what was presented and what was expected — and must
* never quote the credential (NFR-SEC-06). */
export function describePrincipalKind(kind: PrincipalKind): string {
return kind === "application" ? "an API key" : "an end-user token";
}
/** The `Bearer <credential>` half of RFC 6750, and nothing else. Query strings
* are refused by omission: URLs reach logs and referrer headers, and NFR-SEC-06
* forbids a credential in either. (The WebSocket upgrade is the one exception,
* and it lives in the gateway where a browser gives no other choice.) */
export function bearerCredential(
headers: RequestWithPrincipal["headers"],
): string | null {
const raw = headers["authorization"];
const value = Array.isArray(raw) ? raw[0] : raw;
if (typeof value !== "string") return null;
const match = /^Bearer (.+)$/i.exec(value.trim());
return match?.[1]?.trim() || null;
}Một tay cầm tra cứu được, và một secret thì không
Một API key phải giải một bài toán mà chẳng câu query nào khác trong codebase này gặp. Mọi phương thức repository từ 2.1 đều bắt đầu bằng một environment; cái này thì tạo ra một environment. Việc xác thực xảy ra trước khi một phạm vi tenant tồn tại, nghĩa là lần tra key là câu query không-giới-hạn duy nhất trong hệ thống.
Điều đó loại bỏ thiết kế hiển nhiên. Nếu cả cái credential đều được băm, thì sẽ chẳng có gì để tra cứu theo, và mọi request sẽ phải quét mọi key trong nền tảng. Nên credential gồm hai phần: một tay cầm công khai được đánh index và không bí mật, và một secret được băm và không bao giờ được lưu.
rk_dev_9f4c1e8b0a7d2f3e5c6b8a9d0e1f2a3b_kQ7xN2mP…
└──┬──┘└───────────────┬──────────────┘ └───┬───┘
│ │ └─ 32 random bytes, base64url.
│ │ Shown ONCE. Hashed at rest.
│ └─ 16 random bytes, hex. Indexed. Globally unique.
└─ FR-AUT-03's visible prefix: which environment am I about to hit?Tiền tố là một yêu cầu, không phải trang trí. FR-AUT-03 đòi rằng một key mang một
dấu hiệu environment nhìn thấy được, và lý do là một phiếu hỗ trợ chẳng ai muốn
viết: rk_live_ trong một file cấu hình staging là một sai lầm bạn thấy ngay từ cái liếc,
còn một khối mờ đục là thứ bạn phát hiện ra sau này.
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
// The API key as a string, and the arithmetic behind it.
// Framework-free on purpose: this is where the credential's rules live, and
// they are testable without a server, a database or a request.
//
// rk_dev_<public_id>_<secret>
// └──┬──┘└────┬────┘ └──┬───┘
// │ │ └─ 32 random bytes, base64url. Shown ONCE, hashed
// │ │ at rest, never recoverable (FR-AUT-02).
// │ └─ 16 random bytes, HEX. Indexed, globally unique, NOT secret.
// └─ FR-AUT-03's visible prefix: which environment am I about to hit?
//
// WHY the public id is hex when the secret is base64url: the two parts have to
// be separable by a machine, and base64url's alphabet INCLUDES the separator
// `_`. Splitting on the last underscore would corrupt any secret containing
// one; splitting on the first would corrupt any public id containing one. Hex
// has no `_` and a fixed length, so the boundary is exact whatever the secret
// happens to contain. (data-model.md said "split on the last separator"; the
// first mint that produced a secret with an underscore said otherwise.)
export const KEY_PREFIXES = {
development: "rk_dev_",
production: "rk_live_",
} as const;
export type EnvironmentKind = keyof typeof KEY_PREFIXES;
export type KeyPrefix = (typeof KEY_PREFIXES)[EnvironmentKind];
const PUBLIC_ID_BYTES = 16;
const SECRET_BYTES = 32;
const SALT_BYTES = 16;
const CREDENTIAL = /^(rk_dev_|rk_live_)([0-9a-f]{32})_(.+)$/;
export interface MintedKey {
/** The whole credential, the only time it exists outside a hash. */
credential: string;
publicId: string;
prefix: KeyPrefix;
salt: string;
secretHash: string;
}
export function mintApiKey(kind: EnvironmentKind): MintedKey {
const prefix = KEY_PREFIXES[kind];
const publicId = randomBytes(PUBLIC_ID_BYTES).toString("hex");
const secret = randomBytes(SECRET_BYTES).toString("base64url");
const salt = randomBytes(SALT_BYTES).toString("base64url");
return {
credential: `${prefix}${publicId}_${secret}`,
publicId,
prefix,
salt,
secretHash: hashSecret(secret, salt),
};
}
export interface ParsedCredential {
prefix: KeyPrefix;
publicId: string;
secret: string;
}
/** Returns null rather than throwing. A thrown error would carry the presented
* string into a stack trace, and a stack trace is a log line (NFR-SEC-06). */
export function parseApiKeyCredential(raw: string): ParsedCredential | null {
const match = CREDENTIAL.exec(raw);
if (!match) return null;
return {
prefix: match[1] as KeyPrefix,
publicId: match[2]!,
secret: match[3]!,
};
}
/** Which class is this? Decided by the prefix, before anything is verified —
* the whole reason FR-AUT-03 puts a visible prefix on the credential. */
export function looksLikeApiKey(raw: string): boolean {
return raw.startsWith("rk_");
}
/** Salted SHA-256, not bcrypt or argon2 — and that is deliberate (research R3).
* A password KDF's slowness buys resistance to GUESSING a low-entropy secret.
* This secret is 256 bits from `randomBytes`; no work factor makes it more
* unguessable, and the cost would be paid on every authenticated request. What
* NFR-SEC-02 asks for is a salted hash, which this is. */
export function hashSecret(secret: string, salt: string): string {
return createHash("sha256").update(`${salt}:${secret}`).digest("hex");
}
/** Constant-time comparison of the HASHES, never of the secrets. Two hashes are
* always the same length, so `timingSafeEqual` — which throws on a length
* mismatch — cannot be handed an absurd presented secret and turned into a 500. */
export function secretMatches(
secret: string,
salt: string,
expectedHash: string,
): boolean {
const presented = Buffer.from(hashSecret(secret, salt), "utf8");
const expected = Buffer.from(expectedHash, "utf8");
if (presented.length !== expected.length) return false;
return timingSafeEqual(presented, expected);
}
/** The prefix duplicates the environment's kind, and storing it is what makes a
* disagreement between them detectable instead of assumed (data-model). */
export function prefixMatchesKind(prefix: string, kind: EnvironmentKind): boolean {
return prefix === KEY_PREFIXES[kind];
}Cái bảng nó rơi vào thì mang environment, như mọi thứ khác bên dưới ranh giới. Thứ nó không mang là bất kỳ ràng buộc nào về việc một environment được có bao nhiêu key — nhiều key cùng hoạt động là tính năng, không phải một sự bỏ sót, bởi đó chính là ý nghĩa của việc xoay vòng mà không phải ngừng dịch vụ.
@@ -129,12 +129,58 @@ export const environments = pgTable(
// above, this unique index IS that rule: two legal kinds, one row each.
// No trigger, no counting query, nothing to lose a race to.
unique("environments_application_kind_unique").on(t.applicationId, t.kind),
],
);
+// DECISION: the SRS states the requirements this table serves
+// (FR-AUT-01…05, NFR-SEC-02) but no source document defines a key table —
+// SAD §6.1 does not have one. Its shape is a chapter derivation, recorded here
+// the way 2.1 recorded `members` and the tenancy chapter recorded its containers.
+//
+// It sits BELOW the environment boundary, so it carries an environment_id like
+// every other table down here. The credential is two parts: `public_id` is an
+// indexed, non-secret lookup handle, and only a salted hash of the secret half
+// is ever stored. That split exists because authentication must resolve a
+// tenant BEFORE one is known — the single query in this file that cannot be
+// scoped, which is exactly why the lookup column is unique globally.
+export const apiKeys = pgTable(
+ "api_keys",
+ {
+ id: uuid("id").primaryKey(),
+ environmentId: uuid("environment_id")
+ .notNull()
+ .references(() => environments.id),
+ publicId: text("public_id").notNull(),
+ secretHash: text("secret_hash").notNull(),
+ salt: text("salt").notNull(),
+ prefix: text("prefix").notNull(),
+ name: text("name"),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ lastUsedAt: timestamp("last_used_at", { withTimezone: true }),
+ // Non-null means refused from that moment on. A timestamp rather than a
+ // DELETE: a deleted row loses the record of what once had access
+ // (FR-AUT-05).
+ revokedAt: timestamp("revoked_at", { withTimezone: true }),
+ },
+ (t) => [
+ // Globally unique, not per environment: the lookup happens before any
+ // environment is known, so it must resolve to at most one row on its own.
+ unique("api_keys_public_id_unique").on(t.publicId),
+ // FR-AUT-03's two prefixes and nothing else. Several ACTIVE keys per
+ // environment stay legal — that is what makes rotation possible without
+ // downtime (FR-AUT-04), so nothing here constrains the count.
+ check(
+ "api_keys_prefix_check",
+ sql`${t.prefix} IN ('rk_dev_','rk_live_')`,
+ ),
+ ],
+);
+
export const users = pgTable(
"users",
{
id: uuid("id").primaryKey(),
environmentId: uuid("environment_id")
.notNull()-- API keys (FR-AUT-01…05, NFR-SEC-02).
--
-- REVIEW DISPOSITION: drizzle-kit generated this file from schema.ts and it was
-- read line by line before being applied (the ADR-16 workflow). Nothing was
-- rewritten this time, and the reason is worth stating: 0002 needed a hand
-- rewrite because it added a NOT NULL column to a populated table. This
-- migration only CREATEs — there are no existing rows to say anything about, so
-- the generated shape is already correct. Reviewing it was still the point; the
-- workflow is "read the SQL", not "read the SQL when you expect a problem".
--
-- Two constraints carry requirements rather than convention:
-- api_keys_public_id_unique — the lookup runs before any tenant is known, so
-- the handle must resolve to at most one row
-- globally (research R2).
-- api_keys_prefix_check — FR-AUT-03's two prefixes, and nothing else.
--
-- Deliberately absent: any constraint on how many active keys an environment
-- may have. Several at once is the feature, not an oversight (FR-AUT-04) —
-- rotation with no downtime needs the old key to keep working while the new one
-- is deployed.
CREATE TABLE "api_keys" (
"id" uuid PRIMARY KEY NOT NULL,
"environment_id" uuid NOT NULL,
"public_id" text NOT NULL,
"secret_hash" text NOT NULL,
"salt" text NOT NULL,
"prefix" text NOT NULL,
"name" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
"last_used_at" timestamp with time zone,
"revoked_at" timestamp with time zone,
CONSTRAINT "api_keys_public_id_unique" UNIQUE("public_id"),
CONSTRAINT "api_keys_prefix_check" CHECK ("api_keys"."prefix" IN ('rk_dev_','rk_live_'))
);
--> statement-breakpoint
ALTER TABLE "api_keys" ADD CONSTRAINT "api_keys_environment_id_environments_id_fk" FOREIGN KEY ("environment_id") REFERENCES "public"."environments"("id") ON DELETE no action ON UPDATE no action;Cái secret bạn thấy một lần
Một tổ chức mới tinh thì không xác thực nổi một request để xin cái key đầu tiên của nó. Chẳng có phiên console nào — chương tenancy cố ý không dựng một cái — nên phải có thứ gì đó khởi tạo credential, và lệnh đăng ký là hành vi duy nhất vốn đã biết bạn là ai.
Nên provisionOrganisation mint key đầu tiên của environment bên trong cùng
transaction với tenant mà nó thuộc về, và callback trả về bản rõ của nó
đúng một lần. FR-DSH-01 muốn một development key trên màn hình đầu tiên sau
khi đăng ký; đây chính là nơi cái key ấy đến từ.
@@ -1,22 +1,30 @@
import { randomUUID } from "node:crypto";
import { and, asc, desc, eq, gt, lt, sql, type SQL } from "drizzle-orm";
import type { Db } from "./client";
import {
+ apiKeys,
applications,
channels,
environments,
humans,
members,
memberships,
messages,
organisations,
users,
} from "./schema";
+import {
+ mintApiKey,
+ parseApiKeyCredential,
+ prefixMatchesKind,
+ secretMatches,
+ type EnvironmentKind,
+} from "../auth/api-key";
// The repository layer — the ONE place data access lives (ADR-04's single
// writer, constitution I). Two surfaces with a bright line between them:
//
// createEnvironment / provisionOrganisation — the ADMIN surface. These
// create tenants, so they are the only operations here that are not
@@ -66,21 +74,168 @@ export async function createEnvironment(
sql`INSERT INTO environments (id, application_id, kind, signing_secret)
VALUES (${environmentId}, ${applicationId}, ${kind}, ${randomUUID()})`,
);
return { id: environmentId, kind };
}
+// ---------------------------------------------------------------------------
+// Credentials. Part of the ADMIN surface, and that placement is
+// the interesting bit: authentication has to resolve a tenant BEFORE one is
+// known, so these are the only queries in this file that cannot be scoped by an
+// environment. They are the operations that PRODUCE the scope everything else
+// is bound by — which is why they sit beside createEnvironment rather than
+// inside the request-scoped class below.
+// ---------------------------------------------------------------------------
+
+/** The parts of a minted key a caller may see. `credential` is the only time
+ * the secret exists outside a hash (FR-AUT-02); lose it and the answer is a new
+ * key, not a lookup. */
+export interface CreatedApiKey {
+ id: string;
+ credential: string;
+ publicId: string;
+ prefix: string;
+}
+
+/** A writer that may be the pool or a transaction. `provisionOrganisation`
+ * mints the first key inside its transaction, so this cannot take `Db` alone —
+ * a key written outside that transaction could survive a rolled-back tenant. */
+type Writer = Pick<Db, "insert" | "select" | "update">;
+
+/** FR-AUT-01. The kind is NOT a parameter: it is read from the environment, so
+ * the prefix and the environment can never disagree at creation time. */
+export async function createApiKey(
+ db: Writer,
+ { environmentId, name }: { environmentId: string; name?: string },
+): Promise<CreatedApiKey> {
+ const [environment] = await db
+ .select({ kind: sql<EnvironmentKind>`${environments.kind}` })
+ .from(environments)
+ .where(eq(environments.id, environmentId));
+ if (!environment) {
+ throw new Error(`no such environment: ${environmentId}`);
+ }
+ const minted = mintApiKey(environment.kind);
+ const id = randomUUID();
+ await db.insert(apiKeys).values({
+ id,
+ environmentId,
+ publicId: minted.publicId,
+ secretHash: minted.secretHash,
+ salt: minted.salt,
+ prefix: minted.prefix,
+ name: name ?? null,
+ });
+ return {
+ id,
+ credential: minted.credential,
+ publicId: minted.publicId,
+ prefix: minted.prefix,
+ };
+}
+
+/** What a verified key resolves to. Deliberately not the row: nothing outside
+ * this function needs the hash, the salt, or the name. */
+export interface AuthenticatedKey {
+ keyId: string;
+ environmentId: string;
+}
+
+/** One indexed lookup, then a constant-time comparison. No cache, on purpose:
+ * FR-AUT-05's revocation bound is true by construction when verification is
+ * live, on every instance, with nothing to invalidate (research R7).
+ *
+ * Returns null for every failure — unknown, revoked, wrong secret, mismatched
+ * prefix — because a caller learns nothing from being told which. */
+export async function authenticateApiKey(
+ db: Db,
+ credential: string,
+): Promise<AuthenticatedKey | null> {
+ const parsed = parseApiKeyCredential(credential);
+ if (!parsed) return null;
+
+ const [row] = await db
+ .select({
+ id: apiKeys.id,
+ environmentId: apiKeys.environmentId,
+ secretHash: apiKeys.secretHash,
+ salt: apiKeys.salt,
+ prefix: apiKeys.prefix,
+ revokedAt: apiKeys.revokedAt,
+ kind: sql<EnvironmentKind>`${environments.kind}`,
+ })
+ .from(apiKeys)
+ .innerJoin(environments, eq(environments.id, apiKeys.environmentId))
+ .where(eq(apiKeys.publicId, parsed.publicId));
+
+ if (!row) return null;
+ if (row.revokedAt !== null) return null;
+ if (!secretMatches(parsed.secret, row.salt, row.secretHash)) return null;
+ // A row whose prefix disagrees with its environment's kind is a data fault,
+ // not a credential to trust. Storing the prefix is what makes this checkable.
+ if (!prefixMatchesKind(row.prefix, row.kind)) return null;
+
+ // Touched at most once a minute rather than on every request: the column is
+ // for spotting a key nobody rotated, and that question does not need
+ // second-level precision or a write per authenticated call.
+ await db
+ .update(apiKeys)
+ .set({ lastUsedAt: new Date() })
+ .where(
+ and(
+ eq(apiKeys.id, row.id),
+ sql`(${apiKeys.lastUsedAt} IS NULL OR ${apiKeys.lastUsedAt} < now() - interval '1 minute')`,
+ ),
+ );
+
+ return { keyId: row.id, environmentId: row.environmentId };
+}
+
+/** FR-AUT-05. A timestamp, not a DELETE: the row is the record of what once had
+ * access, and the credential stops working on the next request either way. */
+export async function revokeApiKey(db: Db, keyId: string): Promise<boolean> {
+ const revoked = await db
+ .update(apiKeys)
+ .set({ revokedAt: new Date() })
+ .where(and(eq(apiKeys.id, keyId), sql`${apiKeys.revokedAt} IS NULL`))
+ .returning({ id: apiKeys.id });
+ return revoked.length > 0;
+}
+
+/** The environment's own signing secret, which is what makes an end-user token
+ * verifiable — and what keeps it verifiable ONLY by the service that owns the
+ * database (ADR-05, research R1). The gateway never sees this. */
+export async function environmentSigningSecret(
+ db: Db,
+ environmentId: string,
+): Promise<{ signingSecret: string; kind: EnvironmentKind } | null> {
+ const [row] = await db
+ .select({
+ signingSecret: environments.signingSecret,
+ kind: sql<EnvironmentKind>`${environments.kind}`,
+ })
+ .from(environments)
+ .where(eq(environments.id, environmentId));
+ return row ?? null;
+}
+
/** What a signup produced — or found. `created` answers "was an organisation
* created on this call?", NOT "was the identity new": a known human who owned
* nothing gets `created: true`, because one really was created for them. */
export interface Provisioned {
organisation: { id: string; name: string };
application: { id: string; name: string };
environment: { id: string; kind: Environment["kind"] };
human: { id: string; provider: string; provider_account_id: string };
created: boolean;
+ /** Research R8: the environment's FIRST key, present only when
+ * this call created the tenant. With no console session, nothing else can
+ * bootstrap a credential — a brand-new organisation cannot authenticate a
+ * request to ask for one. A returning owner gets no key, because the old
+ * secret is unrecoverable and the answer to a lost secret is rotation. */
+ apiKey?: { prefix: string; secret: string };
}
/** Signup (FR-TEN-01/02). The admin surface's second entrance:
* it mints a tenant, so like createEnvironment it carries no tenant scope —
* it is the operation that creates one.
*
@@ -226,18 +381,26 @@ export async function provisionOrganisation(
await tx.insert(memberships).values({
organisationId,
humanId: human.id,
role: "owner", // FR-TEN-07's vocabulary; management of it is later
});
+ // The first credential, inside the same transaction as the tenant it
+ // belongs to. A key written outside this transaction could
+ // outlive a rolled-back organisation and authenticate against nothing.
+ // FR-DSH-01 wants a development key on the first screen after signup; this
+ // is where it comes from.
+ const key = await createApiKey(tx, { environmentId });
+
return {
organisation: { id: organisationId, name: organisationName },
application: { id: applicationId, name: organisationName },
environment: { id: environmentId, kind: "development" as const },
human,
created: true,
+ apiKey: { prefix: key.prefix, secret: key.credential },
};
});
}
export interface UserRow {
id: string;Lần đi bộ từ chương tenancy giờ trưng ra thứ nó trao đi, và — hữu ích hơn — thứ nó không trao đi ở lần thứ hai:
$ node scripts/signup-walk.mjs
first authentication → GET /auth/github/callback
200 created=true
organisation cc19528d-a1f7-4d16-b25a-e74b812922d1
application 85184e51-c6e5-4b48-804a-a7a25687f4b7
environment 98a995d7-fa28-442a-b67d-782ae08c002a (development)
api key rk_dev_f373520fba4… (shown once)
second authentication → GET /auth/github/callback
200 created=false
organisation cc19528d-a1f7-4d16-b25a-e74b812922d1
application 85184e51-c6e5-4b48-804a-a7a25687f4b7
environment 98a995d7-fa28-442a-b67d-782ae08c002a (development)
api key none — the secret was shown at creation and is goneMột người chủ quay lại thì không được trao một secret mới. Cái cũ không phục hồi được theo cấu tạo, và câu trả lời cho một key bị mất là xoay vòng, chứ không phải lấy lại — và đó chỉ là một câu trả lời dùng được bởi nhiều key có thể cùng hoạt động một lúc.
Token mà api ký, và gateway thì không
Token người dùng cuối được ký bằng chính signing_secret của environment. Cột
đó sống trong Postgres, và ADR-05 nói gateway không bao giờ chạm Postgres.
Có hai lối ra. Ship signing secret của mọi environment tới gateway, hoặc hỏi cái service sở hữu chúng. Ship nghĩa là một secret theo phạm vi tenant nằm bên trong một tiến trình cố ý chẳng giữ trạng thái tenant nào, nhân lên theo mọi instance gateway, cộng thêm một câu chuyện xoay vòng cho khi một cái đổi. Hỏi thì tốn một lời gọi HTTP.
Và gateway thì vốn đã đang thực hiện một lời gọi. Từ 2.5 nó đã hỏi api "user này được nghe những channel nào?" ở lúc connect. Thay câu hỏi đó bằng "đây là ai, và họ được nghe gì?" thì tốn đúng cái vòng đi về ấy và dời quyết định về danh tính sang service duy nhất đưa ra được nó.
sequenceDiagram
participant C as Client
participant G as Gateway
participant A as API service
participant DB as PostgreSQL
C->>G: upgrade /v1/ws?token=eyJ…
Note over G: KHÔNG giữ signing secret<br/>sau chương này
G->>A: POST /internal/session<br/>Authorization: Bearer (the same token)
A->>DB: environments.signing_secret cho env claim của token
A->>A: verify HS256 · check sub/env/iat/exp (FR-AUT-06/07/08)
A->>DB: channels user này thuộc về
A-->>G: 200 { environment_id, user, channel_ids }
G-->>C: connection.ack
Note over G,A: MỘT call, đúng call mà 2.5 đã dùng cho<br/>memberships. Connect path không thêm round trip —<br/>nó chỉ ngừng hỏi câu nhỏ hơn (research R1)import { SignJWT, decodeJwt, jwtVerify } from "jose";
// End-user tokens (FR-AUT-06/07/08). HS256 over the
// environment's own signing secret, verified by `jose` rather than by hand.
//
// WHY a dependency here, when the tenancy chapter went out of its way to add none:
// hand-rolled
// HS256 is thirty lines and three classic vulnerabilities — accepting whatever
// algorithm the token names, forgetting to check `exp`, and comparing
// signatures with `===`. A convenience is worth typing around; security code is
// not (research R4).
/** FR-AUT-07. Enforced at BOTH ends: this api will not mint a longer-lived
* token, and will not accept one either — including one signed with a secret it
* trusts, because a secret can leak and a bound that only applies at mint time
* is a bound on well-behaved callers. */
export const MAX_TOKEN_LIFETIME_SECONDS = 86_400;
const ALGORITHMS = ["HS256"] as const;
export interface TokenClaims {
sub: string;
env: string;
iat: number;
exp: number;
}
export interface MintOptions {
user: string;
environmentId: string;
ttlSeconds: number;
/** Overridden only by tests that need a token issued in the past. */
issuedAt?: number;
/** Test-only escape hatch: mint a token this api would refuse, so the refusal
* can be proven rather than assumed. Never set by product code. */
allowOverLongLifetime?: boolean;
}
export async function mintUserToken(
signingSecret: string,
{
user,
environmentId,
ttlSeconds,
issuedAt,
allowOverLongLifetime = false,
}: MintOptions,
): Promise<{ token: string; expiresAt: string }> {
if (!allowOverLongLifetime && ttlSeconds > MAX_TOKEN_LIFETIME_SECONDS) {
throw new Error(
`a token may not live longer than 24 hours (${MAX_TOKEN_LIFETIME_SECONDS}s)`,
);
}
const iat = issuedAt ?? Math.floor(Date.now() / 1000);
const exp = iat + ttlSeconds;
const token = await new SignJWT({ env: environmentId })
.setProtectedHeader({ alg: "HS256" })
.setSubject(user)
.setIssuedAt(iat)
.setExpirationTime(exp)
.sign(new TextEncoder().encode(signingSecret));
return { token, expiresAt: new Date(exp * 1000).toISOString() };
}
/** Reads the environment claim WITHOUT verifying anything, because the api has
* to know which environment's secret to check the signature with before it can
* check it. That is the only thing this function is for: the lookup is by
* claim, the trust is by signature (data-model). */
export function environmentClaim(token: string): string | null {
try {
const claims = decodeJwt(token);
return typeof claims.env === "string" && claims.env.length > 0
? claims.env
: null;
} catch {
return null;
}
}
/** Null for every failure, with no distinction between them: expired,
* malformed, mis-signed and foreign are one answer to a caller (FR-AUT-08's
* refusal), and telling them apart in a response body is how an attacker learns
* which half of a guess was right. */
export async function verifyUserToken(
token: string,
signingSecret: string,
environmentId: string,
): Promise<TokenClaims | null> {
try {
const { payload } = await jwtVerify(
token,
new TextEncoder().encode(signingSecret),
// The allow-list is the whole defence against algorithm confusion: `jose`
// refuses `alg: none` and any algorithm not named here BEFORE it looks at
// key material. Passing the token's own header back as the algorithm is
// the vulnerability this line exists to make impossible.
{ algorithms: [...ALGORITHMS] },
);
const { sub, env, iat, exp } = payload;
if (typeof sub !== "string" || sub.length === 0) return null;
// Non-empty, and the environment we verified against. An empty env claim
// would be a session scoped to no tenant, which constitution I says must be
// unrepresentable rather than unlikely (2.5 found this the same way).
if (typeof env !== "string" || env !== environmentId) return null;
if (typeof iat !== "number" || typeof exp !== "number") return null;
if (exp - iat > MAX_TOKEN_LIFETIME_SECONDS) return null;
return { sub, env, iat, exp };
} catch {
// Includes the expiry check: `jose` throws on an expired token rather than
// returning it, so `exp` is enforced by the library and not by a comparison
// this file could forget to write.
return null;
}
}Có một sự tinh tế trong thứ tự các thao tác. Api phải biết secret của environment nào để đem đi kiểm một chữ ký, và nơi duy nhất mà thông tin ấy tồn tại là bên trong token — chưa được xác minh. Nên tuyên bố về environment được đọc trước, không tin, thuần tuý để chọn khoá; rồi chữ ký mới quyết định có tin nó hay không. Đọc tuyên bố ấy sau khi xác minh thì bạn chẳng xác minh được gì; tin tuyên bố ấy cho bất cứ thứ gì khác thì một token từ environment A sẽ đi thẳng vào environment B.
Route lắp tất cả lại thì thay thế GET /internal/memberships hoàn toàn:
import {
Controller,
HttpCode,
Inject,
Post,
Req,
UnauthorizedException,
UseGuards,
} from "@nestjs/common";
import type { InternalSessionResponse } from "@relay/protocol";
import { AUTH_DB } from "../auth/authenticate.middleware";
import { Accepts, CredentialGuard } from "../auth/credential.guard";
import type { RequestWithPrincipal } from "../auth/principal";
import type { Db } from "../db/client";
import { Repository } from "../db/repository";
// `POST /internal/session` — the route that replaced
// `GET /internal/memberships`.
//
// It answers the gateway's only question at connect: who is this, and what may
// they hear? Both halves used to be answered in two different places — the
// gateway verified the token locally and then asked the api for memberships.
// Now the api does both, in the call the gateway was already making, so the
// connect path costs exactly what it cost before (research R1).
//
// WHY IT IS A POST when it reads nothing: it presents a credential for
// verification, and a credential does not belong in a URL — NFR-SEC-06 forbids
// exactly that. The token arrives in the Authorization header like everywhere
// else in this api; the POST is about not having a cacheable, loggable GET of a
// credential-bearing request.
@Controller("internal")
@Accepts("user")
@UseGuards(CredentialGuard)
export class SessionController {
constructor(
@Inject(AUTH_DB) private readonly db: Db,
private readonly repo: Repository,
) {}
@Post("session")
// 200: nothing is created. The POST is about keeping a credential out of a
// URL, not about creating a resource (contracts).
@HttpCode(200)
async session(
@Req() req: RequestWithPrincipal,
): Promise<InternalSessionResponse> {
const principal = req.principal;
// The guard has already refused an absent, invalid or wrong-class
// credential, so reaching here with anything else is a wiring fault rather
// than a client error.
if (principal?.kind !== "user") {
throw new UnauthorizedException("a verified end-user token is required");
}
const user = await this.repo.getUserByExternalId(principal.userExternalId);
// A verified token for a user this environment has never seen is not an
// error: it is a user with no channels. The gateway's job is delivery, not
// identity forensics — 2.5's rule, and the reason a first connect from a
// brand-new user works before anything is seeded.
return {
environment_id: principal.environmentId,
user: principal.userExternalId,
channel_ids: user ? await this.repo.channelsForUser(user.id) : [],
};
}
}Và cánh cửa của gateway thành một cuộc điện thoại:
@@ -1,42 +1,59 @@
-import { jwtVerify } from "jose";
+import type { ApiClient, Identity } from "./api-client.js";
-// The door (chapter 2.5). Tokens are verified BEFORE the upgrade
-// completes — an unauthenticated socket never reaches session code.
+// The door (chapter 2.5, rebuilt by the credentials chapter). Tokens are still
+// checked BEFORE the
+// handshake completes — an unauthenticated socket never reaches session code —
+// but the gateway no longer does the checking.
//
-// DECISION (chapter 2.5): real tokens are minted by Part 3 (the dev-token
-// endpoint is FR-AUT-09; per-environment signing secrets live in the
-// environments table). Until then, dev tokens are HS256 over
-// RELAY_DEV_JWT_SECRET with claims { sub: user external_id,
-// env: environment_id } — a seam Part 3 replaces without touching the
-// session code behind it.
+// WHAT CHANGED, and why it is a narrowing rather than a complication: 2.5's
+// gateway verified tokens itself, with HS256 over a shared development secret.
+// Real tokens are signed with the ENVIRONMENT'S OWN secret, and that secret
+// lives in Postgres — which ADR-05 says this service may never touch. The two
+// ways out were to ship every environment's signing secret to the gateway, or to
+// ask the service that owns them. Shipping the secret means a tenant-scoped
+// secret inside a process that deliberately holds no tenant state, plus a
+// rotation story; asking costs one HTTP call the connect path was ALREADY making
+// for memberships (research R1).
+//
+// So the round-trip count is unchanged: the memberships lookup became a session
+// lookup that answers identity and memberships together. The gateway holds no
+// signing secret after this chapter.
-export const DEV_JWT_SECRET = process.env.RELAY_DEV_JWT_SECRET ?? "dev-secret";
+export type { Identity } from "./api-client.js";
-export interface Identity {
- userExternalId: string;
- environmentId: string;
-}
+/** Three outcomes, not two. A refused token and an unreachable api both fail to
+ * open a socket, but they are not the same event and must not close the same
+ * way: 4001 tells a client its credential is wrong (retrying will not help),
+ * 1011 tells it we are broken (retrying will). 2.5 drew that line for the
+ * memberships lookup; moving verification here must not erase it. */
+export type Authentication =
+ | { outcome: "ok"; identity: Identity; channelIds: string[] }
+ | { outcome: "refused" }
+ | { outcome: "unavailable"; error: string };
-export async function verifyToken(token: string): Promise<Identity | null> {
+export async function authenticate(
+ api: ApiClient,
+ token: string | null,
+): Promise<Authentication> {
+ if (token === null || token.length === 0) return { outcome: "refused" };
try {
- const { payload } = await jwtVerify(
- token,
- new TextEncoder().encode(DEV_JWT_SECRET),
- );
- // Non-EMPTY strings: `typeof x === "string"` happily accepts "", and an
- // empty environment claim would open a session scoped to no tenant —
- // which constitution I says must be unrepresentable, not merely
- // unlikely. Found by the test below, not by reading the code.
- if (
- typeof payload.sub !== "string" ||
- payload.sub.length === 0 ||
- typeof payload.env !== "string" ||
- payload.env.length === 0
- ) {
- return null;
- }
- return { userExternalId: payload.sub, environmentId: payload.env };
- } catch {
- return null;
+ const session = await api.session(token);
+ // The api answered, and the answer was "no". Every refusal — expired,
+ // malformed, mis-signed, for another environment, over-long — arrives here
+ // as one outcome, because the socket has one close code for all of them.
+ if (session === null) return { outcome: "refused" };
+ return {
+ outcome: "ok",
+ identity: {
+ environmentId: session.environment_id,
+ userExternalId: session.user,
+ // Carried, not trusted: the internal hop forwards this instead of
+ // asserting an identity the gateway invented.
+ token,
+ },
+ channelIds: session.channel_ids,
+ };
+ } catch (error) {
+ return { outcome: "unavailable", error: String(error) };
}
}Hãy để ý cái kiểu đó nói gì. Ba kết cục, không phải hai — bởi dời việc xác minh sang api đã sinh ra một kiểu thất bại mà gateway chưa từng có: bên xác minh chết. Một token bị từ chối và một api không với tới được đều làm một socket không mở được, và trả lời chúng theo cùng một cách sẽ là một lời nói dối theo một hướng. 4001 nói với một client rằng credential của nó sai, nên hãy thôi thử lại; 1011 nói với nó rằng chúng ta đang hỏng, nên hãy thử lại. Chương 2.5 đã kẻ ra sự phân biệt đó cho lần tra memberships, và dời lần tra ấy thì không được lặng lẽ xoá nó đi.
Việc xác thực chạy ở đâu, và làm sao tôi biết
Chương 2.6 tốn của tôi một buổi chiều để học rằng NestJS dựng các provider theo phạm vi request trước khi chuỗi enhancer chạy — và đó là lý do nhà máy repository của 2.2 tự đọc request thay vì tin rằng một guard đã cất sẵn thứ gì đó. Phát hiện ấy ràng buộc chương này: việc xác thực giờ liên quan tới một lần tra database, và một guard vẫn sẽ chạy quá muộn với cái nhà máy cần câu trả lời của nó.
Middleware chạy sớm hơn. Nhưng "chạy sớm hơn" là thứ tôi tin chứ không phải một thứ tôi đã đo trên chính đường này, nên trước khi dựng lên trên nó tôi ghi một dòng log từ mỗi chỗ trong ba chỗ rồi gửi một request duy nhất:
DIAG middleware
DIAG repository-factory
DIAG guardMiddleware, rồi nhà máy, rồi guard. Thiết kế đứng vững, phương án dự phòng đã gọi tên thì không cần, và phép đo tốn bốn phút.
flowchart LR
req["request đến"]
mw["RequestContextMiddleware<br/>then AuthenticateMiddleware<br/>→ req.principal"]
fac["request-scoped Repository factory<br/>đọc principal.environmentId"]
grd["CredentialGuard<br/>class NÀY có được dùng route NÀY không?"]
hnd["handler"]
req --> mw --> fac --> grd --> hnd
measured["Đo được, không giả định (T004):<br/>middleware → factory → guard.<br/>2.6 thấy factory chạy trước enhancer chain,<br/>nên authentication không thể nằm trong guard"]
fac -.-> measuredimport { Inject, Injectable, type NestMiddleware } from "@nestjs/common";
import type { Db } from "../db/client";
import {
authenticateApiKey,
environmentSigningSecret,
} from "../db/repository";
import { looksLikeApiKey } from "./api-key";
import { bearerCredential, type Principal, type RequestWithPrincipal } from "./principal";
import { environmentClaim, verifyUserToken } from "./user-token";
export const AUTH_DB = "AUTH_DB";
/** Credential in, principal out. The one function that decides who a caller is;
* everything else in the api reads its answer.
*
* The two classes are told apart by the PREFIX, before either is verified —
* that is what FR-AUT-03's visible `rk_` buys beyond human readability.
*
* A token's environment claim is read UNVERIFIED, because the api has to know
* which environment's secret to check the signature with. The claim chooses the
* key; the signature decides whether to believe the claim. Getting that order
* backwards is how a token from environment A gets accepted for environment B.
*/
export async function resolvePrincipal(
db: Db,
credential: string,
): Promise<Principal | null> {
if (looksLikeApiKey(credential)) {
const key = await authenticateApiKey(db, credential);
return key
? {
kind: "application",
environmentId: key.environmentId,
keyId: key.keyId,
}
: null;
}
const environmentId = environmentClaim(credential);
if (!environmentId) return null;
const environment = await environmentSigningSecret(db, environmentId);
if (!environment) return null;
const claims = await verifyUserToken(
credential,
environment.signingSecret,
environmentId,
);
return claims
? { kind: "user", environmentId, userExternalId: claims.sub }
: null;
}
/** Authentication runs in MIDDLEWARE, and that is a measured decision rather
* than a preference (research R5, verified in T004). Chapter 2.6 found that
* Nest constructs request-scoped providers BEFORE the enhancer chain, so a
* guard cannot be the thing that resolves tenant scope — whatever it stashes on
* the request is invisible to the factory that needs it. The observed order on
* this code path is:
*
* middleware -> request-scoped factory -> guard
*
* which is exactly enough room: the middleware resolves the principal, the
* factory reads `req.principal.environmentId`, and the guard is left with the
* narrower job it is actually good at — deciding whether this route accepts
* this class of credential.
*
* It NEVER throws. A request that presents nothing simply has no principal, and
* pre-credential routes (signup, health) are reached that way on purpose. A
* request that presents something invalid also has no principal, so "absent"
* and "does not verify" arrive at the same 401 — which is all a caller should
* be able to learn.
*/
@Injectable()
export class AuthenticateMiddleware implements NestMiddleware {
constructor(@Inject(AUTH_DB) private readonly db: Db) {}
async use(
req: RequestWithPrincipal,
_res: unknown,
next: () => void,
): Promise<void> {
const credential = bearerCredential(req.headers);
if (credential !== null) {
const principal = await resolvePrincipal(this.db, credential);
if (principal !== null) req.principal = principal;
}
next();
}
}Cái guard còn lại thì nhỏ hơn cái nó thay thế. EnvironmentContextGuard
phân giải một tenant từ một header; CredentialGuard chẳng phân giải gì. Nó hỏi một
câu — lớp credential này có được dùng route này không? — và tạo ra hai
lời từ chối mà chương này nói về.
import {
ForbiddenException,
Injectable,
SetMetadata,
UnauthorizedException,
type CanActivate,
type ExecutionContext,
} from "@nestjs/common";
import { Reflector } from "@nestjs/core";
import {
describePrincipalKind,
type PrincipalKind,
type RequestWithPrincipal,
} from "./principal";
const ACCEPTS = "relay:accepts";
/** What a route accepts, declared on the route (research R6). The default is
* "either class", so a handler only says something when it is narrower than
* that — and the narrow cases are the interesting ones: FR-AUT-09's dev-token
* endpoint and FR-AUT-10's administrative operations want an API key
* specifically, not merely a valid credential. */
export const Accepts = (...kinds: PrincipalKind[]) => SetMetadata(ACCEPTS, kinds);
const EITHER: PrincipalKind[] = ["application", "user"];
function expectation(kinds: PrincipalKind[]): string {
return kinds.map(describePrincipalKind).join(" or ");
}
/** The guard that used to be `EnvironmentContextGuard` (2.2), doing a smaller
* job. It no longer resolves anything — the middleware did that, from a
* credential rather than from a header the caller asserted — so all that is left
* is the question a guard can actually answer: may THIS class of credential use
* THIS route?
*
* The two refusals it produces are the chapter's subject:
*
* 401 — nothing valid was presented. Absent and invalid are the same answer.
* 403 `wrong_credential_type` — a perfectly good credential of the wrong kind.
*
* The 403's message names the CLASS presented and the class expected, and never
* the credential: "the key rk_dev_abc… is not valid" is how a live secret ends
* up in a support ticket (NFR-SEC-06, research R9).
*/
@Injectable()
export class CredentialGuard implements CanActivate {
constructor(private readonly reflector: Reflector) {}
canActivate(context: ExecutionContext): boolean {
const accepted =
this.reflector.getAllAndOverride<PrincipalKind[]>(ACCEPTS, [
context.getHandler(),
context.getClass(),
]) ?? EITHER;
const req = context.switchToHttp().getRequest<RequestWithPrincipal>();
const principal = req.principal;
if (!principal) {
throw new UnauthorizedException(
`this route requires a credential: ${expectation(accepted)}, presented as "Authorization: Bearer …"`,
);
}
if (!accepted.includes(principal.kind)) {
throw new ForbiddenException({
code: "wrong_credential_type",
message: `this route expects ${expectation(accepted)}; ${describePrincipalKind(
principal.kind,
)} was presented`,
});
}
return true;
}
}Sai lầm mà SRS đã tiên đoán
SRS có một ghi chú thiết kế dưới các yêu cầu về xác thực, và nó cụ thể một cách bất thường:
Lẫn lộn giữa API key và user token là kiểu thất bại phổ biến nhất ở lần tích hợp đầu tiên. FR-AUT-03 và FR-AUT-09 tồn tại chính là để giảm bớt nó, và thông điệp lỗi cho việc dùng sai loại credential phải gọi tên sai lầm ấy một cách rõ ràng.
Một 401 unauthorized chung chung thì thoả mãn mã status và trượt hoàn toàn
yêu cầu đó. Một người đang cầm một credential hoàn toàn hợp lệ, trình ra
ở sai cửa, thì chẳng học được gì từ việc bị bảo rằng họ không được phép — họ
sẽ đi kiểm secret, xoay key, rồi đọc lại tài liệu, mà chẳng cái nào trong đó
là vấn đề.
Nên sai lớp thì nhận mã riêng của nó và một thông điệp gọi tên cả hai nửa:
{
"code": "wrong_credential_type",
"message": "this route expects an API key; an end-user token was presented",
"docs_url": "https://relay.dev/docs/error-reference#wrong_credential_type"
}Hãy để ý thông điệp không chứa gì. Nó gọi tên lớp, chứ không bao giờ gọi tên
credential. "Key rk_dev_abc… không hợp lệ" chính là cách một secret sống tới được một
phiếu hỗ trợ, một ảnh chụp màn hình, và một bộ gom log, mà NFR-SEC-06 cấm
đúng chuyện đó. Có một bài test grep mọi dòng log và mọi body lỗi
tạo ra trong một loạt thất bại cố ý, và đỏ nếu bất kỳ cái nào trong chúng
chứa một secret.
Lần đi bộ trưng ra cả hai credential đều chạy và cả hai lời từ chối đều vững:
$ node scripts/credential-walk.mjs
api key (shown once) rk_dev_2777b9f8ef4…
its prefix rk_dev_
POST with the key 201 seq=1
dev-token 200 expires 2026-08-08T14:30:59.000Z
socket with the token open as tuan
token → key-only route 403 wrong_credential_type
"this route expects an API key; an end-user token was presented"
key → socket closed 4001
heard as tuan; both credentials worked, both refusals held.Cái endpoint nằm giữa bản ghi đó là của FR-AUT-09, và nó tồn tại vì
một lý do: không có nó thì message đã xác thực đầu tiên của một lập trình viên bị chặn
sau việc phải cài đặt phần mint JWT. Nó nhận một API key và trả về một token. Nó
chỉ sống trong development — và nó trả lời 404 trong production chứ không phải
403, một cách có chủ đích. Một 403 nói bạn thiếu một quyền, thứ đó mời ai đó
đi tìm cái quyền sẽ mở khoá nó. Một 404 nói route này
không tồn tại ở đây, và đó là sự thật.
import {
BadRequestException,
Body,
Controller,
HttpCode,
Inject,
NotFoundException,
Post,
Req,
UseGuards,
} from "@nestjs/common";
import { z } from "zod";
import type { Db } from "../db/client";
import { environmentSigningSecret } from "../db/repository";
import { AUTH_DB } from "./authenticate.middleware";
import { Accepts, CredentialGuard } from "./credential.guard";
import type { RequestWithPrincipal } from "./principal";
import { MAX_TOKEN_LIFETIME_SECONDS, mintUserToken } from "./user-token";
import { ZodValidationPipe } from "../messages/zod-validation.pipe";
// FR-AUT-09: the development-only endpoint that turns an API key into an
// end-user token. It exists so a developer reaches a first authenticated
// message before writing any token-signing code of their own — the alternative
// being a quickstart that starts with "implement JWT minting".
//
// The signing secret never leaves the api, which is the same reason the gateway
// asks rather than verifies (research R1): a per-environment secret handed to a
// second process is a secret in two places.
const devTokenRequestSchema = z.object({
user: z.string().min(1),
ttl_seconds: z.number().int().positive().max(MAX_TOKEN_LIFETIME_SECONDS).optional(),
});
type DevTokenRequest = z.infer<typeof devTokenRequestSchema>;
const DEFAULT_TTL_SECONDS = 3600;
@Controller("auth")
export class DevTokenController {
constructor(@Inject(AUTH_DB) private readonly db: Db) {}
@Post("dev-token")
// 200, not Nest's default 201 for a POST: nothing was created. The token is
// derived from a key that already existed, and the contract says 200.
@HttpCode(200)
// An API key and nothing else. A route that minted end-user tokens from an
// end-user token would let a leaked token extend itself indefinitely, and no
// requirement asks for it (research R8's rejected alternative, same shape).
@Accepts("application")
@UseGuards(CredentialGuard)
async mint(
@Body(new ZodValidationPipe(devTokenRequestSchema)) body: DevTokenRequest,
@Req() req: RequestWithPrincipal,
): Promise<{ token: string; expires_at: string }> {
const principal = req.principal!;
const environment = await environmentSigningSecret(
this.db,
principal.environmentId,
);
if (!environment) throw new BadRequestException("unknown environment");
// 404 and not 403, deliberately (contracts). This is not a permission the
// caller lacks — it is a development affordance that does not exist in
// production, and a 403 would invite someone to go looking for the
// permission that would unlock it.
if (environment.kind !== "development") {
throw new NotFoundException("Cannot POST /auth/dev-token");
}
const { token, expiresAt } = await mintUserToken(environment.signingSecret, {
user: body.user,
environmentId: principal.environmentId,
ttlSeconds: body.ttl_seconds ?? DEFAULT_TTL_SECONDS,
});
// snake_case on the wire, camelCase inside — the same boundary rule every
// other response in this service follows.
return { token, expires_at: expiresAt };
}
}Cả hai đường nối, biến mất
Cái header trước. Cho nó nghỉ hưu là một bước tăng duy nhất bởi một đường nối nghỉ hưu nửa vời thì tệ hơn cả hai trạng thái, và bán kính vụ nổ được đo chứ không được đoán: mười một file, trong đó ba là đường code sản phẩm còn phần còn lại là các suite trình ra cái header hoặc các header danh tính đi kèm nó.
Secret phát triển dùng chung của gateway là cái thứ hai, và nó rơi vào bảy file nữa: cánh cửa của gateway, hai suite của nó, harness e2e, và ba script đi bộ vốn đều tự mint token của mình bằng một secret mà chúng biết.
Phép kiểm thì cơ học, và tôi viết nó để kỳ vọng số không chứ không phải "chỉ còn vài cái":
$ grep -rn "x-relay-environment" services packages scripts --include=*.ts --include=*.mjs | grep -v itest
$ grep -rn "RELAY_DEV_JWT_SECRET\|DEV_JWT_SECRET" services --include=*.ts | grep -v test
$Chẳng gì. Không phải "chẳng gì ngoài các lời chú thích" — các lời chú thích mô tả đường nối đã nghỉ hưu thì mô tả mà không trích dẫn nó, nên phép kiểm chẳng cần ngoại lệ nào để quay về sạch. Một phép kiểm cần một lời rào là một phép kiểm mà rồi sẽ có ai đó giải thích cho qua.
Cái file khởi đầu tất cả cũng ra đi cùng chúng:
@@ -1,28 +0,0 @@
-import {
- type CanActivate,
- type ExecutionContext,
- Injectable,
- UnauthorizedException,
-} from "@nestjs/common";
-
-// DECISION (chapter 2.2): real credentials arrive with Part 3 (API keys in
-// the credentials chapter, tenancy in the tenancy chapter). Until then,
-// public routes name their tenant via the X-Relay-Environment header — a
-// dev-mode seam, load-bearing for exactly as long as it takes Part 3 to
-// replace it. The guard resolves the header; the request-scoped Repository
-// below it is what makes the scoping real (constitution I).
-@Injectable()
-export class EnvironmentContextGuard implements CanActivate {
- canActivate(context: ExecutionContext): boolean {
- const req = context.switchToHttp().getRequest<{
- headers: Record<string, string | undefined>;
- environmentId?: string;
- }>();
- const environmentId = req.headers["x-relay-environment"];
- if (!environmentId) {
- throw new UnauthorizedException("missing X-Relay-Environment");
- }
- req.environmentId = environmentId;
- return true;
- }
-}Và cái nhà máy mà 2.2 viết thì đọc một principal ở chỗ nó từng đọc một header. Nó là lần đổi một dòng mà chương ấy đã hứa, và lý do nó vẫn chỉ là một dòng là bởi đường nối đã được gọi tên và cô lập từ ngày nó được đưa vào:
@@ -1,39 +1,47 @@
import { Module, Scope } from "@nestjs/common";
import { REQUEST } from "@nestjs/core";
+import { AuthModule } from "../auth/auth.module";
import { createDb, createPool, type Db } from "../db/client";
import type { RequestWithTenant } from "./request-with-tenant";
import { Repository } from "../db/repository";
-import { EnvironmentContextGuard } from "./environment-context.guard";
import { MessagesController } from "./messages.controller";
import { MessagesService } from "./messages.service";
// The repository stays the plain 2.1 class — the framework's job is only
// to construct it per request with the authenticated tenant (ADR-15's
// scope note: guards authenticate, the data layer isolates).
@Module({
+ imports: [AuthModule],
controllers: [MessagesController],
providers: [
{
provide: "DB",
useFactory: (): Db => createDb(createPool()),
scope: Scope.DEFAULT,
},
{
provide: Repository,
scope: Scope.REQUEST,
inject: ["DB", REQUEST],
useFactory: (db: Db, req: RequestWithTenant) =>
- // The FACTORY reads the header, not the guard's leftovers: Nest
+ // The FACTORY reads the PRINCIPAL, not the guard's leftovers: Nest
// resolves request-scoped providers BEFORE the enhancer chain
// runs, so anything a guard stashes on the request is invisible
- // here. The guard still rejects tenant-less requests (401); the
- // factory is what scopes the layer.
- new Repository(db, req.headers["x-relay-environment"] ?? ""),
+ // here. Middleware runs earlier still, which is why the credentials chapter
+ // authenticates there (research R5, measured in T004).
+ //
+ // The credentials chapter changed WHERE the environment comes from and nothing
+ // else about this line. It used to be an environment header — a
+ // header any caller could type. It is now the environment resolved
+ // from a verified credential, so a request cannot name a tenant it
+ // has not proved it may act for. The empty-string fallback is the
+ // same as 2.2's: no principal means no scope, and the guard below
+ // turns that into a 401 before any handler runs.
+ new Repository(db, req.principal?.environmentId ?? ""),
},
MessagesService,
- EnvironmentContextGuard,
],
- exports: [Repository, MessagesService, EnvironmentContextGuard],
+ exports: [Repository, MessagesService],
})
export class MessagesModule {}Thứ chương này để lại cho sau, một cách có chủ đích
Một token đã làm mới trên một kết nối đang sống (FR-AUT-11, điều khoản thứ hai). Một socket đã lập sống lâu hơn token của nó — việc xác minh xảy ra lúc connect, và chẳng bộ đếm giờ nào kiểm lại nó. Đó là điều khoản thứ nhất, và nó đã được test. Nhưng bước nhảy nội bộ thì chuyển tiếp chính cái token ấy, nên một client giữ một socket mở quá hạn token của nó thì vẫn nhận được và không còn gửi được. Thay vì để chuyện đó đỏ thành một "lỗi nội bộ", socket nói ra thứ mà một client thực sự làm được:
{
"type": "error",
"payload": {
"code": "unauthorized",
"message": "the token this connection was opened with has expired; reconnect with a fresh one to send again",
"docs_url": "https://relay.dev/docs/error-reference#unauthorized"
}
}Đóng lỗ hổng đó cho đàng hoàng thì cần một frame giao thức — một cách để client trao một token mới trên kết nối đang mở — và đó là một thay đổi lên đường truyền, còn hình dạng của đường truyền thì thuộc về cái SDK nói nó.
Giới hạn tần suất cho các lần xác thực thất bại (FR-AUT-12). Hạn mức và giới hạn tần suất là chủ đề của chương retry-and-disable, và dựng nửa cái bộ giới hạn ở đây sẽ nghĩa là dựng nó hai lần.
Quản lý key. Tạo, liệt kê và thu hồi key qua một API thì cần một phiên console để xác thực người đang làm việc đó, và một phiên là chương dashboard ở Phần 5. Việc thu hồi hôm nay đã được cài đặt và test ở tầng repository; thứ còn thiếu là cánh cửa, không phải cơ chế.
Credential service-với-service trên bước nhảy nội bộ. Gateway và api vẫn tin cái mạng giữa chúng, đúng như chương 2.5 đã ghi lại. Thứ đã đổi là gateway không còn khẳng định một danh tính nữa: nó chuyển tiếp token của người dùng cuối và api quyết định. Ranh giới tin cậy đã dời theo đúng hướng; nó chưa biến mất.
Được thu hẹp bởi chương connection-metering. Gateway có được một credential của riêng nó —
RELAY_INTERNAL_CREDENTIAL_GATEWAY, một platform credential thuộc lớp mà
chương webhook dispatcher dựng cho dispatcher — bởi một báo cáo mức dùng chẳng phải hành động của user nào và
chẳng có token nào để chuyển tiếp. Nên một trong bốn lời gọi api của gateway giờ được
xác thực như một service chứ không được tin như một người hàng xóm. Ba lời gọi kia
vẫn chuyển tiếp token của người dùng cuối, và cái mạng giữa chúng vẫn được
tin.
Các bài test, và chúng giữ gì
Mười hai bất biến, mỗi cái có một cái tên nói nó bảo vệ gì. Chín cái chạy qua HTTP thật với Postgres, hai cái là thuần tuý, và một cái cần một socket:
$ pnpm --filter @relay/api test:integration src/auth/credentials.itest.ts
✓ invariant 1: a key's secret is returned once and is unrecoverable afterwards
✓ invariant 2: no credential is a 401 that names what the route expects
✓ invariant 3: the wrong class is a 403 naming presented and expected
✓ invariant 4: a foreign key sees nothing, and it looks exactly like absent
✓ invariant 5: a revoked key is refused on the very next request
✓ invariant 6: several active keys work at once, which is what rotation needs
✓ invariant 7: a token is refused when expired, malformed, mis-signed, foreign, or over-long
✓ invariant 9: the dev-token endpoint mints in development and does not exist in production
✓ invariant 11: no credential appears in a log line or an error body
✓ signup hands over exactly one key, and only when it creates something
Tests 10 passed (10)Bất biến 4 là cái tôi sẽ giữ nếu chỉ được giữ một. Một key cho environment A trình ra channel của environment B thì nhận một 404 — và bài test khẳng định rằng response ấy giống từng byte với response bạn nhận cho một channel chẳng tồn tại ở đâu. Sự cô lập mà làm lộ ra sự tồn tại của một người hàng xóm thì không phải sự cô lập; nó là một quyển danh bạ.
import "reflect-metadata";
import { Test } from "@nestjs/testing";
import type { INestApplication } from "@nestjs/common";
import { SignJWT } from "jose";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { AppModule } from "../app.module";
import { createDb, createPool, type Db } from "../db/client";
import {
createApiKey,
createEnvironment,
environmentSigningSecret,
provisionOrganisation,
Repository,
revokeApiKey,
} from "../db/repository";
import { MAX_TOKEN_LIFETIME_SECONDS } from "./user-token";
// The refusals, over real HTTP against the compose Postgres.
// Invariants 1-7, 9 and 11 of contracts/credentials.md live here; 8 and 12 are
// pure and live in the unit lane; 10 needs a socket and lives in the gateway's
// session.itest.ts.
//
// Every environment in this file is minted here. Two suites sharing an
// environment would let one suite's key see another's channels — 2.1's
// isolation property is what makes a test lane like this cheap.
describe("credentials", () => {
let app: INestApplication;
let url: string;
let db: Db;
let env: { id: string };
let key: { id: string; credential: string };
let channelId: string;
let foreign: { id: string };
let foreignKey: { credential: string };
let foreignChannelId: string;
let production: { id: string };
let productionKey: { credential: string };
const post = (body: unknown, credential?: string, channel = channelId) =>
fetch(`${url}/v1/channels/${channel}/messages`, {
method: "POST",
headers: {
"content-type": "application/json",
...(credential ? { authorization: `Bearer ${credential}` } : {}),
},
body: JSON.stringify(body),
});
const devToken = (credential: string, body: unknown = { user: "tuan" }) =>
fetch(`${url}/auth/dev-token`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${credential}`,
},
body: JSON.stringify(body),
});
/** A token this api would accept, or a deliberately broken variant of one —
* signed with the environment's own secret, the way the real minter does. */
const signToken = async (
over: {
env?: string;
sub?: string;
iat?: number;
exp?: number;
secret?: string;
} = {},
) => {
const now = Math.floor(Date.now() / 1000);
const secret =
over.secret ?? (await environmentSigningSecret(db, env.id))!.signingSecret;
return new SignJWT({ env: over.env ?? env.id })
.setProtectedHeader({ alg: "HS256" })
.setSubject(over.sub ?? "tuan")
.setIssuedAt(over.iat ?? now)
.setExpirationTime(over.exp ?? now + 3600)
.sign(new TextEncoder().encode(secret));
};
beforeAll(async () => {
db = createDb(createPool());
env = await createEnvironment(db, { name: "credentials-itest" });
key = await createApiKey(db, { environmentId: env.id });
const repo = new Repository(db, env.id);
channelId = (await repo.createChannel("general", "public")).id;
await repo.createUser("tuan", "Tuan");
foreign = await createEnvironment(db, { name: "credentials-itest-other" });
foreignKey = await createApiKey(db, { environmentId: foreign.id });
foreignChannelId = (
await new Repository(db, foreign.id).createChannel("theirs", "public")
).id;
production = await createEnvironment(db, {
name: "credentials-itest-prod",
kind: "production",
});
productionKey = await createApiKey(db, { environmentId: production.id });
app = (
await Test.createTestingModule({ imports: [AppModule] }).compile()
).createNestApplication({ logger: false });
await app.listen(0);
url = await app.getUrl();
});
afterAll(async () => {
await app.close();
});
it("invariant 1: a key's secret is returned once and is unrecoverable afterwards", async () => {
const minted = await createApiKey(db, {
environmentId: env.id,
name: "once",
});
const secret = minted.credential.split("_").at(-1)!;
// Nothing in the row it left behind contains what was returned. Read with
// a plain string rather than drizzle's `sql` helper: the query engine lives
// inside the repository layer and nowhere else (constitution I, ADR-16),
// and the lint rule that says so does not make an exception for tests.
const stored = JSON.stringify(
(
await db.execute(
`SELECT public_id, secret_hash, salt, prefix, name
FROM api_keys WHERE id = '${minted.id}'`,
)
).rows,
);
expect(stored).not.toContain(secret);
expect(stored).not.toContain(minted.credential);
// And it still works — unrecoverable is not the same as unusable.
expect((await post({ text: "with the new key" }, minted.credential)).status).toBe(
201,
);
});
it("invariant 2: no credential is a 401 that names what the route expects", async () => {
const res = await post({ text: "anonymous" });
expect(res.status).toBe(401);
// EIR-API-04's envelope is flat — { code, message, docs_url } — the same
// shape 2.2 established and the WebSocket error frame mirrors.
const body = (await res.json()) as { code: string; message: string };
expect(body.code).toBe("unauthorized");
expect(body.message.toLowerCase()).toMatch(/credential|api key|token/);
});
it("invariant 3: the wrong class is a 403 naming presented and expected", async () => {
// The chapter's subject: an end-user token presented to a route that wants
// an API key. Not a 401 — the credential is valid, it is the wrong KIND.
const token = await signToken();
const res = await devToken(token);
expect(res.status).toBe(403);
const body = (await res.json()) as { code: string; message: string };
expect(body.code).toBe("wrong_credential_type");
expect(body.message).toMatch(/API key/i);
expect(body.message).toMatch(/end-user token/i);
// Never the credential itself (NFR-SEC-06).
expect(body.message).not.toContain(token);
});
it("invariant 4: a foreign key sees nothing, and it looks exactly like absent", async () => {
const foreignAnswer = await post({ text: "trespass" }, foreignKey.credential);
const absentAnswer = await post(
{ text: "nowhere" },
key.credential,
"00000000-0000-0000-0000-000000000000",
);
expect(foreignAnswer.status).toBe(404);
expect(absentAnswer.status).toBe(404);
expect(await foreignAnswer.json()).toEqual(await absentAnswer.json());
// And the reverse direction, so the test cannot pass by both being broken.
expect(
(await post({ text: "mine" }, foreignKey.credential, foreignChannelId))
.status,
).toBe(201);
});
it("invariant 5: a revoked key is refused on the very next request", async () => {
const doomed = await createApiKey(db, {
environmentId: env.id,
name: "doomed",
});
expect((await post({ text: "before" }, doomed.credential)).status).toBe(201);
await revokeApiKey(db, doomed.id);
// No wait, no cache to expire: verification is a live query (research R7).
expect((await post({ text: "after" }, doomed.credential)).status).toBe(401);
});
it("invariant 6: several active keys work at once, which is what rotation needs", async () => {
const second = await createApiKey(db, {
environmentId: env.id,
name: "rotation",
});
expect((await post({ text: "old key" }, key.credential)).status).toBe(201);
expect((await post({ text: "new key" }, second.credential)).status).toBe(201);
});
it("invariant 7: a token is refused when expired, malformed, mis-signed, foreign, or over-long", async () => {
const now = Math.floor(Date.now() / 1000);
const read = (credential?: string) =>
fetch(`${url}/v1/channels/${channelId}/messages`, {
headers: credential ? { authorization: `Bearer ${credential}` } : {},
});
// A good one first, so the refusals below mean something.
expect((await read(await signToken())).status).toBe(200);
expect((await read(await signToken({ exp: now - 60, iat: now - 3600 }))).status)
.toBe(401);
expect((await read("eyJhbGciOiJIUzI1NiJ9.not-a-token")).status).toBe(401);
expect((await read(await signToken({ secret: "wrong-secret" }))).status).toBe(
401,
);
expect((await read(await signToken({ env: foreign.id }))).status).toBe(401);
expect(
(
await read(
await signToken({
iat: now,
exp: now + MAX_TOKEN_LIFETIME_SECONDS + 60,
}),
)
).status,
).toBe(401);
});
it("invariant 9: the dev-token endpoint mints in development and does not exist in production", async () => {
const minted = await devToken(key.credential);
expect(minted.status).toBe(200);
const body = (await minted.json()) as { token: string; expires_at: string };
expect(typeof body.token).toBe("string");
expect(Date.parse(body.expires_at)).toBeGreaterThan(Date.now());
// The token it minted is a usable credential, which is the whole point of
// the endpoint existing (FR-AUT-09).
expect(
(
await fetch(`${url}/v1/channels/${channelId}/messages`, {
headers: { authorization: `Bearer ${body.token}` },
})
).status,
).toBe(200);
// A production key gets a 404, not a 403: the route is not a permission
// this caller lacks, it is an affordance that does not exist there.
const refused = await devToken(productionKey.credential);
expect(refused.status).toBe(404);
// FR-AUT-07's bound is enforced at the endpoint too.
expect(
(await devToken(key.credential, { user: "tuan", ttl_seconds: 86_401 }))
.status,
).toBe(400);
expect((await devToken(key.credential, {})).status).toBe(400);
});
it("invariant 11: no credential appears in a log line or an error body", async () => {
// The one request-log line per request (1.4's middleware) plus every error
// envelope, captured while credentials are used and abused.
const captured: string[] = [];
const original = process.stdout.write.bind(process.stdout);
(process.stdout as unknown as { write: typeof original }).write = ((
chunk: string | Uint8Array,
...rest: unknown[]
) => {
captured.push(typeof chunk === "string" ? chunk : Buffer.from(chunk).toString());
return (original as (...args: unknown[]) => boolean)(chunk, ...rest);
}) as typeof original;
const token = await signToken();
const bodies: string[] = [];
try {
for (const attempt of [
post({ text: "logged" }, key.credential),
post({ text: "logged" }, `${key.credential}-tampered`),
post({ text: "logged" }, foreignKey.credential),
devToken(token),
post({ text: "logged" }),
]) {
bodies.push(await (await attempt).text());
}
} finally {
(process.stdout as unknown as { write: typeof original }).write = original;
}
const haystack = captured.join("") + bodies.join("");
const secret = key.credential.split("_").at(-1)!;
expect(haystack).not.toContain(key.credential);
expect(haystack).not.toContain(secret);
expect(haystack).not.toContain(foreignKey.credential);
expect(haystack).not.toContain(token);
// The prefix alone is not a secret and may legitimately appear.
});
it("signup hands over exactly one key, and only when it creates something", async () => {
// R8: with no console session, signup is the only thing that can bootstrap
// a first credential. The second call to the same identity must not mint a
// second one (FR-AUT-02: the old secret is gone, and rotation is the answer).
const account = `credentials-itest-${Date.now()}`;
const first = await provisionOrganisation(db, {
provider: "github",
providerAccountId: account,
organisationName: "first key co",
});
expect(first.created).toBe(true);
expect(first.apiKey).toBeDefined();
expect(first.apiKey!.secret.startsWith("rk_dev_")).toBe(true);
const again = await provisionOrganisation(db, {
provider: "github",
providerAccountId: account,
organisationName: "first key co",
});
expect(again.created).toBe(false);
expect(again.apiKey).toBeUndefined();
// And the key it did hand over works on the environment it belongs to.
const repo = new Repository(db, first.environment.id);
const channel = await repo.createChannel("signup-key", "public");
expect(
(await post({ text: "bootstrapped" }, first.apiKey!.secret, channel.id))
.status,
).toBe(201);
});
});Việc thu hồi đáng một câu về thứ không có ở đó. FR-AUT-05 đưa ra một giới hạn năm giây để một key bị thu hồi thôi chạy, thứ thường được đọc là "hãy vô hiệu cache của bạn đủ nhanh". Cách đọc rẻ hơn là đừng có cache nào: một lần tra có index cho mỗi request, trong chính cái connection pool mà request đã dùng, và giới hạn ấy đúng theo cấu tạo trên mọi instance mà chẳng có gì để vô hiệu. Nếu profiling có bao giờ biến đó thành nút thắt, cách sửa là một cache cộng một kênh vô hiệu hoá — và đó là tấm vải của chương 2.6 làm một việc thứ hai, và nó thuộc về bất kỳ chương nào đo được nhu cầu ấy chứ không phải chương này.
Bán kính vụ nổ, từng file một
Hai lần cho đường nối nghỉ hưu chạm tới hai mươi chín file mà các chương trước đã trưng ra cho bạn. Chúng đều ở đây, bởi một chương đổi đoạn code bạn đã chép mà không nói ra thì chính là cách một cuốn tutorial bắt đầu nói dối.
Phần lớn chỉ là ba dòng: một header đổi thành một bearer credential trong một suite, một script đi bộ hỏi api xin một token thay vì tự ký. Hãy đọc chúng như một thứ chứ không phải hai mươi chín — đây là cái giá của một đường nối, trả đủ, vào đúng ngày nó nghỉ hưu.
@@ -18,9 +18,16 @@ export type CloseCode = keyof typeof CLOSE_CODES;
// their chapters; uniqueness is test-enforced from day one.
export const ERROR_CODES = {
invalid_frame: "the frame failed schema validation",
unknown_frame_type: "the type discriminator names no known frame",
unauthorized: "the connection is not authorized for this action",
rate_limited: "too many frames; slow down and retry",
+ // The SRS singles this out as the most common first-integration
+ // failure, so it gets its own code instead of a generic `unauthorized`: the
+ // response has to say which class was presented and which the route wanted.
+ // The MESSAGE names the class and never the credential — "the key rk_dev_abc…
+ // is invalid" is how a live secret reaches a support ticket (NFR-SEC-06).
+ wrong_credential_type:
+ "the credential class presented cannot use this route; the message names presented and expected",
} as const;
export type ErrorCode = keyof typeof ERROR_CODES;@@ -84,13 +84,33 @@ export const internalBackfillResponseSchema = z.strictObject({
/** api → gateway: the channels this user may hear (FR-RTM-01). */
export const internalMembershipsResponseSchema = z.strictObject({
channel_ids: z.array(z.string().min(1)),
});
+/** api → gateway: who the presented token belongs to, and what it
+ * may hear — in ONE answer.
+ *
+ * This replaces the memberships response above rather than joining it. The
+ * gateway used to verify a token itself and then ask "what may this user hear";
+ * it now presents the token and is told both. The round-trip count at connect is
+ * unchanged, and the gateway stops being the thing that decides identity
+ * (research R1).
+ *
+ * `user` is the EXTERNAL id, as everywhere else on this contract: internal uuids
+ * are the api's business. */
+export const internalSessionResponseSchema = z.strictObject({
+ environment_id: z.string().min(1),
+ user: z.string().min(1),
+ channel_ids: z.array(z.string().min(1)),
+});
+
export type InternalSendRequest = z.infer<typeof internalSendRequestSchema>;
+export type InternalSessionResponse = z.infer<
+ typeof internalSessionResponseSchema
+>;
export type InternalSendResponse = z.infer<typeof internalSendResponseSchema>;
export type InternalMembershipsResponse = z.infer<
typeof internalMembershipsResponseSchema
>;
export type InternalBackfillRequest = z.infer<
typeof internalBackfillRequestSchema@@ -16,12 +16,13 @@
"@nestjs/common": "^11.1.28",
"@nestjs/core": "^11.1.28",
"@nestjs/platform-express": "^11.1.28",
"@relay/protocol": "workspace:*",
"@relay/service-kit": "workspace:*",
"drizzle-orm": "^0.45.2",
+ "jose": "^6.2.7",
"pg": "^8.22.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.2",
"zod": "^4.4.3"
},
"devDependencies": {@@ -2,12 +2,14 @@ import {
Module,
type MiddlewareConsumer,
type NestModule,
} from "@nestjs/common";
import { APP_FILTER } from "@nestjs/core";
+import { AuthModule } from "./auth/auth.module";
+import { AuthenticateMiddleware } from "./auth/authenticate.middleware";
import { HealthController } from "./health.controller";
import { InternalModule } from "./internal/internal.module";
import { MessagesModule } from "./messages/messages.module";
import { TenancyModule } from "./tenancy/tenancy.module";
import { LOGGER, apiLogger } from "./logger";
import { ProtocolErrorFilter } from "./protocol-error.filter";
@@ -15,19 +17,25 @@ import { RequestContextMiddleware } from "./request-context.middleware";
// The application described as a module graph — ADR-15's convention for the
// wide surface Phases 2-4 will grow. Registering the error filter as a
// provider (APP_FILTER) instead of wiring it in main.ts means every entry
// point — including tests — gets the same error envelope for free.
@Module({
- imports: [MessagesModule, InternalModule, TenancyModule],
+ imports: [AuthModule, MessagesModule, InternalModule, TenancyModule],
controllers: [HealthController],
providers: [
{ provide: LOGGER, useFactory: apiLogger },
{ provide: APP_FILTER, useClass: ProtocolErrorFilter },
RequestContextMiddleware,
],
})
export class AppModule implements NestModule {
configure(consumer: MiddlewareConsumer): void {
- consumer.apply(RequestContextMiddleware).forRoutes("{*path}");
+ // Order is the chain: the request gets its id first, then its principal.
+ // The credentials chapter put authentication HERE rather than in a guard because Nest
+ // constructs request-scoped providers before the enhancer chain runs — the
+ // finding 2.6 paid for, measured again on this path in T004.
+ consumer
+ .apply(RequestContextMiddleware, AuthenticateMiddleware)
+ .forRoutes("{*path}");
}
}import { Module } from "@nestjs/common";
import { createDb, createPool, type Db } from "../db/client";
import { AUTH_DB, AuthenticateMiddleware } from "./authenticate.middleware";
import { CredentialGuard } from "./credential.guard";
import { DevTokenController } from "./dev-token.controller";
// Authentication's home, beside messages/, internal/ and
// tenancy/. It lives in the api and not in a package or a new service for one
// reason: authentication is a question about data — which key is this, which
// environment signs that token — and the api owns the data (ADR-04). The
// gateway's share of the work is one call it was already making (research R1).
//
// The DB handle is its own provider rather than MessagesModule's: this module
// runs BEFORE any tenant scope exists, and borrowing the request-scoped
// machinery 2.2 built would invert the order it needs.
@Module({
controllers: [DevTokenController],
providers: [
{ provide: AUTH_DB, useFactory: (): Db => createDb(createPool()) },
AuthenticateMiddleware,
CredentialGuard,
],
exports: [AUTH_DB, AuthenticateMiddleware, CredentialGuard],
})
export class AuthModule {}@@ -18,20 +18,39 @@ export class ProtocolErrorFilter implements ExceptionFilter {
const res = host.switchToHttp().getResponse<ServerResponse>();
const status =
exception instanceof HttpException ? exception.getStatus() : 500;
// REST error codes by status (chapter 2.2 widened this: a 400 that
// calls itself "internal_error" is a lie the client cannot act on).
// The registry stays here until an API chapter owns a REST one.
+ //
+ // The credentials chapter widened this twice. A thrower may now NAME its code, because
+ // `wrong_credential_type` is a distinction the status alone cannot carry: a
+ // 403 that calls itself "forbidden" tells an integrator they lack a
+ // permission, when what they actually did was present the wrong kind of
+ // credential. And 403 finally has a fallback — it used to land in
+ // "internal_error", which was a lie in the same family as the 400 that 2.2
+ // fixed.
+ const response =
+ exception instanceof HttpException ? exception.getResponse() : null;
+ const named =
+ typeof response === "object" &&
+ response !== null &&
+ typeof (response as { code?: unknown }).code === "string"
+ ? (response as { code: string }).code
+ : null;
const code =
- status === 400
+ named ??
+ (status === 400
? "invalid_request"
: status === 401
? "unauthorized"
- : status === 404
- ? "not_found"
- : "internal_error";
+ : status === 403
+ ? "forbidden"
+ : status === 404
+ ? "not_found"
+ : "internal_error");
const message =
exception instanceof HttpException
? exception.message
: "unexpected internal error";
res.statusCode = status;
res.setHeader("content-type", "application/json");@@ -5,27 +5,33 @@ import {
Param,
Post,
Query,
UseGuards,
} from "@nestjs/common";
-import { EnvironmentContextGuard } from "./environment-context.guard";
+import { CredentialGuard } from "../auth/credential.guard";
import { MessagesService } from "./messages.service";
import { historyQuerySchema, sendMessageBodySchema } from "./messages.schema";
// `import type` is required, not stylistic: with isolatedModules and
// emitDecoratorMetadata on (ADR-15's trade-off, chapter 1.4), a type used
// in a decorated signature must be imported as a type or TS1272 refuses
// to compile it.
import type { HistoryQuery, SendMessageBody } from "./messages.schema";
import { ZodValidationPipe } from "./zod-validation.pipe";
// The api's first product endpoint (chapter 2.2). Validation is zod at the
// boundary — the same schema family as @relay/protocol, so the REST body
// and the WebSocket frame payload cannot drift (1.3's payoff, again).
+//
+// The credentials chapter swapped the guard. `EnvironmentContextGuard` resolved a tenant
+// from a header the caller asserted; `CredentialGuard` only asks whether the
+// principal the middleware already resolved is allowed here. Both classes are
+// (FR-MSG-13 lets a server send on a user's behalf, and FR-AUT-10 does not
+// reserve these routes), so this one declares nothing narrower.
@Controller("v1/channels/:channelId/messages")
-@UseGuards(EnvironmentContextGuard)
+@UseGuards(CredentialGuard)
export class MessagesController {
constructor(private readonly messages: MessagesService) {}
@Post()
async send(
@Param("channelId") channelId: string,@@ -1,62 +1,68 @@
import {
BadRequestException,
Body,
Controller,
- Get,
- Headers,
Post,
+ Req,
UseGuards,
} from "@nestjs/common";
-import { EnvironmentContextGuard } from "../messages/environment-context.guard";
+import { Accepts, CredentialGuard } from "../auth/credential.guard";
+import type { RequestWithPrincipal } from "../auth/principal";
import { MessagesService } from "../messages/messages.service";
import { Repository } from "../db/repository";
import {
internalSendRequestSchema,
type InternalSendRequest,
} from "@relay/protocol";
import { ZodValidationPipe } from "../messages/zod-validation.pipe";
+/** The connected user, from the token the gateway forwarded. The guard has
+ * already refused anything that is not a user principal, so this narrowing is
+ * about the type system rather than about trust. */
+function principalUser(req: RequestWithPrincipal): string {
+ const principal = req.principal;
+ if (principal?.kind !== "user") {
+ throw new BadRequestException("internal routes act for an end user");
+ }
+ return principal.userExternalId;
+}
+
// The internal surface (chapter 2.5): the routes the gateway calls on a
// connected user's behalf. They reuse the SAME service methods as the
// public routes — the write path has one implementation (ADR-04), and the
// socket is a new door onto it, not a second path.
//
-// DECISION (chapter 2.5): these routes are network-internal and
-// unauthenticated between services at this stage; the gateway's forwarded
-// identity headers are trusted. Service-to-service credentials are Part 3
-// hardening, and this controller is the whole seam.
+// DECISION (chapter 2.5, narrowed by the credentials chapter): these routes are still
+// network-internal, and there is still no service-to-service credential between
+// the gateway and the api — that remains Part 3 hardening. What changed is what
+// they trust. The gateway used to ASSERT identity in two headers it invented
+// from a token it verified locally; it now forwards the END USER'S OWN token,
+// and the api resolves the identity itself. The seam got narrower, not wider:
+// the gateway can no longer claim to be somebody it cannot present a token for.
@Controller("internal")
-@UseGuards(EnvironmentContextGuard)
+// The end user's token, not a key: these routes exist to act for a connected
+// person, and a route that also accepted an application credential would let a
+// key act as any user without saying which (research R6).
+@Accepts("user")
+@UseGuards(CredentialGuard)
export class InternalController {
constructor(
private readonly repo: Repository,
private readonly messages: MessagesService,
) {}
- /** Which channels may this user hear? The gateway caches the answer on
- * the session; membership.changed frames invalidate it (FR-RTM-05). */
- @Get("memberships")
- async memberships(@Headers("x-relay-user") userExternalId?: string) {
- if (!userExternalId) throw new BadRequestException("missing x-relay-user");
- const user = await this.repo.getUserByExternalId(userExternalId);
- // An unknown user is not an error — it is a user with no channels. The
- // gateway's job is delivery, not identity forensics.
- if (!user) return { channel_ids: [] };
- return { channel_ids: await this.repo.channelsForUser(user.id) };
- }
-
@Post("messages")
async send(
@Body(new ZodValidationPipe(internalSendRequestSchema))
body: InternalSendRequest,
- @Headers("x-relay-user") userExternalId?: string,
+ @Req() req: RequestWithPrincipal,
) {
- if (!userExternalId) throw new BadRequestException("missing x-relay-user");
+ const userExternalId = principalUser(req);
const user = await this.repo.getUserByExternalId(userExternalId);
if (!user) throw new BadRequestException("unknown user");
const message = await this.messages.send(
body.channel_id,
{
text: body.text,@@ -1,14 +1,16 @@
import { Module } from "@nestjs/common";
import { MessagesModule } from "../messages/messages.module";
+import { AuthModule } from "../auth/auth.module";
import { BackfillController } from "./backfill.controller";
import { InternalController } from "./internal.controller";
+import { SessionController } from "./session.controller";
// The internal routes reuse MessagesModule's providers wholesale — the
// request-scoped Repository, the guard, the service. One write path, two
// doors (ADR-04/05).
@Module({
- imports: [MessagesModule],
- controllers: [InternalController, BackfillController],
+ imports: [MessagesModule, AuthModule],
+ controllers: [InternalController, BackfillController, SessionController],
})
export class InternalModule {}@@ -1,24 +1,25 @@
import {
BadRequestException,
Body,
Controller,
- Headers,
Post,
+ Req,
UseGuards,
} from "@nestjs/common";
import {
BACKFILL_LIMIT,
internalBackfillRequestSchema,
type InternalBackfillRequest,
type InternalBackfillResponse,
type Message,
} from "@relay/protocol";
-import { EnvironmentContextGuard } from "../messages/environment-context.guard";
+import { Accepts, CredentialGuard } from "../auth/credential.guard";
+import type { RequestWithPrincipal } from "../auth/principal";
import { Repository, type MessageWithSender } from "../db/repository";
import { ZodValidationPipe } from "../messages/zod-validation.pipe";
// The api's half of resume (chapter 2.7): everything the client has not
// applied yet, per channel, capped. It is a READ behind a POST, because the
// request carries a map — cursors in a query string would be a length limit
@@ -26,24 +27,30 @@ import { ZodValidationPipe } from "../messages/zod-validation.pipe";
// once per connect.
//
// The controller's real job is SHAPE: the repository returns rows, the
// gateway needs frames, and this is the boundary where one becomes the
// other (the same division of labour 2.6 settled for the public send).
@Controller("internal")
-@UseGuards(EnvironmentContextGuard)
+// The end user's own token, forwarded by the gateway, rather than
+// two headers the gateway asserted. Same trust boundary, narrower claim.
+@Accepts("user")
+@UseGuards(CredentialGuard)
export class BackfillController {
constructor(private readonly repo: Repository) {}
@Post("backfill")
async backfill(
@Body(new ZodValidationPipe(internalBackfillRequestSchema))
body: InternalBackfillRequest,
- @Headers("x-relay-user") userExternalId?: string,
+ @Req() req: RequestWithPrincipal,
): Promise<InternalBackfillResponse> {
- if (!userExternalId) throw new BadRequestException("missing x-relay-user");
- const user = await this.repo.getUserByExternalId(userExternalId);
+ const principal = req.principal;
+ if (principal?.kind !== "user") {
+ throw new BadRequestException("internal routes act for an end user");
+ }
+ const user = await this.repo.getUserByExternalId(principal.userExternalId);
// An unknown user resumes nothing — the same answer memberships gives,
// for the same reason: delivery is not identity forensics.
if (!user) return { channels: {} };
const pages = await this.repo.backfill(
user.id,@@ -33,13 +33,16 @@ import {
// applied per controller, so a pre-tenant route simply does not use it, and
// the seam it guards is untouched by this chapter (the credentials chapter
// retires it).
//
// What these routes do NOT do: issue a session. A session is a credential,
// credentials are the credentials chapter's subject, and the dashboard that would consume one is
-// Part 5. The callback reports what it created and stops there.
+// Part 5. The callback reports what it created — and, from the credentials
+ // chapter, hands over the
+// environment's first API key, because with no session nothing else could
+// bootstrap one (research R8).
/** The two things this controller needs from the response object.
*
* `@Res()` puts a handler in Nest's library-specific mode, which normally
* means importing express's `Response` type — and express 5 ships no types, so
* that would mean adding `@types/express` for two method signatures. Declaring
@@ -125,10 +128,22 @@ export class SignupController {
});
return {
organisation: result.organisation,
application: result.application,
environment: result.environment,
+ // FR-AUT-02: the environment's first key, and the ONLY time
+ // its secret exists outside a hash. It is present only when this call
+ // created the tenant — a returning owner is not handed a new secret,
+ // because the old one is unrecoverable by design and the recovery for a
+ // lost key is rotation, not retrieval (research R8).
+ ...(result.apiKey && {
+ api_key: {
+ prefix: result.apiKey.prefix,
+ secret: result.apiKey.secret,
+ shown_once: true,
+ },
+ }),
created: result.created,
};
}
}@@ -1,14 +1,15 @@
import {
internalBackfillResponseSchema,
- internalMembershipsResponseSchema,
internalSendResponseSchema,
+ internalSessionResponseSchema,
type InternalBackfillRequest,
type InternalBackfillResponse,
type InternalSendRequest,
type InternalSendResponse,
+ type InternalSessionResponse,
} from "@relay/protocol";
// The gateway's only road to state (chapter 2.5, ADR-05): internal HTTP to
// the api service. Not a database client — a client of the service that
// owns the database.
//
@@ -17,19 +18,45 @@ import {
// with, so the two sides cannot drift (ADR-01's payoff, applied to the
// internal hop). And responses are PARSED, not assumed: an internal caller
// has no more right to trust a payload's shape than an external one does —
// the day the api changes a field name, this fails loudly here instead of
// producing an `undefined` seq in an ack three layers away.
+/** A failed internal call, carrying the status. The credentials chapter needs the
+ * distinction: a 401 from the api means the CONNECTION'S credential is no longer
+ * good, which a client can act on by reconnecting, while a 500 means we are
+ * broken and it should not. `new Error("send failed")` could not tell them
+ * apart, so the socket answered both the same way. */
+export class ApiError extends Error {
+ readonly status: number;
+
+ constructor(what: string, status: number) {
+ super(`${what} failed: ${status}`);
+ this.name = "ApiError";
+ // Declared and assigned rather than a constructor parameter property:
+ // `erasableSyntaxOnly` is on everywhere except the api (ADR-15, chapter
+ // 1.4), and the gateway keeps that guarantee.
+ this.status = status;
+ }
+}
+
export interface Identity {
environmentId: string;
userExternalId: string;
+ /** The token the client presented at connect, carried so the
+ * internal hop can FORWARD it instead of asserting who the caller is. The
+ * gateway holds it; it does not verify it and holds no secret that could. */
+ token: string;
}
export interface ApiClient {
- memberships(identity: Identity): Promise<string[]>;
+ /** Present the token, be told who it belongs to and what it may
+ * hear. Null means the api answered "not valid" — distinct from a throw, which
+ * means it could not answer at all, and the two must not close a socket the
+ * same way. */
+ session(token: string): Promise<InternalSessionResponse | null>;
/** Resume backfill (chapter 2.7): everything past the cursors, per
* channel, already shaped as wire frames. */
backfill(
identity: Identity,
cursors: Record<string, number>,
): Promise<InternalBackfillResponse["channels"]>;
@@ -37,42 +64,49 @@ export interface ApiClient {
identity: Identity,
body: InternalSendRequest,
): Promise<InternalSendResponse>;
}
export function createApiClient(baseUrl: string): ApiClient {
+ // The credentials chapter retired two headers here. The gateway used to send
+ // an environment header and a user header — values it INVENTED from a token
+ // it verified with a shared development secret. It now forwards the token
+ // itself and is told who the caller is (research R1). One header instead of
+ // two, and the api is the only thing that decides identity.
const headers = (identity: Identity) => ({
"content-type": "application/json",
- "x-relay-environment": identity.environmentId,
- "x-relay-user": identity.userExternalId,
+ authorization: `Bearer ${identity.token}`,
});
async function parse<T>(
res: Response,
schema: { safeParse: (value: unknown) => { success: boolean; data?: T } },
what: string,
): Promise<T> {
- if (!res.ok) throw new Error(`${what} failed: ${res.status}`);
+ if (!res.ok) throw new ApiError(what, res.status);
const parsed = schema.safeParse(await res.json());
if (!parsed.success || parsed.data === undefined) {
throw new Error(`${what} returned a payload the contract does not allow`);
}
return parsed.data;
}
return {
- async memberships(identity) {
- const res = await fetch(`${baseUrl}/internal/memberships`, {
- headers: headers(identity),
+ async session(token) {
+ const res = await fetch(`${baseUrl}/internal/session`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${token}`,
+ },
});
- const body = await parse(
- res,
- internalMembershipsResponseSchema,
- "memberships",
- );
- return body.channel_ids;
+ // 401 is an ANSWER, not a failure: the api verified the token and refused
+ // it. Everything else falls through to `parse`, which throws — the api
+ // being unreachable is a different event with a different close code.
+ if (res.status === 401 || res.status === 403) return null;
+ return parse(res, internalSessionResponseSchema, "session");
},
async backfill(identity, cursors) {
const res = await fetch(`${baseUrl}/internal/backfill`, {
method: "POST",
headers: headers(identity),
body: JSON.stringify({ cursors } satisfies InternalBackfillRequest),@@ -7,14 +7,14 @@ import {
type Frame,
type Message,
} from "@relay/protocol";
import type { Logger } from "@relay/service-kit";
import { WebSocketServer, type WebSocket } from "ws";
-import type { ApiClient } from "./api-client.js";
-import { verifyToken, type Identity } from "./auth.js";
+import { ApiError, type ApiClient } from "./api-client.js";
+import { authenticate, type Identity } from "./auth.js";
import type { Fanout } from "./fanout.js";
import { Registry, type Connection } from "./registry.js";
import {
MAX_BUFFERED_FRAMES,
SUBSCRIBE_DEADLINE_MS,
flushable,
@@ -109,58 +109,62 @@ export function attachSessions({
if (url.pathname !== "/v1/ws") {
socket.destroy();
return;
}
const token = url.searchParams.get("token");
void (async () => {
- const identity = token ? await verifyToken(token) : null;
+ // The api verifies, and answers with the identity AND the
+ // memberships. This is the same one call the connect path already made —
+ // it just asks a better question than "what may this user hear".
+ const result = await authenticate(api, token);
wss.handleUpgrade(req, socket, head, (ws) => {
- if (!identity) {
+ if (result.outcome === "refused") {
// 4001: "invalid or expired token" (EIR-WS-05). The close code is
// the protocol package's, not a number invented here.
ws.close(4001, CLOSE_CODES[4001]);
logger.log("info", "connection.rejected", { reason: "bad_token" });
return;
}
- void open(ws, identity, req.url ?? "/");
+ if (result.outcome === "unavailable") {
+ // The api could not answer. Not the client's fault, so not 4001:
+ // 1011 tells it to retry, which is the honest instruction (the same
+ // distinction 2.5 drew for the memberships lookup).
+ ws.close(1011, "session lookup failed");
+ logger.log("error", "connection.session_failed", {
+ error: result.error,
+ });
+ return;
+ }
+ void open(ws, result.identity, result.channelIds, req.url ?? "/");
});
})();
});
async function open(
socket: WebSocket,
identity: Identity,
+ channelIds: string[],
url: string,
): Promise<void> {
// Cursors are read BEFORE anything else, because their presence decides
// whether this connection is born buffering or born live.
const presented = parseCursors(url);
const connection: Connection = {
id: randomUUID(),
identity,
socket,
- channelIds: new Set(),
+ // Memberships arrived with the identity, from the session
+ // call at the door. There is no second lookup to fail here — the api is
+ // still the only source of membership (ADR-05), it just answers both
+ // questions at once, and a failure now closes the socket before it opens.
+ channelIds: new Set(channelIds),
missedPings: 0,
phase: presented === undefined ? "live" : "buffering",
buffer: [],
overflowed: false,
};
- try {
- connection.channelIds = new Set(await api.memberships(identity));
- } catch (error) {
- // The api is the only source of membership (ADR-05). If it cannot
- // answer, we do not guess — we close, and the client retries with
- // backoff. A session with unknown memberships would deliver nothing
- // and look healthy doing it.
- logger.log("error", "connection.memberships_failed", {
- connection_id: connection.id,
- error: String(error),
- });
- socket.close(1011, "membership lookup failed");
- return;
- }
registry.add(connection);
// Subscriptions follow membership: the first local member of a channel
// makes this instance a subscriber, and the last one to leave releases
// it (reference-counted in the fabric).
const subscribing = Promise.all(
@@ -407,12 +411,28 @@ export function attachSessions({
}
} catch (error) {
logger.log("error", "send.failed", {
connection_id: connection.id,
error: String(error),
});
+ // A 401 here means the token this connection was opened
+ // with has aged out: the socket is still up (FR-AUT-11 says expiry must
+ // not terminate it) and still RECEIVES, because delivery never asks the
+ // api anything. Writing does. Until FR-AUT-11's second clause exists — a
+ // frame that lets a client hand over a refreshed token on the open
+ // connection — the honest instruction is "reconnect", and saying so is
+ // more useful than an "internal error" the client cannot act on.
+ if (error instanceof ApiError && error.status === 401) {
+ sendError(
+ connection.socket,
+ "unauthorized",
+ "the token this connection was opened with has expired; " +
+ "reconnect with a fresh one to send again",
+ );
+ return;
+ }
sendError(connection.socket, "internal_error", "send failed");
}
}
const heartbeat = setInterval(() => {
for (const connection of registry.all()) {@@ -3,32 +3,37 @@ import "reflect-metadata";
import { Test } from "@nestjs/testing";
import type { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { AppModule } from "../app.module";
import { createDb, createPool } from "../db/client";
-import { createEnvironment, Repository } from "../db/repository";
+import { createApiKey, createEnvironment, Repository } from "../db/repository";
// The endpoint path (chapter 2.2): guard → pipe → service → repository →
// filter, over real HTTP against the compose Postgres. Its own environment,
// minted here — no truncate, because tenant isolation means this suite and
// the repository suite cannot see each other's rows (2.1's property, paying
// for itself in the test lane).
describe("POST /v1/channels/:channelId/messages", () => {
let app: INestApplication;
let url: string;
let env: { id: string };
+ // The tenant arrives as a CREDENTIAL now, not as a header. The
+ // suite mints its own key the same way signup does — through the repository's
+ // admin surface — so nothing here needs a test-only route to exist.
+ let credential: string;
let channelId: string;
let foreignChannelId: string;
beforeAll(async () => {
const db = createDb(createPool());
env = await createEnvironment(db, { name: "messages-itest" });
channelId = (
await new Repository(db, env.id).createChannel("general", "public")
).id;
+ credential = (await createApiKey(db, { environmentId: env.id })).credential;
const other = await createEnvironment(db, { name: "messages-itest-other" });
foreignChannelId = (
await new Repository(db, other.id).createChannel("theirs", "public")
).id;
app = (
await Test.createTestingModule({ imports: [AppModule] }).compile()
@@ -38,18 +43,18 @@ describe("POST /v1/channels/:channelId/messages", () => {
});
afterAll(async () => {
await app.close();
});
- const send = (body: unknown, channel = channelId, environment = env.id) =>
+ const send = (body: unknown, channel = channelId, key = credential) =>
fetch(`${url}/v1/channels/${channel}/messages`, {
method: "POST",
headers: {
"content-type": "application/json",
- "x-relay-environment": environment,
+ authorization: `Bearer ${key}`,
},
body: JSON.stringify(body),
});
it("returns 201 with an ascending sequence", async () => {
const first = await send({ text: "hello" });
@@ -72,17 +77,17 @@ describe("POST /v1/channels/:channelId/messages", () => {
// a channel this tenant cannot see, GET said 200 with an empty page. An
// empty page leaks nothing, but it leaves a client unable to tell "no
// such conversation" from "nothing said yet" — and one resource should
// not answer two ways depending on the verb.
const foreign = await fetch(
`${url}/v1/channels/${foreignChannelId}/messages?limit=10`,
- { headers: { "x-relay-environment": env.id } },
+ { headers: { authorization: `Bearer ${credential}` } },
);
const missing = await fetch(
`${url}/v1/channels/${crypto.randomUUID()}/messages?limit=10`,
- { headers: { "x-relay-environment": env.id } },
+ { headers: { authorization: `Bearer ${credential}` } },
);
expect(foreign.status).toBe(404);
expect(missing.status).toBe(404);
expect(await foreign.json()).toEqual(await missing.json());
});@@ -2,19 +2,24 @@ import "reflect-metadata";
import { Test } from "@nestjs/testing";
import type { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import {
- internalMembershipsResponseSchema,
internalSendResponseSchema,
+ internalSessionResponseSchema,
} from "@relay/protocol";
import { AppModule } from "../app.module";
import { createDb, createPool } from "../db/client";
-import { createEnvironment, Repository } from "../db/repository";
+import {
+ createEnvironment,
+ environmentSigningSecret,
+ Repository,
+} from "../db/repository";
+import { mintUserToken } from "../auth/user-token";
// The internal boundary, tested as a CONTRACT (chapter 2.6). 2.5 built these
// routes and verified them only through the gateway's tests, where the api
// was a stub — which means nothing checked that the real api emits what the
// shared schema demands. The gateway parses these bodies at runtime and
// refuses what does not fit, so an unnoticed drift here becomes a failed
@@ -25,41 +30,54 @@ import { createEnvironment, Repository } from "../db/repository";
// deploy together.
describe("the internal surface", () => {
let app: INestApplication;
let url: string;
let env: { id: string };
let channelId: string;
+ /** The gateway forwards the END USER'S token instead of
+ * asserting two identity headers, so this suite mints tokens the same way the
+ * dev-token endpoint does — with the environment's own signing secret. */
+ let tokenFor: (user: string) => Promise<string>;
beforeAll(async () => {
const db = createDb(createPool());
env = await createEnvironment(db, { name: "internal-itest" });
const repo = new Repository(db, env.id);
const user = await repo.createUser("tuan", "Tuan");
channelId = (await repo.createChannel("fleet", "public")).id;
await repo.addMember(channelId, user.id);
+ const signingSecret = (await environmentSigningSecret(db, env.id))!
+ .signingSecret;
+ tokenFor = async (subject: string) =>
+ (
+ await mintUserToken(signingSecret, {
+ user: subject,
+ environmentId: env.id,
+ ttlSeconds: 3600,
+ })
+ ).token;
app = (
await Test.createTestingModule({ imports: [AppModule] }).compile()
).createNestApplication({ logger: false });
await app.listen(0);
url = await app.getUrl();
});
afterAll(async () => {
await app.close();
});
- const headers = (user = "tuan") => ({
+ const headers = async (user = "tuan") => ({
"content-type": "application/json",
- "x-relay-environment": env.id,
- "x-relay-user": user,
+ authorization: `Bearer ${await tokenFor(user)}`,
});
- const send = (body: unknown, user = "tuan") =>
+ const send = async (body: unknown, user = "tuan") =>
fetch(`${url}/internal/messages`, {
method: "POST",
- headers: headers(user),
+ headers: await headers(user),
body: JSON.stringify(body),
});
it("emits a send response the shared contract accepts", async () => {
const res = await send({ channel_id: channelId, text: "which entrance?" });
expect(res.status).toBe(201);
@@ -93,36 +111,55 @@ describe("the internal surface", () => {
it("persists the sender on the row, not just in the response", async () => {
const res = internalSendResponseSchema.parse(
await (await send({ channel_id: channelId, text: "north ramp" })).json(),
);
const history = await fetch(
`${url}/v1/channels/${channelId}/messages?limit=50`,
- { headers: headers() },
+ { headers: await headers() },
);
const page = (await history.json()) as { messages: { id: string }[] };
// History does not expose `user` yet — 2.7's resume path is where the
// read side catches up. What this asserts is that the message exists
// and the write did not fail silently while claiming a sender.
expect(page.messages.some((m) => m.id === res.id)).toBe(true);
});
- it("emits a memberships response the shared contract accepts", async () => {
- const res = await fetch(`${url}/internal/memberships`, {
- headers: headers(),
+ // The credentials chapter replaced `GET /internal/memberships` with
+ // `POST /internal/session`. These two cases held that route's contract, and
+ // they move rather than disappear: the route changed, the guarantees did not.
+ // The answer now carries the identity as well, because the gateway no longer
+ // decides it (research R1).
+ it("emits a session response the shared contract accepts", async () => {
+ const res = await fetch(`${url}/internal/session`, {
+ method: "POST",
+ headers: await headers(),
});
- const parsed = internalMembershipsResponseSchema.safeParse(
- await res.json(),
- );
+ const parsed = internalSessionResponseSchema.safeParse(await res.json());
expect(parsed.error?.issues ?? []).toEqual([]);
expect(parsed.data?.channel_ids).toContain(channelId);
+ // The half that is new: the api says who the token belongs to.
+ expect(parsed.data?.user).toBe("tuan");
+ expect(parsed.data?.environment_id).toBe(env.id);
});
it("answers for an unknown user with no channels rather than an error", async () => {
- const res = await fetch(`${url}/internal/memberships`, {
- headers: headers("nobody-here"),
+ const res = await fetch(`${url}/internal/session`, {
+ method: "POST",
+ headers: await headers("nobody-here"),
});
expect(res.status).toBe(200);
expect(
- internalMembershipsResponseSchema.parse(await res.json()).channel_ids,
+ internalSessionResponseSchema.parse(await res.json()).channel_ids,
).toEqual([]);
});
+
+ it("refuses an unverifiable token instead of answering for it", async () => {
+ // The refusal the gateway turns into a 4001. It exists here because the
+ // route that verifies is the route that must refuse — the gateway holds no
+ // secret and cannot tell a good token from a bad one any more.
+ const res = await fetch(`${url}/internal/session`, {
+ method: "POST",
+ headers: { authorization: "Bearer not-a-token" },
+ });
+ expect(res.status).toBe(401);
+ });
});@@ -9,13 +9,18 @@ import {
internalBackfillResponseSchema,
MAX_RESUME_CHANNELS,
} from "@relay/protocol";
import { AppModule } from "../app.module";
import { createDb, createPool } from "../db/client";
-import { createEnvironment, Repository } from "../db/repository";
+import {
+ createEnvironment,
+ environmentSigningSecret,
+ Repository,
+} from "../db/repository";
+import { mintUserToken } from "../auth/user-token";
// The api's half of resume (chapter 2.7), against the compose Postgres. The
// gateway's suites prove the ORDERING; this one proves the read: everything
// past the cursor, capped honestly, scoped to membership as it stands now.
describe("POST /internal/backfill", () => {
@@ -24,12 +29,15 @@ describe("POST /internal/backfill", () => {
let env: { id: string };
let repo: Repository;
let channelId: string;
let quietChannelId: string;
let leftChannelId: string;
let tuan: { id: string };
+ /** The gateway forwards the user's own token now, so the suite
+ * mints one per subject rather than asserting a name in a header. */
+ let tokenFor: (user: string) => Promise<string>;
beforeAll(async () => {
const db = createDb(createPool());
env = await createEnvironment(db, { name: "backfill-itest" });
repo = new Repository(db, env.id);
tuan = await repo.createUser("tuan", "Tuan");
@@ -41,30 +49,39 @@ describe("POST /internal/backfill", () => {
await repo.addMember(id, tuan.id);
await repo.addMember(id, dispatcher.id);
}
// Tuan is NOT a member of leftChannelId — the "removed while offline"
// case, which is indistinguishable from "never joined" by design.
await repo.addMember(leftChannelId, dispatcher.id);
+ const signingSecret = (await environmentSigningSecret(db, env.id))!
+ .signingSecret;
+ tokenFor = async (subject: string) =>
+ (
+ await mintUserToken(signingSecret, {
+ user: subject,
+ environmentId: env.id,
+ ttlSeconds: 3600,
+ })
+ ).token;
app = (
await Test.createTestingModule({ imports: [AppModule] }).compile()
).createNestApplication({ logger: false });
await app.listen(0);
url = await app.getUrl();
}, 60_000);
afterAll(async () => {
await app.close();
});
- const ask = (cursors: Record<string, number>, user = "tuan") =>
+ const ask = async (cursors: Record<string, number>, user = "tuan") =>
fetch(`${url}/internal/backfill`, {
method: "POST",
headers: {
"content-type": "application/json",
- "x-relay-environment": env.id,
- "x-relay-user": user,
+ authorization: `Bearer ${await tokenFor(user)}`,
},
body: JSON.stringify({ cursors }),
});
const parsed = async (res: Response) =>
internalBackfillResponseSchema.parse(await res.json());@@ -279,26 +279,58 @@ describe("signup", () => {
});
// Whatever these answer, it is never "here is a new tenant".
expect(res.status).not.toBe(200);
const text = await res.text();
expect(text).not.toContain("organisation");
}
- const before = await db.execute(
- `SELECT count(*)::int AS n FROM organisations`,
- );
- await fetch(`${url}/internal/memberships`, {
- headers: {
- "x-relay-environment": "00000000-0000-0000-0000-000000000000",
- },
- });
- const after = await db.execute(
- `SELECT count(*)::int AS n FROM organisations`,
- );
- expect((after.rows[0] as { n: number }).n).toBe(
- (before.rows[0] as { n: number }).n,
- );
+ // AND THE SECOND HALF READS THE SOURCE, BECAUSE THE COUNT COULD ONLY FAIL FOR
+ // SOMEBODY ELSE'S REASON.
+ //
+ // This was `SELECT count(*) FROM organisations` before and after a credential-free
+ // `GET /internal/memberships`, asserting the two matched. Ask what would have to be
+ // false for it to fail: that request is refused before it reaches a handler, so no
+ // code path exists that could move the number — and the number moves anyway, because
+ // it is the whole table and a neighbour provisions a tenant. It failed as
+ // `expected 20140 to be 20139`, which names no neighbour and no route.
+ //
+ // **A whole-table count is the wrong instrument for "nothing else exposes this".**
+ // What invariant 7 actually claims is structural, and the comment above already
+ // states it: provisioning is a module-free function, reachable only from the signup
+ // path. That is readable, exactly, from the tree — and it is STRONGER than the
+ // count, which fires only if a new route both exists and is called here, while this
+ // goes red the moment one imports the function.
+ await fetch(`${url}/internal/memberships`);
+
+ // `__dirname`, NOT `import.meta.url`: this package compiles to CommonJS and
+ // `import.meta` is a compile error there — the same constraint that makes the
+ // vitest configs `.mts`.
+ const SRC = join(__dirname, "..");
+ const importers: string[] = [];
+ const walk = (dir: string): void => {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const full = join(dir, entry.name);
+ if (entry.isDirectory()) {
+ walk(full);
+ } else if (
+ entry.name.endsWith(".ts") &&
+ !entry.name.endsWith(".test.ts") &&
+ !entry.name.endsWith(".itest.ts") &&
+ full !== join(SRC, "db", "repository.ts")
+ ) {
+ if (/\bprovisionOrganisation\b/.test(readFileSync(full, "utf8"))) {
+ importers.push(full.slice(SRC.length + 1));
+ }
+ }
+ }
+ };
+ walk(SRC);
+ // Its definition is excluded above; every other mention is a caller. One file, and
+ // it is the signup path. A count rather than a `toContain`, because "the signup
+ // controller uses it" stays true when a second route starts using it too.
+ expect(importers, `provisionOrganisation is reachable from: ${importers.join(", ")}`)
+ .toEqual(["tenancy/signup.controller.ts"]);
});
it("refuses a callback whose state does not match the cookie (invariant 5, over HTTP)", async () => {
const start = await fetch(`${url}/auth/github/start`, {
redirect: "manual",
});@@ -1,20 +1,18 @@
-import { SignJWT } from "jose";
import { WebSocket } from "ws";
import { afterEach, describe, expect, it } from "vitest";
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import { createLogger, type Logger } from "@relay/service-kit";
import { serve } from "@relay/service-kit";
import type { Frame } from "@relay/protocol";
import type { InternalSendResponse, Message } from "@relay/protocol";
import type { ApiClient } from "./api-client.js";
-import { DEV_JWT_SECRET } from "./auth.js";
import type { Fanout } from "./fanout.js";
import { attachSessions } from "./session.js";
// The door, the frames, and the liveness clock — all provable without a
// database, because the gateway has no database (ADR-05). The api is a
// stub here for exactly that reason: if these tests needed Postgres, the
@@ -35,13 +33,20 @@ function committed(seq: number): InternalSendResponse {
created_at: new Date().toISOString(),
};
}
function stubApi(overrides: Partial<ApiClient> = {}): ApiClient {
return {
- memberships: async () => [CHANNEL],
+ // The api verifies tokens, so the stub is what decides which
+ // credential is good. That inversion is the point — the gateway holds no
+ // secret and cannot check a signature, so there is nothing left here to
+ // fake except the ANSWER.
+ session: async (token) =>
+ token === VALID_TOKEN
+ ? { environment_id: "env-1", user: "tuan", channel_ids: [CHANNEL] }
+ : null,
backfill: async () => ({}),
sendMessage: async () => committed(42),
...overrides,
};
}
@@ -56,17 +61,28 @@ function frame(seq: number, channel = CHANNEL): Message {
user: "dispatcher",
text: `m${seq}`,
created_at: "2026-08-04T00:00:00.000Z",
};
}
+/** The one credential the stubbed api recognises. */
+const VALID_TOKEN = "token-for-tuan";
+
+/** A token, from the gateway's point of view: an opaque string it forwards.
+ *
+ * This function used to SIGN one, with HS256 over a development secret both the
+ * gateway and this file knew. After the credentials chapter neither of them holds a secret —
+ * tokens are signed with the environment's own, in the api — so any override
+ * here simply produces a DIFFERENT opaque string, which the stub refuses. The
+ * refusal cases below therefore test what they always tested: a credential the
+ * api will not accept never reaches session code. */
async function token(claims: Record<string, string> = {}): Promise<string> {
- return new SignJWT({ env: "env-1", ...claims })
- .setProtectedHeader({ alg: "HS256" })
- .setSubject("tuan")
- .sign(new TextEncoder().encode(DEV_JWT_SECRET));
+ const keys = Object.entries(claims);
+ return keys.length === 0
+ ? VALID_TOKEN
+ : `token-with-${keys.map(([k, v]) => `${k}=${v}`).join(",")}`;
}
interface Harness {
url: string;
close: () => Promise<void>;
}
@@ -187,12 +203,29 @@ describe("the socket (chapter 2.5)", () => {
for (const bad of ["", "not-a-jwt", await token({ env: "" })]) {
const socket = new WebSocket(`${harness.url}?token=${bad}`);
expect(await closeCode(socket)).toBe(4001);
}
});
+ it("closes 1011, not 4001, when the api cannot answer at all", async () => {
+ // New in the credentials chapter, and the reason `authenticate` has three outcomes
+ // rather than two. Moving verification to the api introduced a failure the
+ // gateway never had: the verifier being DOWN. Answering that with 4001
+ // would tell a client its credential is bad and stop it retrying, when the
+ // truth is that we are broken and it should.
+ harness = await boot(
+ stubApi({
+ session: async () => {
+ throw new Error("connect ECONNREFUSED");
+ },
+ }),
+ );
+ const socket = new WebSocket(`${harness.url}?token=${await token()}`);
+ expect(await closeCode(socket)).toBe(1011);
+ });
+
it("forwards message.send to the api and acks the committed sequence", async () => {
const sent: unknown[] = [];
harness = await boot(
stubApi({
sendMessage: async (identity, body) => {
sent.push({ identity, body });
@@ -207,16 +240,22 @@ describe("the socket (chapter 2.5)", () => {
type: "message.send",
payload: { idem_key: "k1", channel: "c1", text: "hello" },
}),
);
const ack = await nextFrame(socket, "message.ack");
expect(ack).toMatchObject({ type: "message.ack", payload: { seq: 7 } });
- // The gateway carried; the api decided. The identity travelled with it.
+ // The gateway carried; the api decided. The identity travelled with it —
+ // and after the credentials chapter the TOKEN travels too, because the internal hop
+ // forwards the user's own credential rather than asserting who they are.
expect(sent).toEqual([
{
- identity: { userExternalId: "tuan", environmentId: "env-1" },
+ identity: {
+ userExternalId: "tuan",
+ environmentId: "env-1",
+ token: VALID_TOKEN,
+ },
body: { channel_id: "c1", text: "hello", idempotency_key: "k1" },
},
]);
socket.close();
});@@ -1,19 +1,17 @@
import { randomUUID } from "node:crypto";
-import { SignJWT } from "jose";
import { WebSocket } from "ws";
import { afterEach, describe, expect, it } from "vitest";
import type { Server } from "node:http";
import type { AddressInfo } from "node:net";
import { createLogger, serve, type Logger } from "@relay/service-kit";
import type { Frame, Message } from "@relay/protocol";
import type { ApiClient } from "./api-client.js";
-import { DEV_JWT_SECRET } from "./auth.js";
import { createFanout } from "./fanout.js";
import { attachSessions } from "./session.js";
// Chapter 2.7's race, run against a REAL broker. The unit suite proves the
// ordering with a stub whose timing the test controls; this file proves it
// with Redis in the middle, where the publish is a network round trip on
@@ -40,19 +38,22 @@ function frame(seq: number): Message {
user: "dispatcher",
text: `m${seq}`,
created_at: "2026-08-04T00:00:00.000Z",
};
}
+const VALID_TOKEN = "token-for-tuan";
+
+/** The gateway holds no signing secret, so a test token is an
+ * opaque string the stubbed api agrees to recognise. What this suite proves —
+ * the resume race against a real broker — never depended on the signature. */
function token(): Promise<string> {
- return new SignJWT({ env: "env-1" })
- .setProtectedHeader({ alg: "HS256" })
- .setSubject("tuan")
- .sign(new TextEncoder().encode(DEV_JWT_SECRET));
+ return Promise.resolve(VALID_TOKEN);
}
+
interface Harness {
url: string;
close: () => Promise<void>;
}
async function boot(api: ApiClient): Promise<Harness> {
@@ -108,13 +109,17 @@ describe("resume across a real fabric", () => {
it("loses nothing and repeats nothing when a frame is published mid-backfill", async () => {
// The backfill leg is deliberately slow, and a DIFFERENT process — a
// different fanout client on the same subject — publishes into the
// window. Neither side coordinates; only the buffer saves this.
harness = await boot({
- memberships: async () => [CHANNEL],
+ session: async () => ({
+ environment_id: "env-1",
+ user: "tuan",
+ channel_ids: [CHANNEL],
+ }),
backfill: async () => {
await publishFromElsewhere(frame(43));
await settle(150); // give Redis time to actually deliver it
return {
[CHANNEL]: { messages: [frame(42), frame(43)], truncated: false },
};
@@ -135,13 +140,17 @@ describe("resume across a real fabric", () => {
});
it("delivers a mid-backfill frame that the backfill did not contain", async () => {
// Committed after the backfill's snapshot: it exists ONLY in the buffer,
// and the flush is the only reason the client ever sees it.
harness = await boot({
- memberships: async () => [CHANNEL],
+ session: async () => ({
+ environment_id: "env-1",
+ user: "tuan",
+ channel_ids: [CHANNEL],
+ }),
backfill: async () => {
await publishFromElsewhere(frame(43));
await settle(150);
return { [CHANNEL]: { messages: [frame(42)], truncated: false } };
},
sendMessage: async () => {
@@ -156,13 +165,17 @@ describe("resume across a real fabric", () => {
expect(created(frames)).toEqual([42, 43]);
socket.close();
});
it("goes live after the flush, with no buffering left behind", async () => {
harness = await boot({
- memberships: async () => [CHANNEL],
+ session: async () => ({
+ environment_id: "env-1",
+ user: "tuan",
+ channel_ids: [CHANNEL],
+ }),
backfill: async () => ({
[CHANNEL]: { messages: [frame(42)], truncated: false },
}),
sendMessage: async () => {
throw new Error("not used");
},@@ -2,13 +2,12 @@ import { spawn, type ChildProcess } from "node:child_process";
import { randomUUID } from "node:crypto";
import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
-import { SignJWT } from "jose";
import { WebSocket } from "ws";
import type { Frame, Message } from "@relay/protocol";
// The system harness (chapter 2.8): boots the api and N gateway instances as
// CHILD PROCESSES against the compose stores, wires a minimal client per
@@ -29,14 +28,12 @@ import type { Frame, Message } from "@relay/protocol";
// reuses this exact scenario.
const HERE = dirname(fileURLToPath(import.meta.url));
const REPO = join(HERE, "..", "..", "..");
const require_ = createRequire(import.meta.url);
-const DEV_SECRET = process.env.RELAY_DEV_JWT_SECRET ?? "dev-secret";
-
/** Store coordinates are FORWARDED, never invented. Each service already has
* a default (2.1's `DEFAULT_DATABASE_URL`, 2.6's `DEFAULT_REDIS_URL`), and a
* harness that composes its own URL from a port variable becomes a second
* source of truth — one that can hand a child process an address the parent
* would never have used itself. That is precisely how this suite first
* failed: turbo runs tasks in strict env mode, the port variable was
@@ -57,12 +54,20 @@ const forwarded = (...names: string[]): Record<string, string> =>
* test-only seam with a named retirement, like 2.3's `listMessagesRaw`. */
interface Seeder {
createEnvironment: (
db: unknown,
input: { name: string },
) => Promise<{ id: string }>;
+ /** The suite needs a real credential now, and it mints one the
+ * same way signup does. There is still no admin API for keys — that is the
+ * dashboard's chapter — so this stays a test-only seam with a named
+ * retirement, exactly like the rest of this interface. */
+ createApiKey: (
+ db: unknown,
+ input: { environmentId: string; name?: string },
+ ) => Promise<{ credential: string }>;
Repository: new (
db: unknown,
environmentId: string,
) => {
createUser: (
externalId: string,
@@ -273,12 +278,15 @@ export interface System {
apiUrl: string;
log: string[];
/** The services' own logs, for when an assertion is not the whole story. */
serviceOutput: () => string;
seedConversation: () => Promise<{
environmentId: string;
+ /** An API key for that environment — the credential the REST assertions
+ * present now that the asserted header is gone. */
+ credential: string;
channel: string;
dispatcher: Client;
tuan: Client;
}>;
seedForeignTenant: () => Promise<{ channel: string; text: string }>;
client: (name: string, environmentId: string) => Promise<Client>;
@@ -320,13 +328,12 @@ export async function boot({ gateways = 2 } = {}): Promise<System> {
...forwarded(
"DATABASE_URL",
"RELAY_POSTGRES_PORT",
"RELAY_REDIS_URL",
"RELAY_REDIS_PORT",
),
- RELAY_DEV_JWT_SECRET: DEV_SECRET,
};
const apiPort = Number(process.env.RELAY_E2E_API_PORT ?? 4100);
children.push(
capture(
"api",
@@ -364,17 +371,39 @@ export async function boot({ gateways = 2 } = {}): Promise<System> {
name: `e2e-${label}-${randomUUID().slice(0, 8)}`,
});
environments.push(created.id);
return new seeder.Repository(db, created.id);
};
- const token = (environmentId: string, subject: string) =>
- new SignJWT({ env: environmentId })
- .setProtectedHeader({ alg: "HS256" })
- .setSubject(subject)
- .sign(new TextEncoder().encode(DEV_SECRET));
+ /** The harness cannot sign a token any more, and that is the
+ * point — nothing outside the api holds a signing secret. It asks the api's
+ * development endpoint instead, with the environment's own key, which is
+ * exactly the path a reader follows to get their first token (FR-AUT-09). */
+ const keys = new Map<string, string>();
+ const keyFor = async (environmentId: string) => {
+ const existing = keys.get(environmentId);
+ if (existing !== undefined) return existing;
+ const minted = await seeder.createApiKey(db, { environmentId });
+ keys.set(environmentId, minted.credential);
+ return minted.credential;
+ };
+
+ const token = async (environmentId: string, subject: string) => {
+ const res = await fetch(`${apiUrl}/auth/dev-token`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${await keyFor(environmentId)}`,
+ },
+ body: JSON.stringify({ user: subject }),
+ });
+ if (!res.ok) {
+ throw new Error(`dev-token failed: ${res.status} ${await res.text()}`);
+ }
+ return ((await res.json()) as { token: string }).token;
+ };
let primaryEnvironment = "";
return {
gateways: urls,
apiUrl,
@@ -388,12 +417,14 @@ export async function boot({ gateways = 2 } = {}): Promise<System> {
const channel = await repo.createChannel("fleet", "public");
await repo.addMember(channel.id, dispatcherUser.id);
await repo.addMember(channel.id, tuanUser.id);
say(`seeded one channel with two members in ${primaryEnvironment}`);
return {
environmentId: primaryEnvironment,
+ // The REST assertions present a credential, not a header.
+ credential: await keyFor(primaryEnvironment),
channel: channel.id,
dispatcher: new Client(
"dispatcher",
await token(primaryEnvironment, "dispatcher"),
say,
),@@ -30,24 +30,24 @@ const isStrictlyAscending = (seqs: number[]): boolean =>
const sorted = (seqs: number[]): number[] => [...seqs].sort((a, b) => a - b);
describe("journey 4 — the message that survives the tunnel", () => {
let system: System;
let channel: string;
- let environmentId: string;
+ let credential: string;
let dispatcher: Client;
let tuan: Client;
let foreign: { channel: string; text: string };
/** Where Tuan's frame log stood when he came back — everything after this
* index arrived through the resume. */
let afterResume = 0;
beforeAll(async () => {
system = await boot({ gateways: 2 });
const seeded = await system.seedConversation(); // 2.1
- ({ channel, environmentId, dispatcher, tuan } = seeded);
+ ({ channel, credential, dispatcher, tuan } = seeded);
foreign = await system.seedForeignTenant();
// ── stage 1: type and send ───────────────────────────────────────────
// Two personas, two INSTANCES. 2.6 exists because a single-instance
// arrangement is blind to a whole class of bug (CON-02: any gateway
// must serve any socket).
@@ -196,29 +196,31 @@ describe("journey 4 — the message that survives the tunnel", () => {
// correctness is asserted, so the suite seeds a second tenant with
// traffic and confirms it stayed invisible.
const everything = JSON.stringify(tuan.frames);
expect(everything).not.toContain(foreign.text);
expect(everything).not.toContain(foreign.channel);
- // And through the REST door, with this tenant's header: a foreign
+ // And through the REST door, with this tenant's CREDENTIAL: a foreign
// channel is a 404, indistinguishable from one that does not exist
- // (FR-TEN-05).
+ // (FR-TEN-05). THE CREDENTIALS CHAPTER changed what proves the tenant here — a key
+ // this environment was issued, rather than a header naming it — and the
+ // assertion is deliberately unchanged.
const res = await fetch(
`${system.apiUrl}/v1/channels/${foreign.channel}/messages?limit=10`,
- { headers: { "x-relay-environment": environmentId } },
+ { headers: { authorization: `Bearer ${credential}` } },
);
expect(res.status).toBe(404);
});
it("agrees with history — the read path tells the same story (FR-MSG-09)", async () => {
// The live path and the read path are two doors onto one truth (2.4).
// If they disagree, one of them is lying, and the suite would rather
// know now.
const res = await fetch(
`${system.apiUrl}/v1/channels/${channel}/messages?limit=50&direction=newer`,
- { headers: { "x-relay-environment": environmentId } },
+ { headers: { authorization: `Bearer ${credential}` } },
);
expect(res.status).toBe(200);
const body = (await res.json()) as { messages: Message[] };
expect(body.messages.map((m) => m.seq)).toEqual(
sorted(seqsOf(tuan.timeline(channel))),
);@@ -1,36 +1,50 @@
// The chapter 2.5 walk, as a script (so the transcript in the chapter is
// reproducible rather than decorative). Seeds a user, channel and
// membership, mints a dev token, connects, sends, and retries with the
// SAME idempotency key so 2.3's recovery leg shows through the socket.
-import { SignJWT } from "jose";
import WebSocket from "ws";
import { createDb, createPool } from "../services/api/dist/db/client.js";
import {
+ createApiKey,
createEnvironment,
Repository,
} from "../services/api/dist/db/repository.js";
const GATEWAY = process.env.RELAY_GATEWAY_URL ?? "ws://127.0.0.1:4001";
-const SECRET = process.env.RELAY_DEV_JWT_SECRET ?? "dev-secret";
const db = createDb(createPool());
const env = await createEnvironment(db, { name: `ws-walk-${Date.now()}` });
const repo = new Repository(db, env.id);
const user = await repo.createUser("tuan", "Tuan");
const channel = await repo.createChannel("fleet", "public");
await repo.addMember(channel.id, user.id);
-const token = await new SignJWT({ env: env.id })
- .setProtectedHeader({ alg: "HS256" })
- .setSubject("tuan")
- .sign(new TextEncoder().encode(SECRET));
+const API = process.env.RELAY_API_URL ?? "http://127.0.0.1:4000";
+// Nothing outside the api can sign a token, so this walk gets one
+// the way a reader does — mint the environment's key, then ask the
+// development-only endpoint for a token (FR-AUT-09).
+const key = await createApiKey(db, { environmentId: env.id });
+const token = async (sub) => {
+ const res = await fetch(`${API}/auth/dev-token`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${key.credential}`,
+ },
+ body: JSON.stringify({ user: sub }),
+ });
+ if (!res.ok) throw new Error(`dev-token failed: ${res.status}`);
+ return (await res.json()).token;
+};
+
+const tuanToken = await token("tuan");
const started = Date.now();
-const socket = new WebSocket(`${GATEWAY}/v1/ws?token=${token}`);
+const socket = new WebSocket(`${GATEWAY}/v1/ws?token=${tuanToken}`);
const frame = {
type: "message.send",
payload: {
idem_key: "walk-key-1",
channel: channel.id,
text: "B2, north ramp",@@ -1,38 +1,49 @@
// The chapter 2.6 demonstration: two gateway instances, two users, one
// channel. Run it BEFORE fanout.ts exists (or with Redis stopped) to see
// the conversation split in half, and again after to see it whole. The
// script does not assert which outcome is correct — it REPORTS what the
// far side heard, so the same command tells you the truth in both states.
-import { SignJWT } from "jose";
import WebSocket from "ws";
import { createDb, createPool } from "../services/api/dist/db/client.js";
import {
+ createApiKey,
createEnvironment,
Repository,
} from "../services/api/dist/db/repository.js";
const G1 = process.env.RELAY_GW1 ?? "ws://127.0.0.1:4001";
const G2 = process.env.RELAY_GW2 ?? "ws://127.0.0.1:4002";
-const SECRET = process.env.RELAY_DEV_JWT_SECRET ?? "dev-secret";
const db = createDb(createPool());
const env = await createEnvironment(db, { name: `split-${Date.now()}` });
const repo = new Repository(db, env.id);
const dispatcher = await repo.createUser("dispatcher", "Dispatcher");
const driver = await repo.createUser("tuan", "Tuan");
const channel = await repo.createChannel("fleet", "public");
await repo.addMember(channel.id, dispatcher.id);
await repo.addMember(channel.id, driver.id);
-const token = (sub) =>
- new SignJWT({ env: env.id })
- .setProtectedHeader({ alg: "HS256" })
- .setSubject(sub)
- .sign(new TextEncoder().encode(SECRET));
+const API = process.env.RELAY_API_URL ?? "http://127.0.0.1:4000";
+// Nothing outside the api can sign a token, so this walk gets one
+// the way a reader does — mint the environment's key, then ask the
+// development-only endpoint for a token (FR-AUT-09).
+const key = await createApiKey(db, { environmentId: env.id });
+const token = async (sub) => {
+ const res = await fetch(`${API}/auth/dev-token`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${key.credential}`,
+ },
+ body: JSON.stringify({ user: sub }),
+ });
+ if (!res.ok) throw new Error(`dev-token failed: ${res.status}`);
+ return (await res.json()).token;
+};
const heard = [];
function connect(url, who, sub) {
return new Promise((resolve) => {
const socket = new WebSocket(`${url}/v1/ws?token=${sub}`);@@ -7,23 +7,22 @@
// Phase 2 is FR-RTM-04's ceiling: a channel that ran away while the client
// was gone, where the honest answer is "page history instead."
//
// node services/api/dist/main.js &
// (cd services/gateway && PORT=4001 pnpm exec tsx src/main.ts &)
// node scripts/tunnel-walk.mjs
-import { SignJWT } from "jose";
import WebSocket from "ws";
import { createDb, createPool } from "../services/api/dist/db/client.js";
import {
+ createApiKey,
createEnvironment,
Repository,
} from "../services/api/dist/db/repository.js";
const GW = process.env.RELAY_GW ?? "ws://127.0.0.1:4001";
-const SECRET = process.env.RELAY_DEV_JWT_SECRET ?? "dev-secret";
const BACKFILL_LIMIT = 500;
const db = createDb(createPool());
const env = await createEnvironment(db, { name: `tunnel-${Date.now()}` });
const repo = new Repository(db, env.id);
const tuan = await repo.createUser("tuan", "Tuan");
@@ -32,17 +31,29 @@ const channel = await repo.createChannel("fleet", "public");
const flood = await repo.createChannel("flood", "public");
for (const c of [channel, flood]) {
await repo.addMember(c.id, tuan.id);
await repo.addMember(c.id, dispatcher.id);
}
-const token = (sub) =>
- new SignJWT({ env: env.id })
- .setProtectedHeader({ alg: "HS256" })
- .setSubject(sub)
- .sign(new TextEncoder().encode(SECRET));
+const API = process.env.RELAY_API_URL ?? "http://127.0.0.1:4000";
+// Nothing outside the api can sign a token, so this walk gets one
+// the way a reader does — mint the environment's key, then ask the
+// development-only endpoint for a token (FR-AUT-09).
+const key = await createApiKey(db, { environmentId: env.id });
+const token = async (sub) => {
+ const res = await fetch(`${API}/auth/dev-token`, {
+ method: "POST",
+ headers: {
+ "content-type": "application/json",
+ authorization: `Bearer ${key.credential}`,
+ },
+ body: JSON.stringify({ user: sub }),
+ });
+ if (!res.ok) throw new Error(`dev-token failed: ${res.status}`);
+ return (await res.json()).token;
+};
/** Connect and report every frame, keeping a cursor the way a client would:
* the highest sequence it has actually applied, per channel. */
async function connect(label, sub, cursors = {}, quiet = false) {
const query = Object.entries(cursors)
.map(([id, seq]) => `&cursor=${id}:${seq}`)@@ -54,12 +54,18 @@ async function signUp(label) {
console.log(`${label} → GET /auth/github/callback`);
console.log(` ${res.status} created=${body.created}`);
console.log(` organisation ${body.organisation?.id}`);
console.log(` application ${body.application?.id}`);
console.log(
` environment ${body.environment?.id} (${body.environment?.kind})\n`,
+ // The first API key, shown exactly once. Truncated here —
+ // this transcript ends up in a chapter, and NFR-SEC-06 does not make an
+ // exception for documentation.
+ body.api_key
+ ? ` api key ${body.api_key.secret.slice(0, 18)}… (shown once)\n`
+ : ` api key none — the secret was shown at creation and is gone\n`,
);
return body;
}
const first = await signUp("first authentication ");
const second = await signUp("second authentication");// The credentials chapter walk, as a script (so the transcript in the chapter is
// reproducible rather than decorative).
//
// It follows the path a reader actually takes: mint a key, send with it, turn
// it into an end-user token, open a socket with THAT, and then present each
// credential where the other belongs. The last two steps are the point — the
// SRS calls confusing the two the most common first-integration failure, and
// this prints both refusals rather than describing them.
//
// docker compose up -d --wait postgres redis
// pnpm build
// node services/api/dist/main.js & # :4000
// node services/gateway/dist/main.js & # :4001
// node scripts/credential-walk.mjs
import WebSocket from "ws";
import { createDb, createPool } from "../services/api/dist/db/client.js";
import {
createApiKey,
createEnvironment,
Repository,
} from "../services/api/dist/db/repository.js";
const API = process.env.RELAY_API_URL ?? "http://127.0.0.1:4000";
const GATEWAY = process.env.RELAY_GATEWAY_URL ?? "ws://127.0.0.1:4001";
const show = (label, value) => console.log(`${label.padEnd(26)} ${value}`);
/** Credentials are TRUNCATED on the way to the terminal. A walk whose output
* ends up pasted into a chapter must not carry a working secret with it
* (NFR-SEC-06), and showing the shape is the part that teaches anything. */
const brief = (credential) => `${credential.slice(0, 18)}…`;
const db = createDb(createPool());
const env = await createEnvironment(db, {
name: `credential-walk-${Date.now()}`,
});
const repo = new Repository(db, env.id);
const tuan = await repo.createUser("tuan", "Tuan");
const channel = await repo.createChannel("fleet", "public");
await repo.addMember(channel.id, tuan.id);
// ── 1. the application's credential ─────────────────────────────────────────
const key = await createApiKey(db, { environmentId: env.id, name: "walk" });
show("api key (shown once)", brief(key.credential));
show("its prefix", key.prefix);
const sent = await fetch(`${API}/v1/channels/${channel.id}/messages`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${key.credential}`,
},
body: JSON.stringify({ text: "B2, north ramp" }),
});
show("POST with the key", `${sent.status} seq=${(await sent.json()).seq}`);
// ── 2. the end user's credential ────────────────────────────────────────────
const minted = await fetch(`${API}/auth/dev-token`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${key.credential}`,
},
body: JSON.stringify({ user: "tuan" }),
});
const { token, expires_at } = await minted.json();
show("dev-token", `${minted.status} expires ${expires_at}`);
const heard = await new Promise((resolve, reject) => {
const socket = new WebSocket(`${GATEWAY}/v1/ws?token=${token}`);
socket.on("message", (raw) => {
const frame = JSON.parse(raw.toString());
if (frame.type === "connection.ack") {
show("socket with the token", `open as ${frame.payload.user}`);
socket.close();
resolve(frame.payload.user);
}
});
socket.on("close", (code) => {
if (code !== 1000 && code !== 1005) reject(new Error(`closed ${code}`));
});
setTimeout(() => reject(new Error("no ack within 5s")), 5_000);
});
// ── 3. each credential where the other belongs ──────────────────────────────
// The mistake, both ways round. Neither answer is a generic "unauthorized":
// one names the classes, the other is a close code a client can act on.
const wrongWay = await fetch(`${API}/auth/dev-token`, {
method: "POST",
headers: {
"content-type": "application/json",
authorization: `Bearer ${token}`,
},
body: JSON.stringify({ user: "tuan" }),
});
const refusal = await wrongWay.json();
show("token → key-only route", `${wrongWay.status} ${refusal.code}`);
console.log(`${"".padEnd(26)} "${refusal.message}"`);
const keyOnSocket = await new Promise((resolve) => {
const socket = new WebSocket(`${GATEWAY}/v1/ws?token=${key.credential}`);
socket.on("close", (code) => resolve(code));
socket.on("error", () => undefined);
});
show("key → socket", `closed ${keyOnSocket}`);
console.log(`\nheard as ${heard}; both credentials worked, both refusals held.`);
process.exit(0);@@ -22,13 +22,12 @@
"cache": false,
"env": [
"DATABASE_URL",
"RELAY_POSTGRES_PORT",
"RELAY_REDIS_URL",
"RELAY_REDIS_PORT",
- "RELAY_DEV_JWT_SECRET",
"RELAY_E2E_API_PORT"
]
},
"//#lint:root": {
"inputs": [
"**/*.{ts,mts,cts,mjs,js}",Checkpoint
docker compose up -d --wait postgres redis
pnpm build
DATABASE_URL="postgres://relay:relay@localhost:15432/relay" node services/api/dist/db/migrate.js
pnpm lint && pnpm typecheck && pnpm test
RELAY_POSTGRES_PORT=15432 RELAY_REDIS_PORT=16379 \
DATABASE_URL="postgres://relay:relay@localhost:15432/relay" \
RELAY_REDIS_URL="redis://localhost:16379" pnpm test:integrationKỳ vọng: 109 bài test unit, 76 bài test tích hợp, tất cả đều xanh — kể cả suite hành trình của chương 2.8, thứ giờ chạy hoàn toàn trên credential thật.
Rồi tới phép kiểm đường nối, thứ phải chẳng in ra gì cả:
grep -rn "x-relay-environment" services packages scripts --include=*.ts --include=*.mjs | grep -v itest
grep -rn "RELAY_DEV_JWT_SECRET\|DEV_JWT_SECRET" services --include=*.ts | grep -v testVà lần đi bộ, với cả hai service đang chạy:
node scripts/credential-walk.mjsKỳ vọng: một key gửi một message, mint một token, token mở một socket — rồi
token bị từ chối ở route chỉ-dành-cho-key với wrong_credential_type, còn
key thì bị từ chối ở socket với mã đóng 4001.