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
Chapter 3.1 built the containers a customer's world lives in and the signup that creates them. It did not build a way to prove you own one. Every request since 2.2 has named its tenant with a header it simply asserted:
x-relay-environment: 3f2a…Anyone who could reach the api could type that header, name any environment, and read it. I called it a dev-mode seam at the time and promised Part 3 would replace it. This is that chapter, and the seam does not survive it.
There is a second seam of the same age. The gateway has been verifying
WebSocket tokens itself, with HS256 over a shared RELAY_DEV_JWT_SECRET that
every service and every test knew. Both seams die here, in two separate
increments, because they have different blast radii and I want you to see both
numbers.
What replaces them is two credentials — and the chapter's subject is not really either one of them. It is the mistake people make with the pair.
Two populations, two credentials
Chapter 3.1 drew a line through the data: humans above the tenant boundary
are people who sign in to Relay, users below it are people inside a
customer's product, and the two are never merged (ADR-18). That line implies a
second one. There are two kinds of caller, and they are asking for different
things.
An application — a company's backend, holding a credential in its own environment variables — says I am this environment. It sends messages on behalf of whoever it likes, because it is the customer. An end user — a driver in a van, a browser tab — says I am this person in that environment. It may only act as itself.
One credential cannot carry both claims honestly, so there are two.
flowchart TB
subgraph app["The APPLICATION — a company's backend"]
key["API key<br/>rk_dev_<public_id>_<secret><br/>(FR-AUT-01, FR-AUT-03)"]
end
subgraph user["The END USER — a person in that company's product"]
tok["End-user token<br/>HS256, signed with the environment's own secret<br/>(FR-AUT-06, FR-AUT-08)"]
end
rest["REST: POST · GET /v1/channels/:id/messages<br/>accepts either class"]
dev["POST /auth/dev-token<br/>API key ONLY, development ONLY (FR-AUT-09)"]
ws["WebSocket upgrade /v1/ws?token=<br/>end-user token ONLY (EIR-WS-05)"]
key --> rest
key --> dev
tok --> rest
tok --> ws
key -. "403 wrong_credential_type" .-> ws
tok -. "403 wrong_credential_type" .-> dev
note["Both resolve to ONE principal carrying an environment_id.<br/>Nothing downstream asks which class it was — except the<br/>routes that must (research R6)"]
rest ~~~ noteBoth resolve to a single shape, which is the point. Authentication produces a
principal, the principal carries an environmentId, and everything
downstream reads that one field. The classes differ in exactly two places: what
each is allowed to reach, and what happens when someone confuses them.
// What authentication produces (chapter 3.2). 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 3.6'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;
}A handle you can look up, and a secret you cannot
An API key has to solve a problem no other query in this codebase has. Every repository method since 2.1 begins with an environment; this one produces one. Authentication happens before a tenant scope exists, which means the key lookup is the single unscoped query in the system.
That rules out the obvious design. If the whole credential were hashed, there would be nothing to look it up by, and every request would scan every key in the platform. So the credential is two parts: a public handle that is indexed and is not secret, and a secret that is hashed and never stored.
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?The prefix is a requirement, not decoration. FR-AUT-03 asks that a key carry a
visible environment marker, and the reason is a support ticket nobody wants to
write: rk_live_ in a staging config file is a mistake you can see at a glance,
where an opaque blob is one you find out about later.
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
// The API key as a string, and the arithmetic behind it (chapter 3.2).
// 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];
}The table it lands in carries the environment, like everything else below the boundary. What it does not carry is any constraint on how many keys an environment may have — several active at once is the feature, not an oversight, because that is what rotation without downtime means.
@@ -132,6 +132,52 @@ export const environments = pgTable(
],
);
+// DECISION (chapter 3.2): 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 3.1 recorded the tenancy 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",
{-- Chapter 3.2 — 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;The secret you see once
A brand-new organisation cannot authenticate a request to ask for its first key. There is no console session — 3.1 deliberately did not build one — so something has to bootstrap the credential, and signup is the only act that already knows who you are.
So provisionOrganisation mints the environment's first key inside the same
transaction as the tenant it belongs to, and the callback returns its plaintext
exactly once. FR-DSH-01 wants a development key on the first screen after
signup; this is where that key comes from.
@@ -4,6 +4,7 @@ import { and, asc, desc, eq, gt, lt, sql, type SQL } from "drizzle-orm";
import type { Db } from "./client";
import {
+ apiKeys,
applications,
channels,
environments,
@@ -14,6 +15,13 @@ import {
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:
@@ -69,6 +77,147 @@ export async function createEnvironment(
return { id: environmentId, kind };
}
+// ---------------------------------------------------------------------------
+// Credentials (chapter 3.2). 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. */
@@ -78,6 +227,12 @@ export interface Provisioned {
environment: { id: string; kind: Environment["kind"] };
human: { id: string; provider: string; provider_account_id: string };
created: boolean;
+ /** Chapter 3.2, 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 (chapter 3.1, FR-TEN-01/02). The admin surface's second entrance:
@@ -229,12 +384,20 @@ export async function provisionOrganisation(
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 (chapter 3.2). 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 },
};
});
}The walk from 3.1 now shows what it hands over, and — more usefully — what it does not hand over the second time:
$ 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 goneA returning owner is not handed a new secret. The old one is unrecoverable by construction, and the answer to a lost key is rotation, not retrieval — which is only a usable answer because several keys may be active at once.
Tokens the api signs, and the gateway cannot
End-user tokens are signed with the environment's own signing_secret. That
column lives in Postgres, and ADR-05 says the gateway never touches Postgres.
Two ways out. Ship every environment's signing secret to the gateway, or ask the service that owns them. Shipping means a tenant-scoped secret inside a process that deliberately holds no tenant state, multiplied by every gateway instance, plus a rotation story for when one changes. Asking costs one HTTP call.
And the gateway was already making one. Since 2.5 it has asked the api "what channels may this user hear?" at connect. Replacing that question with "who is this, and what may they hear?" costs the same round trip and moves the identity decision to the only service that can make it.
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: holds NO signing secret<br/>after this chapter
G->>A: POST /internal/session<br/>Authorization: Bearer (the same token)
A->>DB: environments.signing_secret for the token's env claim
A->>A: verify HS256 · check sub/env/iat/exp (FR-AUT-06/07/08)
A->>DB: channels this user belongs to
A-->>G: 200 { environment_id, user, channel_ids }
G-->>C: connection.ack
Note over G,A: ONE call, the same one 2.5 already made for<br/>memberships. The connect path gained no round trip —<br/>it stopped asking the smaller question (research R1)import { SignJWT, decodeJwt, jwtVerify } from "jose";
// End-user tokens (chapter 3.2, 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 3.1 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;
}
}There is a subtlety in the order of operations. The api has to know which environment's secret to check a signature with, and the only place that information exists is inside the token — unverified. So the environment claim is read first, without trust, purely to choose the key; the signature then decides whether to believe it. Read the claim after verifying and you cannot verify at all; trust the claim for anything else and a token from environment A walks into environment B.
The route that puts it together replaces GET /internal/memberships outright:
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` (chapter 3.2) — 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) : [],
};
}
}And the gateway's door becomes a phone call:
import type { ApiClient, Identity } from "./api-client.js";
// The door (chapter 2.5, rebuilt by 3.2). Tokens are still checked BEFORE the
// handshake completes — an unauthenticated socket never reaches session code —
// but the gateway no longer does the checking.
//
// 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 type { Identity } from "./api-client.js";
/** 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 authenticate(
api: ApiClient,
token: string | null,
): Promise<Authentication> {
if (token === null || token.length === 0) return { outcome: "refused" };
try {
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) };
}
}Note what that type says. Three outcomes, not two — because moving verification to the api introduced a failure the gateway never had: the verifier being down. A refused token and an unreachable api both fail to open a socket, and answering them the same way would be a lie in one direction. 4001 tells a client its credential is wrong, so stop retrying; 1011 tells it we are broken, so do retry. Chapter 2.5 drew that distinction for the memberships lookup, and moving the lookup must not quietly erase it.
Where authentication runs, and how I know
Chapter 2.6 cost me an afternoon to learn that NestJS constructs request-scoped providers before the enhancer chain runs — which is why 2.2's repository factory reads the request itself rather than trusting a guard to have stashed something. That finding constrains this chapter: authentication now involves a database lookup, and a guard would still run too late for the factory that needs its answer.
Middleware runs earlier. But "runs earlier" is a thing I believed rather than a thing I had measured on this path, so before building on it I logged one line from each of the three places and sent a single request:
DIAG middleware
DIAG repository-factory
DIAG guardMiddleware, then the factory, then the guard. The design holds, the named fallback is not needed, and the measurement took four minutes.
flowchart LR
req["request arrives"]
mw["RequestContextMiddleware<br/>then AuthenticateMiddleware<br/>→ req.principal"]
fac["request-scoped Repository factory<br/>reads principal.environmentId"]
grd["CredentialGuard<br/>may THIS class use THIS route?"]
hnd["handler"]
req --> mw --> fac --> grd --> hnd
measured["Measured, not assumed (T004):<br/>middleware → factory → guard.<br/>2.6 found the factory runs before the enhancer chain,<br/>which is why authentication cannot live in a 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();
}
}The guard that remains is smaller than the one it replaces. EnvironmentContextGuard
resolved a tenant from a header; CredentialGuard resolves nothing. It asks one
question — may this class of credential use this route? — and produces the two
refusals this chapter is about.
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;
}
}The mistake the SRS predicted
The SRS has a design note under the authentication requirements, and it is unusually specific:
Confusion between API keys and user tokens is the most common first-integration failure. FR-AUT-03 and FR-AUT-09 exist specifically to reduce it, and the error message for using the wrong credential type must name the mistake explicitly.
A generic 401 unauthorized satisfies the status code and fails that
requirement completely. Someone holding a perfectly valid credential, presented
at the wrong door, learns nothing from being told they are unauthorised — they
will check the secret, rotate the key, and read the docs again, none of which
is the problem.
So the wrong class gets its own code and a message that names both halves:
{
"code": "wrong_credential_type",
"message": "this route expects an API key; an end-user token was presented",
"docs_url": "https://relay.example/docs/errors/wrong_credential_type"
}Note what the message does not contain. It names the class, never the
credential. "The key rk_dev_abc… is not valid" is how a live secret reaches a
support ticket, a screenshot, and a log aggregator, and NFR-SEC-06 forbids
exactly that. There is a test that greps every log line and every error body
produced during a batch of deliberate failures, and fails if any of them
contains a secret.
The walk shows both credentials working and both refusals holding:
$ 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.The endpoint in the middle of that transcript is FR-AUT-09's, and it exists for
one reason: without it, a developer's first authenticated message is blocked
behind implementing JWT minting. It takes an API key and returns a token. It
lives only in development — and it answers 404 in production rather than
403, deliberately. A 403 says you lack a permission, which invites someone
to go looking for the permission that would unlock it. A 404 says this route
does not exist here, which is the truth.
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 };
}
}Both seams, gone
The header first. Retiring it is one increment because a half-retired seam is worse than either state, and the blast radius was measured rather than guessed: eleven files, of which three were production code paths and the rest were suites presenting the header or the identity headers that paired with it.
The gateway's shared development secret is the second, and it lands in seven more: the gateway's door, two of its suites, the e2e harness, and three walk scripts that had all been minting their own tokens with a secret they knew.
The check is mechanical, and I wrote it to expect zero rather than "only a few":
$ 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
$Nothing. Not "nothing outside comments" — the comments that describe the retired seam do so without quoting it, so the check needs no exclusions to come back clean. A check that needs a caveat is a check somebody will eventually explain away.
The file that started it all goes with them:
Retired by chapter 3.2. It resolved a tenant from a header any caller could
type — the dev-mode seam 2.2 introduced with a named expiry date. Nothing
replaces it one-for-one: the middleware resolves a principal from a verified
credential, and CredentialGuard keeps only the question a guard can answer.And the factory 2.2 wrote reads a principal where it used to read a header. It is the one-line swap that chapter promised, and the reason it stayed one line is that the seam was named and isolated from the day it was introduced:
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 { 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 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. Middleware runs earlier still, which is why chapter 3.2
// authenticates there (research R5, measured in T004).
//
// Chapter 3.2 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,
],
exports: [Repository, MessagesService],
})
export class MessagesModule {}What this chapter leaves for later, on purpose
A refreshed token on a live connection (FR-AUT-11, second clause). An established socket outlives its token — verification happens at connect, and no timer re-checks it. That is the first clause, and it is tested. But the internal hop forwards that same token, so a client that keeps a socket open past its token's expiry can still receive and can no longer send. Rather than let that fail as an "internal error", the socket says what a client can actually do:
{
"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.example/docs/errors/unauthorized"
}
}Closing that gap properly needs a protocol frame — a way for the client to hand over a new token on the open connection — which is a change to the wire, and the wire's shape belongs with the SDK that speaks it.
Rate-limited authentication failures (FR-AUT-12). Quotas and rate limits are chapter 3.6's subject, and building half a limiter here would mean building it twice.
Key management. Creating, listing and revoking keys through an API needs a console session to authenticate the person doing it, and a session is the dashboard's chapter in Part 5. Revocation is implemented and tested at the repository layer today; what is missing is the door, not the mechanism.
Service-to-service credentials on the internal hop. The gateway and api still trust the network between them, exactly as chapter 2.5 recorded. What changed is that the gateway no longer asserts an identity: it forwards the end user's token and the api decides. The trust boundary moved in the right direction; it has not disappeared.
The tests, and what they hold
Twelve invariants, each with a name that says what it protects. Nine of them run over real HTTP against Postgres, two are pure, and one needs a 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)Invariant 4 is the one I would keep if I could keep only one. A key for environment A presenting environment B's channel gets a 404 — and the test asserts that the response is byte-identical to the one you get for a channel that does not exist anywhere. Isolation that leaks the existence of a neighbour is not isolation; it is a directory.
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 (chapter 3.2).
// 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);
});
});Revocation deserves a sentence about what is not there. FR-AUT-05 gives a five-second bound for a revoked key to stop working, which is usually read as "invalidate your cache fast enough". The cheaper reading is to have no cache: one indexed lookup per request, in the connection pool the request already uses, and the bound is true by construction on every instance with nothing to invalidate. If profiling ever makes that the bottleneck, the fix is a cache plus an invalidation channel — which is chapter 2.6's fabric doing a second job, and it belongs to whichever chapter measures the need rather than this one.
The blast radius, file by file
The two seam retirements touched twenty-nine files that earlier chapters had already shown you. They are all here, because a chapter that changes code you copied and does not say so is how a tutorial starts lying.
Most are three lines: a header swapped for a bearer credential in a suite, a walk script asking the api for a token instead of signing its own. Read them as one thing rather than twenty-nine — this is the cost of a seam, paid in full, on the day it was retired.
@@ -21,6 +21,13 @@ export const ERROR_CODES = {
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",
+ // Chapter 3.2. 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;@@ -87,7 +87,27 @@ export const internalMembershipsResponseSchema = z.strictObject({
channel_ids: z.array(z.string().min(1)),
});
+/** api → gateway, chapter 3.2: 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@@ -19,6 +19,7 @@
"@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",@@ -5,6 +5,8 @@ import {
} 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";
@@ -18,7 +20,7 @@ import { RequestContextMiddleware } from "./request-context.middleware";
// 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 },
@@ -28,6 +30,12 @@ import { RequestContextMiddleware } from "./request-context.middleware";
})
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.
+ // Chapter 3.2 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 (chapter 3.2), 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 {}@@ -21,14 +21,33 @@ export class ProtocolErrorFilter implements ExceptionFilter {
// 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.
+ //
+ // Chapter 3.2 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@@ -8,7 +8,7 @@ import {
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
@@ -21,8 +21,14 @@ 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).
+//
+// Chapter 3.2 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) {}
import {
BadRequestException,
Body,
Controller,
Post,
Req,
UseGuards,
} from "@nestjs/common";
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, narrowed by 3.2): 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")
// 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,
) {}
@Post("messages")
async send(
@Body(new ZodValidationPipe(internalSendRequestSchema))
body: InternalSendRequest,
@Req() req: RequestWithPrincipal,
) {
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,
...(body.idempotency_key !== undefined && {
idempotency_key: body.idempotency_key,
}),
},
// Chapter 2.6: the sender is RESOLVED here and, until now, dropped
// here — every socket-written row had user_id NULL. Fan-out cannot
// build a message.created frame without a sender, so the write path
// finally records the one it already had in its hand.
user.id,
);
// `user` is echoed as the EXTERNAL id: internal uuids are ours, and
// the frame this becomes is client-facing.
return { ...message, user: userExternalId };
}
}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, AuthModule],
controllers: [InternalController, BackfillController, SessionController],
})
export class InternalModule {}@@ -2,8 +2,8 @@ import {
BadRequestException,
Body,
Controller,
- Headers,
Post,
+ Req,
UseGuards,
} from "@nestjs/common";
@@ -15,7 +15,8 @@ import {
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";
@@ -29,7 +30,10 @@ import { ZodValidationPipe } from "../messages/zod-validation.pipe";
// 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)
+// Chapter 3.2: 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) {}
@@ -37,10 +41,13 @@ export class BackfillController {
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: {} };@@ -35,7 +35,9 @@ import {
//
// What these routes do NOT do: issue a session. A session is a credential,
// credentials are 3.2'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 3.2, 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.
*
@@ -127,6 +129,18 @@ export class SignupController {
organisation: result.organisation,
application: result.application,
environment: result.environment,
+ // Chapter 3.2, 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,11 +1,12 @@
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
@@ -20,13 +21,39 @@ import {
// 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. Chapter 3.2 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;
+ /** Chapter 3.2: 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[]>;
+ /** Chapter 3.2: 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(
@@ -40,10 +67,14 @@ export interface ApiClient {
}
export function createApiClient(baseUrl: string): ApiClient {
+ // Chapter 3.2 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>(
@@ -51,7 +82,7 @@ export function createApiClient(baseUrl: string): ApiClient {
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`);
@@ -60,16 +91,19 @@ export function createApiClient(baseUrl: string): ApiClient {
}
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`, {@@ -10,8 +10,8 @@ import {
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 {
@@ -112,16 +112,29 @@ export function attachSessions({
}
const token = url.searchParams.get("token");
void (async () => {
- const identity = token ? await verifyToken(token) : null;
+ // Chapter 3.2: 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 ?? "/");
});
})();
});
@@ -129,6 +142,7 @@ export function attachSessions({
async function open(
socket: WebSocket,
identity: Identity,
+ channelIds: string[],
url: string,
): Promise<void> {
// Cursors are read BEFORE anything else, because their presence decides
@@ -138,26 +152,16 @@ export function attachSessions({
id: randomUUID(),
identity,
socket,
- channelIds: new Set(),
+ // Chapter 3.2: 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
@@ -410,6 +414,22 @@ export function attachSessions({
connection_id: connection.id,
error: String(error),
});
+ // Chapter 3.2. 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");
}
}@@ -6,7 +6,7 @@ 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,
@@ -17,6 +17,10 @@ describe("POST /v1/channels/:channelId/messages", () => {
let app: INestApplication;
let url: string;
let env: { id: string };
+ // Chapter 3.2: 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;
@@ -26,6 +30,7 @@ describe("POST /v1/channels/:channelId/messages", () => {
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")
@@ -41,12 +46,12 @@ describe("POST /v1/channels/:channelId/messages", () => {
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),
});
@@ -75,11 +80,11 @@ describe("POST /v1/channels/:channelId/messages", () => {
// 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);@@ -5,13 +5,18 @@ 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
@@ -28,6 +33,10 @@ describe("the internal surface", () => {
let url: string;
let env: { id: string };
let channelId: string;
+ /** Chapter 3.2: 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());
@@ -36,6 +45,16 @@ describe("the internal surface", () => {
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 });
@@ -47,16 +66,15 @@ describe("the internal surface", () => {
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),
});
@@ -96,7 +114,7 @@ describe("the internal surface", () => {
);
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
@@ -105,24 +123,43 @@ describe("the internal surface", () => {
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(),
+ // Chapter 3.2 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);
+ });
});@@ -12,7 +12,12 @@ import {
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
@@ -27,6 +32,9 @@ describe("POST /internal/backfill", () => {
let quietChannelId: string;
let leftChannelId: string;
let tuan: { id: string };
+ /** Chapter 3.2: 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());
@@ -44,6 +52,16 @@ describe("POST /internal/backfill", () => {
// 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 });
@@ -55,13 +73,12 @@ describe("POST /internal/backfill", () => {
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 }),
});@@ -283,11 +283,11 @@ describe("signup", () => {
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",
- },
- });
+ // Chapter 3.2: there is no header left to forge here. The assertion is
+ // unchanged — no route but signup creates a tenant — and a credential-free
+ // internal call is now refused before it reaches a handler, which is a
+ // stronger form of the same guarantee.
+ await fetch(`${url}/internal/memberships`);
const after = await db.execute(
`SELECT count(*)::int AS n FROM organisations`,
);@@ -1,4 +1,3 @@
-import { SignJWT } from "jose";
import { WebSocket } from "ws";
import { afterEach, describe, expect, it } from "vitest";
import type { Server } from "node:http";
@@ -11,7 +10,6 @@ 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";
@@ -38,7 +36,14 @@ function committed(seq: number): InternalSendResponse {
function stubApi(overrides: Partial<ApiClient> = {}): ApiClient {
return {
- memberships: async () => [CHANNEL],
+ // Chapter 3.2: 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,
@@ -59,11 +64,22 @@ function frame(seq: number, channel = CHANNEL): Message {
};
}
+/** 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 chapter 3.2 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 {
@@ -190,6 +206,23 @@ describe("the socket (chapter 2.5)", () => {
}
});
+ it("closes 1011, not 4001, when the api cannot answer at all", async () => {
+ // New in chapter 3.2, 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(
@@ -210,10 +243,16 @@ describe("the socket (chapter 2.5)", () => {
);
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 chapter 3.2 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" },
},
]);@@ -1,6 +1,5 @@
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";
@@ -10,7 +9,6 @@ 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";
@@ -43,13 +41,16 @@ function frame(seq: number): Message {
};
}
+const VALID_TOKEN = "token-for-tuan";
+
+/** Chapter 3.2: 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>;
@@ -111,7 +112,11 @@ describe("resume across a real fabric", () => {
// 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
@@ -138,7 +143,11 @@ describe("resume across a real fabric", () => {
// 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);
@@ -159,7 +168,11 @@ describe("resume across a real fabric", () => {
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 },
}),@@ -5,7 +5,6 @@ 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";
@@ -32,8 +31,6 @@ 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
@@ -60,6 +57,14 @@ interface Seeder {
db: unknown,
input: { name: string },
) => Promise<{ id: string }>;
+ /** Chapter 3.2: 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,
@@ -276,6 +281,9 @@ export interface System {
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 (chapter 3.2). */
+ credential: string;
channel: string;
dispatcher: Client;
tuan: Client;
@@ -323,7 +331,6 @@ export async function boot({ gateways = 2 } = {}): Promise<System> {
"RELAY_REDIS_URL",
"RELAY_REDIS_PORT",
),
- RELAY_DEV_JWT_SECRET: DEV_SECRET,
};
const apiPort = Number(process.env.RELAY_E2E_API_PORT ?? 4100);
@@ -367,11 +374,33 @@ export async function boot({ gateways = 2 } = {}): Promise<System> {
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));
+ /** Chapter 3.2: 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 = "";
@@ -391,6 +420,8 @@ export async function boot({ gateways = 2 } = {}): Promise<System> {
say(`seeded one channel with two members in ${primaryEnvironment}`);
return {
environmentId: primaryEnvironment,
+ // Chapter 3.2: the REST assertions present a credential, not a header.
+ credential: await keyFor(primaryEnvironment),
channel: channel.id,
dispatcher: new Client(
"dispatcher",@@ -33,7 +33,7 @@ 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 };
@@ -44,7 +44,7 @@ describe("journey 4 — the message that survives the tunnel", () => {
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 ───────────────────────────────────────────
@@ -199,12 +199,14 @@ describe("journey 4 — the message that survives the tunnel", () => {
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). Chapter 3.2 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);
});
@@ -215,7 +217,7 @@ describe("journey 4 — the message that survives the tunnel", () => {
// 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[] };@@ -2,17 +2,16 @@
// 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()}` });
@@ -21,13 +20,28 @@ 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";
+// Chapter 3.2: 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: {@@ -3,18 +3,17 @@
// 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()}` });
@@ -25,11 +24,23 @@ 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";
+// Chapter 3.2: 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 = [];
@@ -10,17 +10,16 @@
// 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());
@@ -35,11 +34,23 @@ for (const c of [channel, flood]) {
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";
+// Chapter 3.2: 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. */@@ -57,6 +57,12 @@ async function signUp(label) {
console.log(` application ${body.application?.id}`);
console.log(
` environment ${body.environment?.id} (${body.environment?.kind})\n`,
+ // Chapter 3.2: 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;
}// The chapter 3.2 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);@@ -25,7 +25,6 @@
"RELAY_POSTGRES_PORT",
"RELAY_REDIS_URL",
"RELAY_REDIS_PORT",
- "RELAY_DEV_JWT_SECRET",
"RELAY_E2E_API_PORT"
]
},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:integrationExpected: 109 unit tests, 76 integration tests, all green — including chapter 2.8's journey suite, which now runs entirely on real credentials.
Then the seam check, which must print nothing at all:
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 testAnd the walk, with both services running:
node scripts/credential-walk.mjsExpected: a key sends a message, mints a token, the token opens a socket — then
the token is refused at the key-only route with wrong_credential_type, and the
key is refused at the socket with close code 4001.