Part 3 · Chapter 3.1
You will produce: Orgs, apps, environments; OAuth signup; the auto-created dev environment · about 95 minutes including the exercise
Source: SRS — Software Requirements Specification · SAD — Software Architecture Document
Part 2 ended with a passing suite and a defensible claim: messages arrive exactly once, in order, across instances, through a tunnel. What it did not end with is a product anybody else can use. Every request in Part 2 named its tenant with a header the client simply asserted, and every tenant in Part 2 was created by a test helper. There is no way to become a customer.
This chapter builds that: the containers a customer's world lives in, and the
single act that creates them. It also pays a debt you watched me take on.
Chapter 2.1 needed an applications row to satisfy a foreign key, did not
have a definition for one, and left this behind:
// DECISION (chapter 2.1): the SAD's environments table references
// applications(id) but never defines the table. This stub satisfies the
// foreign key; the real application lifecycle belongs to Part 3's tenancy
// chapters.This is that chapter, and the stub goes.
The SRS asks for a specific shape, in four requirements that are easier to
read as one sentence. A person creates an organisation by authenticating
with a provider and supplying nothing else (FR-TEN-01). That organisation can
hold many applications, each with independent data, keys and quotas
(FR-TEN-03). Each application has exactly two environments,
development and production, with separate credentials, quotas and
datastores (FR-TEN-04). And creating an organisation automatically creates one
application and one development environment, with no further input
(FR-TEN-02).
Three levels is one more than most people would draw, so it is worth asking what each one is for. The organisation is where billing and human ownership live — it is the thing a company is. The application is where a product lives: a company shipping two apps wants two sets of channels, two sets of keys, and one invoice. And the environment is where the data lives, because shipping anything means having somewhere to break it first.
That last level is the one you have already built.
flowchart TB
subgraph above["ABOVE the tenant boundary — who owns the account"]
org["organisations"]
app["applications<br/>(FR-TEN-03: many per organisation)"]
hum["humans<br/>(a person, one provider account)"]
mem["memberships<br/>(owner · admin · member — FR-TEN-07)"]
end
subgraph below["BELOW it — every row carries environment_id"]
env["environments<br/>(exactly two: development · production — FR-TEN-04)"]
rest["users · channels · members · messages<br/>(all of Part 2)"]
end
org --> app
app --> env
env --> rest
org --- mem
mem --- hum
note["The line is the whole chapter. Above it, no row has an<br/>environment_id — a person is not a tenant. Below it,<br/>every row has one and the repository requires it<br/>(constitution I, FR-TEN-06)"]
below ~~~ noteSAD §6.1 defines environments and everything beneath it, in SQL, precisely.
It defines neither applications nor anything above it — environments
references applications(id) and the document simply stops. So the three
tables below are derived from the SRS, not quoted from the SAD, and they
say so in the schema where a reader will meet them. That is the same
mechanism 2.1 used for members: when the documents run out, the chapter
records a decision rather than presenting an invention as a specification.
@@ -24,13 +24,87 @@
// (ADR-06's chapter), emoji/media tables (their parts), messages
// partitioning (SAD growth note -> retention chapter).
-// DECISION (chapter 2.1): the SAD's environments table references
-// applications(id) but never defines the table. This stub satisfies the
-// foreign key; the real application lifecycle belongs to Part 3's tenancy
-// chapters.
+// The tenancy hierarchy (chapter 3.1). Everything from here to `members`
+// below sits ABOVE the environment boundary: these rows say who owns a
+// platform account, and they are the only tables in this file without an
+// environment_id. Everything below the boundary carries one and is scoped by
+// the repository (constitution I).
+//
+// DECISION (chapter 3.1): SAD §6.1 defines `environments` and everything under
+// it, but never defines the containers above — the gap 2.1 papered over with a
+// one-column `applications` stub. These three tables are derived from the SRS
+// (FR-TEN-01/02/03/04/07), not quoted from the SAD, and that is why they carry
+// this note.
+export const organisations = pgTable("organisations", {
+ id: uuid("id").primaryKey(),
+ name: text("name").notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+});
+
+// A person who signs in to Relay. NOT the `users` table below — see ADR-18.
+// Identity is the provider account, never the email: emails change hands, and
+// a provider may not release one at all (hence nullable).
+export const humans = pgTable(
+ "humans",
+ {
+ id: uuid("id").primaryKey(),
+ provider: text("provider").notNull(),
+ providerAccountId: text("provider_account_id").notNull(),
+ displayName: text("display_name"),
+ email: text("email"),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ },
+ (t) => [
+ // Signup idempotency, decided by the index rather than by a read-then-
+ // write check that loses to a concurrent second click (2.3's lesson).
+ unique("humans_provider_account_unique").on(
+ t.provider,
+ t.providerAccountId,
+ ),
+ check("humans_provider_check", sql`${t.provider} IN ('github','google')`),
+ ],
+);
+
+export const memberships = pgTable(
+ "memberships",
+ {
+ organisationId: uuid("organisation_id")
+ .notNull()
+ .references(() => organisations.id),
+ humanId: uuid("human_id")
+ .notNull()
+ .references(() => humans.id),
+ role: text("role").notNull(),
+ joinedAt: timestamp("joined_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
+ },
+ (t) => [
+ primaryKey({ columns: [t.organisationId, t.humanId] }),
+ check(
+ "memberships_role_check",
+ sql`${t.role} IN ('owner','admin','member')`,
+ ), // FR-TEN-07
+ ],
+);
+
+// Replaces chapter 2.1's stub: an application now knows who owns it
+// (FR-TEN-03). Deletion (FR-TEN-08) needs machinery this chapter does not
+// build, so no cascade is declared — a cascade would imply a deletion story
+// that does not exist yet.
export const applications = pgTable("applications", {
id: uuid("id").primaryKey(),
+ organisationId: uuid("organisation_id")
+ .notNull()
+ .references(() => organisations.id),
name: text("name").notNull(),
+ createdAt: timestamp("created_at", { withTimezone: true })
+ .notNull()
+ .defaultNow(),
});
export const environments = pgTable(
@@ -51,6 +125,10 @@
"environments_kind_check",
sql`${t.kind} IN ('development','production')`,
),
+ // FR-TEN-04 says exactly two environments per application. With the CHECK
+ // above, this unique index IS that rule: two legal kinds, one row each.
+ // No trigger, no counting query, nothing to lose a race to.
+ unique("environments_application_kind_unique").on(t.applicationId, t.kind),
],
);
Two details in that diff are doing more work than they look.
UNIQUE (application_id, kind) on environments is FR-TEN-04. The kind
CHECK from 2.1 already limits the column to two legal values; a unique index
across (application_id, kind) therefore permits at most one row of each,
which is at most two environments. No trigger, no SELECT count(*) that a
concurrent request can race past — the rule is a shape, and shapes do not
have to be remembered.
And humans has no environment_id. That absence is the chapter's most
important line, and it has an ADR of its own.
flowchart LR
subgraph rejected["The tempting shape — rejected (ADR-18)"]
one["one users table<br/>environment_id NULLABLE<br/>(null = a platform human)"]
cost["a nullable tenant column is the one shape<br/>Principle I forbids: the repository can no<br/>longer refuse an unscoped query BY CONSTRUCTION,<br/>and FR-TEN-05 becomes a code review"]
one --> cost
end
subgraph chosen["What the chapter builds"]
humans["humans — identified by<br/>(provider, provider_account_id)<br/>no environment_id, ever"]
users["users — identified by the<br/>customer's external_id<br/>environment_id NOT NULL"]
end
humans -.->|"never merged, no row crosses"| usersWith the schema written, drizzle-kit generates the SQL and — per ADR-16's workflow — we read it before it runs. The very first thing it wanted to do was impossible:
ALTER TABLE "applications" ADD COLUMN "organisation_id" uuid NOT NULL;That statement cannot succeed against any database that already has
application rows, and every reader's does: 2.1's createEnvironment has been
minting them since Part 2 began. Mine held 237 of them. A NOT NULL column
cannot arrive on a populated table without somebody saying what the existing
rows should contain — and the generator has no way to know.
So the statement was rewritten by hand, in the shape this problem always takes: add it nullable, backfill, then constrain.
-- Chapter 3.1 — the tenancy hierarchy (FR-TEN-01/02/03/04/07).
--
-- REVIEW DISPOSITION: drizzle-kit generated this file from schema.ts and it
-- was reviewed before being applied (the ADR-16 workflow). One statement was
-- rewritten by hand:
--
-- generated: ALTER TABLE "applications" ADD COLUMN "organisation_id" uuid NOT NULL;
--
-- That fails on any database that already has application rows — which every
-- reader's does, because chapter 2.1's createEnvironment has been minting them
-- since Part 2. A NOT NULL column cannot appear on a populated table without
-- saying what the existing rows should hold. Replaced by the three-step shape
-- below: add nullable, backfill, then constrain.
--
-- The backfill gives every orphaned application its own organisation and
-- reuses the application's uuid as the organisation's, so the lineage stays
-- readable afterwards: an organisation whose id matches an application is one
-- this migration invented.
CREATE TABLE "humans" (
"id" uuid PRIMARY KEY NOT NULL,
"provider" text NOT NULL,
"provider_account_id" text NOT NULL,
"display_name" text,
"email" text,
"created_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "humans_provider_account_unique" UNIQUE("provider","provider_account_id"),
CONSTRAINT "humans_provider_check" CHECK ("humans"."provider" IN ('github','google'))
);
--> statement-breakpoint
CREATE TABLE "memberships" (
"organisation_id" uuid NOT NULL,
"human_id" uuid NOT NULL,
"role" text NOT NULL,
"joined_at" timestamp with time zone DEFAULT now() NOT NULL,
CONSTRAINT "memberships_organisation_id_human_id_pk" PRIMARY KEY("organisation_id","human_id"),
CONSTRAINT "memberships_role_check" CHECK ("memberships"."role" IN ('owner','admin','member'))
);
--> statement-breakpoint
CREATE TABLE "organisations" (
"id" uuid PRIMARY KEY NOT NULL,
"name" text NOT NULL,
"created_at" timestamp with time zone DEFAULT now() NOT NULL
);
--> statement-breakpoint
-- 1. add it nullable, so the column can exist alongside the rows that predate it
ALTER TABLE "applications" ADD COLUMN "organisation_id" uuid;--> statement-breakpoint
-- 2. backfill: one organisation per orphan, id carried across for traceability
INSERT INTO "organisations" ("id", "name")
SELECT a."id", 'migrated: ' || a."name" FROM "applications" a WHERE a."organisation_id" IS NULL;--> statement-breakpoint
UPDATE "applications" SET "organisation_id" = "id" WHERE "organisation_id" IS NULL;--> statement-breakpoint
-- 3. and only now is the constraint true of every row
ALTER TABLE "applications" ALTER COLUMN "organisation_id" SET NOT NULL;--> statement-breakpoint
ALTER TABLE "applications" ADD COLUMN "created_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint
ALTER TABLE "memberships" ADD CONSTRAINT "memberships_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "memberships" ADD CONSTRAINT "memberships_human_id_humans_id_fk" FOREIGN KEY ("human_id") REFERENCES "public"."humans"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "applications" ADD CONSTRAINT "applications_organisation_id_organisations_id_fk" FOREIGN KEY ("organisation_id") REFERENCES "public"."organisations"("id") ON DELETE no action ON UPDATE no action;--> statement-breakpoint
ALTER TABLE "environments" ADD CONSTRAINT "environments_application_kind_unique" UNIQUE("application_id","kind");The backfill gives every orphaned application its own organisation and reuses the application's uuid as the organisation's, so afterwards the lineage is still legible: an organisation whose id matches an application is one this migration invented. After applying it, all 237 applications had an organisation and the column was NOT NULL, which is the only evidence that matters.
Provisioning belongs in the repository layer's admin surface — the one
function in that file which has always been allowed to run without a tenant
scope, because it is the operation that creates one. It already existed, and
2.1 explained it to you: createEnvironment mints an application and an
environment. Now it mints an organisation too, and gains a sibling that does
the whole job for a signup.
@@ -3,15 +3,25 @@
import { and, asc, desc, eq, gt, lt, sql, type SQL } from "drizzle-orm";
import type { Db } from "./client";
-import { channels, members, messages, users } from "./schema";
+import {
+ applications,
+ channels,
+ environments,
+ humans,
+ members,
+ memberships,
+ messages,
+ organisations,
+ users,
+} from "./schema";
// The repository layer — the ONE place data access lives (ADR-04's single
// writer, constitution I). Two surfaces with a bright line between them:
//
-// createEnvironment — the ADMIN surface. It creates tenants, so it is the
-// only operation here that is not tenant-scoped. It also inserts a stub
-// application row to satisfy environments' NOT NULL foreign key (recorded
-// decision: the real application lifecycle belongs to Part 3).
+// createEnvironment / provisionOrganisation — the ADMIN surface. These
+// create tenants, so they are the only operations here that are not
+// tenant-scoped. As of chapter 3.1 they build the whole container stack:
+// organisation -> application -> environment, with no stubs left.
//
// Repository — everything else. The constructor REQUIRES an
// environment_id; every query is scoped by it HERE, in one home — never
@@ -34,18 +44,199 @@
db: Db,
{ name, kind = "development" }: { name: string; kind?: Environment["kind"] },
): Promise<Environment> {
+ const organisationId = randomUUID();
const applicationId = randomUUID();
const environmentId = randomUUID();
// The admin surface writes through the same Db handle but carries no
// tenant scope — it is the operation that MINTS the scope.
+ //
+ // Chapter 3.1 added the organisation above the application, and this
+ // function had to grow with it the same day: `applications.organisation_id`
+ // is NOT NULL, and this is the function every Part 2 suite, the e2e harness
+ // and three walk scripts use to make a tenant. A schema change whose only
+ // writer is left behind is not a migration, it is an outage.
await db.execute(
- sql`INSERT INTO applications (id, name) VALUES (${applicationId}, ${name})`,
+ sql`INSERT INTO organisations (id, name) VALUES (${organisationId}, ${name})`,
+ );
+ await db.execute(
+ sql`INSERT INTO applications (id, organisation_id, name)
+ VALUES (${applicationId}, ${organisationId}, ${name})`,
);
await db.execute(
sql`INSERT INTO environments (id, application_id, kind, signing_secret)
VALUES (${environmentId}, ${applicationId}, ${kind}, ${randomUUID()})`,
);
return { id: environmentId, kind };
+}
+
+/** What a signup produced — or found. `created` answers "was an organisation
+ * created on this call?", NOT "was the identity new": a known human who owned
+ * nothing gets `created: true`, because one really was created for them. */
+export interface Provisioned {
+ organisation: { id: string; name: string };
+ application: { id: string; name: string };
+ environment: { id: string; kind: Environment["kind"] };
+ human: { id: string; provider: string; provider_account_id: string };
+ created: boolean;
+}
+
+/** Signup (chapter 3.1, FR-TEN-01/02). The admin surface's second entrance:
+ * it mints a tenant, so like createEnvironment it carries no tenant scope —
+ * it is the operation that creates one.
+ *
+ * ATOMIC: one transaction. A half-built tenant — an application with no
+ * environment — is unusable and invisible to the person who just signed up,
+ * so there is no state between "nothing" and "everything".
+ *
+ * IDEMPOTENT ON THE OWNED ORGANISATION, which is the only rule that is defined
+ * for every reachable case:
+ *
+ * unknown identity -> five rows; created: true
+ * known, owns an org -> that org; nothing written; created: false
+ * known, owns none -> four rows (no new human); created: true
+ *
+ * The third case cannot happen until invitations exist, and the rule is stated
+ * now because "return the existing organisation" is undefined for a human who
+ * only belongs to someone ELSE's — a state FR-TEN-07 makes legal the moment
+ * membership management arrives. Signing up gives you your own workspace; it
+ * never hands you somebody else's.
+ */
+export async function provisionOrganisation(
+ db: Db,
+ {
+ provider,
+ providerAccountId,
+ displayName,
+ email,
+ organisationName,
+ }: {
+ provider: string;
+ providerAccountId: string;
+ displayName?: string | null;
+ email?: string | null;
+ organisationName: string;
+ },
+): Promise<Provisioned> {
+ return db.transaction(async (tx) => {
+ // The identity, or the row that already speaks for it. The unique index on
+ // (provider, provider_account_id) is what decides under concurrency — a
+ // read-then-write check here would let two simultaneous first clicks both
+ // believe they were first (2.3's lesson, on a different table).
+ const [existingHuman] = await tx
+ .select({
+ id: humans.id,
+ provider: humans.provider,
+ provider_account_id: humans.providerAccountId,
+ })
+ .from(humans)
+ .where(
+ and(
+ eq(humans.provider, provider),
+ eq(humans.providerAccountId, providerAccountId),
+ ),
+ );
+
+ if (existingHuman) {
+ // Does this identity already OWN an organisation? Membership is not
+ // ownership: being a member of someone else's does not count.
+ const [owned] = await tx
+ .select({
+ id: organisations.id,
+ name: organisations.name,
+ })
+ .from(memberships)
+ .innerJoin(
+ organisations,
+ eq(organisations.id, memberships.organisationId),
+ )
+ .where(
+ and(
+ eq(memberships.humanId, existingHuman.id),
+ eq(memberships.role, "owner"),
+ ),
+ )
+ .orderBy(asc(memberships.joinedAt))
+ .limit(1);
+
+ if (owned) {
+ const [application] = await tx
+ .select({ id: applications.id, name: applications.name })
+ .from(applications)
+ .where(eq(applications.organisationId, owned.id))
+ .orderBy(asc(applications.createdAt))
+ .limit(1);
+ const [environment] = await tx
+ .select({
+ id: environments.id,
+ kind: sql<Environment["kind"]>`${environments.kind}`,
+ })
+ .from(environments)
+ .where(eq(environments.applicationId, application!.id))
+ .orderBy(asc(environments.kind))
+ .limit(1);
+ return {
+ organisation: owned,
+ application: application!,
+ environment: environment!,
+ human: existingHuman,
+ created: false,
+ };
+ }
+ }
+
+ const human =
+ existingHuman ??
+ (
+ await tx
+ .insert(humans)
+ .values({
+ id: randomUUID(),
+ provider,
+ providerAccountId,
+ displayName: displayName ?? null,
+ email: email ?? null,
+ })
+ .returning({
+ id: humans.id,
+ provider: humans.provider,
+ provider_account_id: humans.providerAccountId,
+ })
+ )[0]!;
+
+ const organisationId = randomUUID();
+ const applicationId = randomUUID();
+ const environmentId = randomUUID();
+
+ await tx
+ .insert(organisations)
+ .values({ id: organisationId, name: organisationName });
+ await tx.insert(applications).values({
+ id: applicationId,
+ organisationId,
+ name: organisationName,
+ });
+ await tx.insert(environments).values({
+ id: environmentId,
+ applicationId,
+ // FR-TEN-02: development, and only development. The production
+ // environment is possible (FR-TEN-04) but not automatic.
+ kind: "development",
+ signingSecret: randomUUID(),
+ });
+ await tx.insert(memberships).values({
+ organisationId,
+ humanId: human.id,
+ role: "owner", // FR-TEN-07's vocabulary; management of it is later
+ });
+
+ return {
+ organisation: { id: organisationId, name: organisationName },
+ application: { id: applicationId, name: organisationName },
+ environment: { id: environmentId, kind: "development" as const },
+ human,
+ created: true,
+ };
+ });
}
export interface UserRow {Notice what did not happen: no second door onto tenant creation. Principle I holds that isolation lives in one place, and "the thing that creates tenants" is exactly the kind of operation that should not be reachable from two directions.
sequenceDiagram
participant B as Browser (a human)
participant A as API service
participant P as Provider (GitHub)
participant DB as PostgreSQL
B->>A: GET /auth/github/start
A->>A: mint state · set it in an HttpOnly cookie
A-->>B: 302 to the provider, carrying the same state
B->>P: authorize (the consent screen)
P-->>B: 302 back with code + state
B->>A: GET /auth/github/callback?code&state
A->>A: state from the query MUST equal the cookie —<br/>checked BEFORE any provider call
A->>P: POST token endpoint (code -> access token)
A->>P: GET user endpoint (who is this?)
A->>DB: one transaction: human · organisation ·<br/>application · environment(development) · owner
A-->>B: 200 {organisation, application, environment, created}
Note over A,DB: five rows or none (FR-TEN-02) —<br/>and a second authentication creates nothingThe authorization-code flow is three steps, and it is written by hand here for
the same reason chapter 2.5 wired the WebSocket upgrade by hand: a strategy
library would hide exactly the part you came to learn, and would add
dependencies to a service whose constitution says boring by design. Node's
fetch does the two HTTP calls; zod validates both answers, because an
unvalidated payload does not fail where it arrives — and here the payload
decides who somebody is.
import { z } from "zod";
// What a provider is allowed to say back (chapter 3.1). Two responses cross
// this boundary — the token exchange and the profile fetch — and both are
// parsed, not assumed.
//
// The habit is 1.3's and it has paid every time since: an unvalidated payload
// does not fail where it arrives, it fails three layers away as an `undefined`
// somebody has to trace back. Here the stakes are higher than usual, because
// the thing being parsed decides who a person is.
/** GitHub answers a failed exchange with HTTP 200 and an error body. Parsing
* only the success shape would read `access_token: undefined` as a token. */
export const providerErrorSchema = z.object({
error: z.string().min(1),
error_description: z.string().optional(),
});
export const tokenResponseSchema = z.object({
access_token: z.string().min(1),
token_type: z.string().optional(),
scope: z.string().optional(),
});
/** The profile, narrowed to what signup is allowed to want. FR-TEN-01 says
* "providing no information beyond that granted by the provider" — so this
* takes an id, a name if there is one, an email if the provider released one,
* and nothing else. `id` arrives as a number from GitHub and a string from
* Google; both become a string, because it is an opaque key here. */
export const profileSchema = z.object({
id: z.union([z.string().min(1), z.number().int()]).transform(String),
login: z.string().min(1).optional(),
name: z.string().min(1).nullish(),
email: z.string().email().nullish(),
});
export type TokenResponse = z.infer<typeof tokenResponseSchema>;
export type Profile = z.infer<typeof profileSchema>;Then the part that is easy to get subtly wrong. state exists to prove the
callback belongs to the browser that started the flow. It is tempting to make
it stateless — sign a random value, verify the signature on return, no cookie
needed. That version verifies beautifully and stops nothing: an attacker
starts the flow themselves, takes the perfectly valid state the server minted,
and feeds a victim the callback URL. The signature checks out, because the
server really did mint it. What the victim's browser does not have is the
cookie.
import { randomBytes, timingSafeEqual } from "node:crypto";
// The CSRF binding for the OAuth flow (chapter 3.1).
//
// `state` exists to prove that the callback belongs to the browser that
// started the flow. A signed-but-stateless state proves the SERVER minted it
// and nothing more — an attacker can start a flow, take the state, and feed
// the victim a callback URL that still verifies. Binding needs something only
// that browser holds, which is a cookie. Five lines of header parsing, and no
// dependency for it.
export const STATE_COOKIE = "relay_oauth_state";
const MAX_AGE_SECONDS = 600;
export function mintState(): string {
return randomBytes(16).toString("hex");
}
/** `Path=/auth` keeps it off every other request; `SameSite=Lax` still allows
* the provider's top-level redirect back; `HttpOnly` keeps script away from
* it. `Secure` is omitted only when the base URL is plain http, which is the
* local case — a reader on localhost would otherwise never receive it. */
export function stateCookie(value: string, secure: boolean): string {
const parts = [
`${STATE_COOKIE}=${value}`,
"HttpOnly",
"SameSite=Lax",
"Path=/auth",
`Max-Age=${value === "" ? 0 : MAX_AGE_SECONDS}`,
];
if (secure) parts.push("Secure");
return parts.join("; ");
}
export function clearStateCookie(secure: boolean): string {
return stateCookie("", secure);
}
/** Read one cookie out of a raw header. No parser dependency: the format is
* `a=1; b=2`, and anything that does not match that is not a cookie we set. */
export function readCookie(
header: string | undefined,
name: string,
): string | undefined {
if (!header) return undefined;
for (const pair of header.split(";")) {
const eq = pair.indexOf("=");
if (eq < 0) continue;
if (pair.slice(0, eq).trim() === name) return pair.slice(eq + 1).trim();
}
return undefined;
}
/** Constant-time comparison. The state is not a secret in the way a password
* is, but it is compared on every callback and the cost of doing it properly
* is one function call. */
export function statesMatch(
fromQuery: string | undefined,
fromCookie: string | undefined,
): boolean {
if (!fromQuery || !fromCookie) return false;
const a = Buffer.from(fromQuery);
const b = Buffer.from(fromCookie);
if (a.length !== b.length) return false;
return timingSafeEqual(a, b);
}SameSite=Lax rather than Strict, because the provider's redirect back is a
cross-site navigation and Strict would withhold the cookie exactly when it
is needed. Secure everywhere except a plain-http base URL, because a reader
on localhost would otherwise never receive it and would have nothing to point
at when the flow failed.
import {
profileSchema,
providerErrorSchema,
tokenResponseSchema,
type Profile,
} from "./oauth.schema";
// The authorization-code flow, by hand (chapter 3.1).
//
// No Passport, no strategy plugin. The flow is three steps and this chapter
// exists to teach them; a library would hide exactly the part the reader came
// for, and would add two dependencies to a service whose constitution says
// boring by design. Chapter 2.5 wired the WebSocket upgrade by hand for the
// same reason.
//
// Endpoints are configuration, not constants. That is what lets the test lane
// point at a local stand-in and stay offline and deterministic (2.1's
// two-lane gate, 2.8's no-flakes rule) — and it is also what a real
// deployment needs for GitHub Enterprise.
export class ProviderError extends Error {
constructor(
message: string,
readonly kind: "denied" | "unusable",
) {
super(message);
this.name = "ProviderError";
}
}
export interface ProviderConfig {
clientId: string;
clientSecret: string;
authorizeUrl: string;
tokenUrl: string;
userUrl: string;
scope: string;
}
const GITHUB_DEFAULTS = {
authorizeUrl: "https://github.com/login/oauth/authorize",
tokenUrl: "https://github.com/login/oauth/access_token",
userUrl: "https://api.github.com/user",
scope: "read:user user:email",
};
/** Configuration for a provider, or undefined when it is not set up. An
* unconfigured provider is a 404 rather than a redirect into a broken flow. */
export function providerConfig(name: string): ProviderConfig | undefined {
if (name !== "github") return undefined;
const clientId = process.env.RELAY_OAUTH_GITHUB_CLIENT_ID;
const clientSecret = process.env.RELAY_OAUTH_GITHUB_CLIENT_SECRET;
if (!clientId || !clientSecret) return undefined;
return {
clientId,
clientSecret,
authorizeUrl:
process.env.RELAY_OAUTH_GITHUB_AUTHORIZE_URL ??
GITHUB_DEFAULTS.authorizeUrl,
tokenUrl:
process.env.RELAY_OAUTH_GITHUB_TOKEN_URL ?? GITHUB_DEFAULTS.tokenUrl,
userUrl: process.env.RELAY_OAUTH_GITHUB_USER_URL ?? GITHUB_DEFAULTS.userUrl,
scope: GITHUB_DEFAULTS.scope,
};
}
export function redirectUri(provider: string): string {
const base = process.env.RELAY_OAUTH_REDIRECT_BASE ?? "http://localhost:4000";
return `${base}/auth/${provider}/callback`;
}
/** Step one: where to send the browser. */
export function authorizeUrl(
provider: string,
config: ProviderConfig,
state: string,
): string {
const url = new URL(config.authorizeUrl);
url.searchParams.set("client_id", config.clientId);
url.searchParams.set("redirect_uri", redirectUri(provider));
url.searchParams.set("scope", config.scope);
url.searchParams.set("state", state);
return url.toString();
}
/** Steps two and three: trade the code for a token, then ask who it belongs
* to. Both responses are parsed; neither is trusted for its shape. */
export async function exchangeCodeForProfile(
provider: string,
config: ProviderConfig,
code: string,
): Promise<Profile> {
const tokenRes = await fetch(config.tokenUrl, {
method: "POST",
headers: { accept: "application/json", "content-type": "application/json" },
body: JSON.stringify({
client_id: config.clientId,
client_secret: config.clientSecret,
code,
redirect_uri: redirectUri(provider),
}),
});
if (!tokenRes.ok) {
throw new ProviderError(
`token endpoint answered ${tokenRes.status}`,
"unusable",
);
}
const tokenBody: unknown = await tokenRes.json();
// The provider's own refusal is a 400 to our caller, not a 502: nothing is
// broken, the person declined or the code expired.
const denied = providerErrorSchema.safeParse(tokenBody);
if (denied.success) {
throw new ProviderError(denied.data.error, "denied");
}
const token = tokenResponseSchema.safeParse(tokenBody);
if (!token.success) {
throw new ProviderError(
"token response did not match the contract",
"unusable",
);
}
const userRes = await fetch(config.userUrl, {
headers: {
accept: "application/json",
authorization: `Bearer ${token.data.access_token}`,
// GitHub rejects requests without one.
"user-agent": "relay",
},
});
if (!userRes.ok) {
throw new ProviderError(
`user endpoint answered ${userRes.status}`,
"unusable",
);
}
const profile = profileSchema.safeParse(await userRes.json());
if (!profile.success) {
throw new ProviderError("profile did not match the contract", "unusable");
}
return profile.data;
}One provider quirk earns a line of code and deserves a sentence: GitHub
answers a bad code with HTTP 200 and an error object. Code that parsed
only the success shape would read access_token: undefined as a token and
carry on. That is why the error shape is parsed first, and why the two
failures are told apart on the way out — a person declining is a 400, and a
provider answering something the contract does not allow is a 502, because the
caller did nothing wrong.
import {
BadGatewayException,
BadRequestException,
Controller,
Get,
Headers,
NotFoundException,
Param,
Query,
Res,
} from "@nestjs/common";
import { createDb, createPool } from "../db/client";
import { provisionOrganisation } from "../db/repository";
import {
authorizeUrl,
exchangeCodeForProfile,
providerConfig,
ProviderError,
} from "./oauth.provider";
import {
STATE_COOKIE,
clearStateCookie,
mintState,
readCookie,
stateCookie,
statesMatch,
} from "./state-cookie";
// Signup (chapter 3.1, FR-TEN-01/02). Two routes, both necessarily
// unauthenticated: they exist to establish who somebody is, and no tenant
// exists yet for a header to name. Note what is absent — the
// EnvironmentContextGuard every other controller carries (2.2). The guard is
// applied per controller, so a pre-tenant route simply does not use it, and
// the seam it guards is untouched by this chapter (3.2 retires it).
//
// 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.
/** The two things this controller needs from the response object.
*
* `@Res()` puts a handler in Nest's library-specific mode, which normally
* means importing express's `Response` type — and express 5 ships no types, so
* that would mean adding `@types/express` for two method signatures. Declaring
* the shape instead keeps the api's dependency list exactly where 1.4 left it,
* and states plainly what these routes actually touch. */
interface HttpResponse {
setHeader(name: string, value: string): void;
redirect(status: number, url: string): void;
}
const db = createDb(createPool());
@Controller("auth")
export class SignupController {
@Get(":provider/start")
start(@Param("provider") provider: string, @Res() res: HttpResponse): void {
const config = providerConfig(provider);
// An unconfigured or unknown provider is a 404 — never a redirect built
// from unvalidated input.
if (!config) throw new NotFoundException("unknown provider");
const state = mintState();
const secure = !(
process.env.RELAY_OAUTH_REDIRECT_BASE ?? "http://localhost:4000"
).startsWith("http://");
res.setHeader("set-cookie", stateCookie(state, secure));
res.redirect(302, authorizeUrl(provider, config, state));
}
@Get(":provider/callback")
async callback(
@Param("provider") provider: string,
@Query("code") code: string | undefined,
@Query("state") state: string | undefined,
@Query("error") providerError: string | undefined,
@Headers("cookie") cookieHeader: string | undefined,
@Res({ passthrough: true }) res: HttpResponse,
) {
const config = providerConfig(provider);
if (!config) throw new NotFoundException("unknown provider");
const secure = !(
process.env.RELAY_OAUTH_REDIRECT_BASE ?? "http://localhost:4000"
).startsWith("http://");
// THE BINDING IS CHECKED FIRST, before anything else is even read. An
// unverified callback must never make this server talk to the provider on
// an attacker's behalf — and a state that merely validates as
// well-formed proves nothing about WHICH browser began the flow.
const expected = readCookie(cookieHeader, STATE_COOKIE);
if (!statesMatch(state, expected)) {
res.setHeader("set-cookie", clearStateCookie(secure));
throw new BadRequestException("state does not match");
}
res.setHeader("set-cookie", clearStateCookie(secure));
if (providerError) throw new BadRequestException(providerError);
if (!code) throw new BadRequestException("missing code");
let profile;
try {
profile = await exchangeCodeForProfile(provider, config, code);
} catch (error) {
if (error instanceof ProviderError) {
// The person declining is a 400 — nothing is broken. A provider that
// answers something the contract does not allow is a 502: the fault
// is upstream, and saying "bad request" would blame the caller.
throw error.kind === "denied"
? new BadRequestException(error.message)
: new BadGatewayException(error.message);
}
throw error;
}
const result = await provisionOrganisation(db, {
provider,
providerAccountId: profile.id,
displayName: profile.name ?? profile.login ?? null,
email: profile.email ?? null,
// No form to fill in (FR-TEN-01): the name comes from what the provider
// granted, and nothing else is asked for.
organisationName: `${profile.login ?? profile.name ?? "relay"}'s org`,
});
return {
organisation: result.organisation,
application: result.application,
environment: result.environment,
created: result.created,
};
}
}import { Module } from "@nestjs/common";
import { SignupController } from "./signup.controller";
// Signup's home (chapter 3.1). Deliberately thin, and deliberately NOT
// importing MessagesModule: nothing here is tenant-scoped, so it needs none of
// the request-scoped machinery 2.2 built. The provisioning it calls lives in
// the repository's admin surface, which is a module-free function for exactly
// this reason — it runs before any tenant exists to scope to.
@Module({
controllers: [SignupController],
})
export class TenancyModule {}@@ -8,6 +8,7 @@
import { HealthController } from "./health.controller";
import { InternalModule } from "./internal/internal.module";
import { MessagesModule } from "./messages/messages.module";
+import { TenancyModule } from "./tenancy/tenancy.module";
import { LOGGER, apiLogger } from "./logger";
import { ProtocolErrorFilter } from "./protocol-error.filter";
import { RequestContextMiddleware } from "./request-context.middleware";
@@ -17,7 +18,7 @@
// 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],
+ imports: [MessagesModule, InternalModule, TenancyModule],
controllers: [HealthController],
providers: [
{ provide: LOGGER, useFactory: apiLogger },Look at what this controller does not carry: the EnvironmentContextGuard
every other controller has worn since 2.2. These two routes exist to establish
who somebody is, and no tenant exists yet for a header to name. The guard is
applied per controller, so a pre-tenant route simply does not use one — and
the seam that guard protects is untouched by this chapter.
The endpoints are configuration, not constants, which is what lets the test
lane point at a local stand-in and a real deployment point at GitHub
Enterprise. To run the real flow, register an OAuth application with your
provider, set the callback to http://localhost:4000/auth/github/callback,
and export:
RELAY_OAUTH_GITHUB_CLIENT_ID=… # from the provider's app page
RELAY_OAUTH_GITHUB_CLIENT_SECRET=… # runtime only — never a build argument
RELAY_OAUTH_REDIRECT_BASE=http://localhost:4000
# optional, for GitHub Enterprise or a local stand-in:
RELAY_OAUTH_GITHUB_AUTHORIZE_URL=…
RELAY_OAUTH_GITHUB_TOKEN_URL=…
RELAY_OAUTH_GITHUB_USER_URL=…With no client id and secret, /auth/github/start answers 404 rather than
redirecting into a flow that cannot complete. And the same rule that has
applied to DATABASE_URL since 1.2 applies to that client secret: runtime
configuration only — never a build argument, never baked into an image layer,
never in a client bundle.
Three things a real product would have, and the chapters that own them:
No session. After the callback you get a JSON body describing what was created, and then the server forgets you. A session is a credential; credentials are 3.2's subject ("two credentials, one mistake"), and the dashboard that would consume one is Part 5. Inventing a third credential here for 3.2 to immediately rework is precisely what Principle VII exists to stop.
No role management. Signup makes the authenticating human the owner, and
the role vocabulary (owner, admin, member) exists in the schema because
FR-TEN-07 names it. Invitations, promotions and removals are not built.
The dev-mode seams stay. Every Part 2 surface still names its tenant with
the x-relay-environment header, and the gateway still verifies tokens with a
dev secret. Real tenancy above them does not make either more trustworthy, and
3.2 is where both retire. Nothing about this chapter is the reason to trust
that header.
Seven invariants, split between the lanes by what they need. The binding and the provider contract are pure functions of their input, so they run in the Docker-free lane a reader uses on every save:
import { describe, expect, it } from "vitest";
import {
profileSchema,
providerErrorSchema,
tokenResponseSchema,
} from "./oauth.schema";
import {
STATE_COOKIE,
clearStateCookie,
mintState,
readCookie,
stateCookie,
statesMatch,
} from "./state-cookie";
// Invariants 5 and 6 (chapter 3.1), in the Docker-free lane: the CSRF binding
// and the provider contract. Neither needs a database, and neither should wait
// for one — this is the lane a reader runs on every save (2.1's gate).
describe("the state binding (invariant 5)", () => {
it("mints a value with enough entropy to be unguessable", () => {
const a = mintState();
const b = mintState();
expect(a).toHaveLength(32); // 128 bits, hex
expect(a).not.toBe(b);
});
it("refuses a callback whose state does not match the cookie", () => {
const minted = mintState();
// The attack this closes: an attacker starts the flow, takes the state
// value, and feeds a victim the callback URL. The value is real, so a
// server that only checked "is this well formed?" would accept it. What
// the victim's browser does NOT have is the cookie.
expect(statesMatch(minted, undefined)).toBe(false);
expect(statesMatch(minted, mintState())).toBe(false);
expect(statesMatch(undefined, minted)).toBe(false);
expect(statesMatch("", "")).toBe(false);
expect(statesMatch(minted, minted)).toBe(true);
});
it("does not leak length through an early return", () => {
// A shorter or longer candidate is refused, not compared byte-for-byte.
expect(statesMatch("abc", "abcd")).toBe(false);
});
it("sets the cookie so a script cannot read it and the provider can return through it", () => {
const header = stateCookie("abc123", true);
expect(header).toContain(`${STATE_COOKIE}=abc123`);
expect(header).toContain("HttpOnly");
// Lax, not Strict: the provider's redirect back IS a cross-site
// navigation, and Strict would withhold the cookie exactly when it is
// needed.
expect(header).toContain("SameSite=Lax");
expect(header).toContain("Path=/auth");
expect(header).toContain("Secure");
});
it("omits Secure only when the base URL is plain http", () => {
// A reader on localhost would never receive a Secure cookie over http,
// and the flow would fail with nothing to point at.
expect(stateCookie("abc", false)).not.toContain("Secure");
});
it("expires the cookie when clearing it", () => {
expect(clearStateCookie(false)).toContain("Max-Age=0");
});
it("reads one cookie out of a header full of them", () => {
const header = `other=1; ${STATE_COOKIE}=wanted; third=3`;
expect(readCookie(header, STATE_COOKIE)).toBe("wanted");
expect(readCookie(header, "missing")).toBeUndefined();
expect(readCookie(undefined, STATE_COOKIE)).toBeUndefined();
// A name that is a suffix of another must not match it.
expect(readCookie(`x${STATE_COOKIE}=no`, STATE_COOKIE)).toBeUndefined();
});
});
describe("the provider contract (invariant 6)", () => {
it("recognises a provider's error body, which arrives with HTTP 200", () => {
// GitHub answers a bad code with 200 and an error object. Parsing only the
// success shape would read `access_token: undefined` as a token.
const parsed = providerErrorSchema.safeParse({
error: "bad_verification_code",
error_description: "The code passed is incorrect or expired.",
});
expect(parsed.success).toBe(true);
});
it("rejects a token response with no token", () => {
expect(
tokenResponseSchema.safeParse({ token_type: "bearer" }).success,
).toBe(false);
expect(tokenResponseSchema.safeParse({ access_token: "" }).success).toBe(
false,
);
expect(
tokenResponseSchema.safeParse({ access_token: "gho_x" }).success,
).toBe(true);
});
it("accepts an id whether the provider sends a number or a string", () => {
// GitHub sends a number, Google a string. It is an opaque key here, so
// both become a string rather than the api caring which provider it is.
expect(profileSchema.parse({ id: 4711, login: "tuan" }).id).toBe("4711");
expect(profileSchema.parse({ id: "4711", login: "tuan" }).id).toBe("4711");
});
it("tolerates a withheld email but not a missing id", () => {
// FR-TEN-01: nothing beyond what the provider granted. A provider that
// releases no email is normal, and identity does not depend on it.
expect(
profileSchema.safeParse({ id: 1, login: "tuan", email: null }).success,
).toBe(true);
expect(profileSchema.safeParse({ login: "tuan" }).success).toBe(false);
});
it("rejects a profile whose email is not an email", () => {
expect(
profileSchema.safeParse({ id: 1, email: "not-an-email" }).success,
).toBe(false);
});
});The rest need the real database, and one of them needs a provider — so the suite starts one, on a real port, answering exactly what the test says. The flow is not stubbed; only the party at the other end is.
import "reflect-metadata";
import { createServer } from "node:http";
import type { AddressInfo } from "node:net";
import { Test } from "@nestjs/testing";
import type { INestApplication } from "@nestjs/common";
import { afterAll, beforeAll, describe, expect, it } from "vitest";
import { AppModule } from "../app.module";
import { createDb, createPool } from "../db/client";
import { provisionOrganisation, Repository } from "../db/repository";
import { STATE_COOKIE } from "./state-cookie";
// Signup against the real database (chapter 3.1) — invariants 1–4 and 7.
//
// The OAuth provider is a stand-in served from this file: a token endpoint and
// a user endpoint, on a real port. Nothing about the flow is stubbed except who
// answers it, which is what keeps this lane offline and deterministic while
// still exercising the same code path a reader points at GitHub (research R8).
/** A provider that answers exactly what the test tells it to. */
function standInProvider(profile: unknown, tokenBody?: unknown) {
const server = createServer((req, res) => {
res.setHeader("content-type", "application/json");
if (req.url?.startsWith("/token")) {
res.end(JSON.stringify(tokenBody ?? { access_token: "gho_test" }));
return;
}
res.end(JSON.stringify(profile));
});
return new Promise<{ port: number; close: () => Promise<void> }>(
(resolve) => {
server.listen(0, () => {
const { port } = server.address() as AddressInfo;
resolve({
port,
close: () => new Promise<void>((done) => server.close(() => done())),
});
});
},
);
}
describe("signup", () => {
let app: INestApplication;
let url: string;
let db: ReturnType<typeof createDb>;
let provider: Awaited<ReturnType<typeof standInProvider>>;
beforeAll(async () => {
db = createDb(createPool());
provider = await standInProvider({
id: 90210,
login: "tuan",
name: "Tuan",
});
// The provider's endpoints are configuration, so pointing them at the
// stand-in is all the wiring this needs.
process.env.RELAY_OAUTH_GITHUB_CLIENT_ID = "test-client";
process.env.RELAY_OAUTH_GITHUB_CLIENT_SECRET = "test-secret";
process.env.RELAY_OAUTH_GITHUB_TOKEN_URL = `http://127.0.0.1:${provider.port}/token`;
process.env.RELAY_OAUTH_GITHUB_USER_URL = `http://127.0.0.1:${provider.port}/user`;
process.env.RELAY_OAUTH_GITHUB_AUTHORIZE_URL = `http://127.0.0.1:${provider.port}/authorize`;
app = (
await Test.createTestingModule({ imports: [AppModule] }).compile()
).createNestApplication({ logger: false });
await app.listen(0);
url = await app.getUrl();
}, 60_000);
afterAll(async () => {
await app.close();
await provider.close();
});
/** Walk the flow the way a browser would: start, keep the cookie, come back
* with the state the server minted. */
async function signUp(code = "code-1") {
const start = await fetch(`${url}/auth/github/start`, {
redirect: "manual",
});
const setCookie = start.headers.get("set-cookie") ?? "";
const state = new URL(start.headers.get("location")!).searchParams.get(
"state",
)!;
const cookie = setCookie.split(";")[0]!;
const res = await fetch(
`${url}/auth/github/callback?code=${code}&state=${state}`,
{ headers: { cookie } },
);
return {
status: res.status,
body: (await res.json()) as {
organisation: { id: string; name: string };
application: { id: string; name: string };
environment: { id: string; kind: string };
created: boolean;
},
};
}
/** Run a block against a provider that reports a fresh identity. Signup is
* idempotent PER IDENTITY, so a test that wants to observe a first signup
* needs an identity no earlier test has used — otherwise it is asserting
* against the suite's history rather than the behaviour. */
async function withFreshIdentity<T>(
accountId: string,
fn: () => Promise<T>,
): Promise<T> {
const fresh = await standInProvider({
id: accountId,
login: `u-${accountId}`,
});
const tokenUrl = process.env.RELAY_OAUTH_GITHUB_TOKEN_URL;
const userUrl = process.env.RELAY_OAUTH_GITHUB_USER_URL;
process.env.RELAY_OAUTH_GITHUB_TOKEN_URL = `http://127.0.0.1:${fresh.port}/token`;
process.env.RELAY_OAUTH_GITHUB_USER_URL = `http://127.0.0.1:${fresh.port}/user`;
try {
return await fn();
} finally {
process.env.RELAY_OAUTH_GITHUB_TOKEN_URL = tokenUrl;
process.env.RELAY_OAUTH_GITHUB_USER_URL = userUrl;
await fresh.close();
}
}
it("provisions the whole trio from one authentication (FR-TEN-01, FR-TEN-02)", async () => {
const { status, body } = await withFreshIdentity(`trio-${Date.now()}`, () =>
signUp(),
);
expect(status).toBe(200);
expect(body.created).toBe(true);
expect(body.organisation.id).toBeTruthy();
expect(body.application.id).toBeTruthy();
// FR-TEN-02 names the kind: development, and only development.
expect(body.environment.kind).toBe("development");
// Five rows, and the fifth is the one that says who owns it.
const rows = await db.execute(
`SELECT
(SELECT count(*) FROM organisations WHERE id = '${body.organisation.id}') AS orgs,
(SELECT count(*) FROM applications WHERE organisation_id = '${body.organisation.id}') AS apps,
(SELECT count(*) FROM environments WHERE application_id = '${body.application.id}') AS envs,
(SELECT count(*) FROM memberships WHERE organisation_id = '${body.organisation.id}' AND role = 'owner') AS owners`,
);
const counts = rows.rows[0] as Record<string, string>;
expect(Number(counts.orgs)).toBe(1);
expect(Number(counts.apps)).toBe(1);
expect(Number(counts.envs)).toBe(1);
expect(Number(counts.owners)).toBe(1);
});
it("writes the full set or nothing when provisioning fails (invariant 1)", async () => {
const before = await db.execute(
`SELECT count(*)::int AS n FROM organisations`,
);
// Force a failure inside the transaction, after the organisation insert:
// an organisation name that is fine and a provider value the CHECK
// constraint refuses.
await expect(
provisionOrganisation(db, {
provider: "not-a-provider",
providerAccountId: "x",
organisationName: "doomed org",
}),
).rejects.toThrow();
const after = await db.execute(
`SELECT count(*)::int AS n FROM organisations`,
);
// Nothing survived — no half-built tenant, which is the whole point of the
// single transaction.
expect((after.rows[0] as { n: number }).n).toBe(
(before.rows[0] as { n: number }).n,
);
const orphan = await db.execute(
`SELECT count(*)::int AS n FROM organisations WHERE name = 'doomed org'`,
);
expect((orphan.rows[0] as { n: number }).n).toBe(0);
});
it("recognises a returning owner instead of creating a second organisation (invariant 2)", async () => {
const accountId = `returning-${Date.now()}`;
const { first, second } = await withFreshIdentity(accountId, async () => ({
first: await signUp("code-a"),
second: await signUp("code-b"),
}));
expect(first.body.created).toBe(true);
expect(second.body.created).toBe(false);
expect(second.body.organisation.id).toBe(first.body.organisation.id);
expect(second.body.environment.id).toBe(first.body.environment.id);
// One human, one owned organisation — the unique index decided, not a
// read-then-write check.
const owned = await db.execute(
`SELECT count(*)::int AS n FROM memberships m
JOIN humans h ON h.id = m.human_id
WHERE h.provider_account_id = '${accountId}' AND m.role = 'owner'`,
);
expect((owned.rows[0] as { n: number }).n).toBe(1);
});
it("refuses a third environment for one application (invariant 3, FR-TEN-04)", async () => {
// Its own identity, so its own application: this test adds a production
// environment, and the database is not truncated between runs — reusing a
// shared identity would hand the second run an application that already
// had one (the per-suite-environment lesson from 2.1, one level up).
const { body } = await withFreshIdentity(`envcap-${Date.now()}`, () =>
signUp("code-c"),
);
const appId = body.application.id;
// Production is legal — two environments are what FR-TEN-04 allows.
await db.execute(
`INSERT INTO environments (id, application_id, kind, signing_secret)
VALUES (gen_random_uuid(), '${appId}', 'production', gen_random_uuid())`,
);
// A second development environment is not, and the database is what says
// so — no application-level guard to lose a race to.
await expect(
db.execute(
`INSERT INTO environments (id, application_id, kind, signing_secret)
VALUES (gen_random_uuid(), '${appId}', 'development', gen_random_uuid())`,
),
).rejects.toThrow();
const kinds = await db.execute(
`SELECT count(*)::int AS n FROM environments WHERE application_id = '${appId}'`,
);
expect((kinds.rows[0] as { n: number }).n).toBe(2);
});
it("keeps two organisations blind to each other (invariant 4, FR-TEN-05)", async () => {
// Two tenants, each with a message of its own.
const a = await provisionOrganisation(db, {
provider: "github",
providerAccountId: `iso-a-${Date.now()}`,
organisationName: "org a",
});
const b = await provisionOrganisation(db, {
provider: "github",
providerAccountId: `iso-b-${Date.now()}`,
organisationName: "org b",
});
const repoA = new Repository(db, a.environment.id);
const repoB = new Repository(db, b.environment.id);
const userA = await repoA.createUser("a-user");
const channelA = await repoA.createChannel("a-channel", "public");
await repoA.addMember(channelA.id, userA.id);
await repoA.sendMessage(channelA.id, {
text: "a secret",
userId: userA.id,
});
// B asks for A's channel by id and gets nothing — not an error that would
// confirm it exists (FR-TEN-05).
expect(await repoB.listMessages(channelA.id, { limit: 10 })).toEqual([]);
expect(await repoB.getChannelByExternalId("a-channel")).toBeNull();
expect(await repoB.listChannels()).toEqual([]);
// And A still sees its own.
expect((await repoA.listMessages(channelA.id, { limit: 10 })).length).toBe(
1,
);
});
it("exposes provisioning nowhere but the signup path (invariant 7, spec FR-011)", async () => {
// The admin surface is a module-free function: it is not a provider, so no
// controller can be handed it by injection, and the tenancy module declares
// no providers at all. What a reader can check from outside is that no
// route creates a tenant on request.
for (const path of [
"/v1/channels/00000000-0000-0000-0000-000000000000/messages",
"/internal/memberships",
"/internal/backfill",
]) {
const res = await fetch(`${url}${path}`, {
method: "POST",
headers: { "content-type": "application/json" },
body: "{}",
});
// Whatever these answer, it is never "here is a new tenant".
expect(res.status).not.toBe(200);
const text = await res.text();
expect(text).not.toContain("organisation");
}
const before = await db.execute(
`SELECT count(*)::int AS n FROM organisations`,
);
await fetch(`${url}/internal/memberships`, {
headers: {
"x-relay-environment": "00000000-0000-0000-0000-000000000000",
},
});
const after = await db.execute(
`SELECT count(*)::int AS n FROM organisations`,
);
expect((after.rows[0] as { n: number }).n).toBe(
(before.rows[0] as { n: number }).n,
);
});
it("refuses a callback whose state does not match the cookie (invariant 5, over HTTP)", async () => {
const start = await fetch(`${url}/auth/github/start`, {
redirect: "manual",
});
const cookie = (start.headers.get("set-cookie") ?? "").split(";")[0]!;
// An attacker's state value with the victim's cookie: refused.
const res = await fetch(
`${url}/auth/github/callback?code=c&state=deadbeefdeadbeefdeadbeefdeadbeef`,
{ headers: { cookie } },
);
expect(res.status).toBe(400);
// And with no cookie at all.
const state = new URL(start.headers.get("location")!).searchParams.get(
"state",
)!;
const naked = await fetch(
`${url}/auth/github/callback?code=c&state=${state}`,
);
expect(naked.status).toBe(400);
expect(cookie).toContain(STATE_COOKIE);
});
it("answers 502 when the provider breaks its contract (invariant 6, over HTTP)", async () => {
const broken = await standInProvider({ login: "no-id-here" });
process.env.RELAY_OAUTH_GITHUB_USER_URL = `http://127.0.0.1:${broken.port}/user`;
process.env.RELAY_OAUTH_GITHUB_TOKEN_URL = `http://127.0.0.1:${broken.port}/token`;
const start = await fetch(`${url}/auth/github/start`, {
redirect: "manual",
});
const cookie = (start.headers.get("set-cookie") ?? "").split(";")[0]!;
const state = new URL(start.headers.get("location")!).searchParams.get(
"state",
)!;
const res = await fetch(
`${url}/auth/github/callback?code=c&state=${state}`,
{ headers: { cookie } },
);
// 502, not 400: the caller did nothing wrong.
expect(res.status).toBe(502);
await broken.close();
process.env.RELAY_OAUTH_GITHUB_USER_URL = `http://127.0.0.1:${provider.port}/user`;
process.env.RELAY_OAUTH_GITHUB_TOKEN_URL = `http://127.0.0.1:${provider.port}/token`;
});
});✓ signup > provisions the whole trio from one authentication (FR-TEN-01, FR-TEN-02)
✓ signup > writes the full set or nothing when provisioning fails (invariant 1)
✓ signup > recognises a returning owner instead of creating a second organisation (invariant 2)
✓ signup > refuses a third environment for one application (invariant 3, FR-TEN-04)
✓ signup > keeps two organisations blind to each other (invariant 4, FR-TEN-05)
✓ signup > exposes provisioning nowhere but the signup path (invariant 7, spec FR-011)
✓ signup > refuses a callback whose state does not match the cookie (invariant 5, over HTTP)
✓ signup > answers 502 when the provider breaks its contract (invariant 6, over HTTP)Two of those tests were wrong before they were right, and the reason is worth
your attention. Both assumed a fresh identity — but signup is idempotent per
identity, and the database is not truncated between runs. The first version of
the returning-owner test asserted created: true on a signup whose identity an
earlier test in the same file had already used, and the environment-cap test
inherited an application to which a previous run of the suite had already
added a production environment. Neither was a product bug; both were tests
asserting against the suite's history instead of against behaviour. The fix is
2.1's lesson one level up: give each test its own identity, the way each
integration suite gets its own environment.
At the tag the lanes read: 86 unit tests with no Docker (config 6, service-kit 3, protocol 26, api 18, gateway 33) and 60 integration tests across 11 files — the api's 44 against Postgres, the gateway's 8 against Redis, and 2.8's journey suite still 8, untouched.
The walk needs no provider account: it starts a stand-in on a fixed port and points the api at it.
// Chapter 3.1's walk: sign up, and then sign up again.
//
// The first run creates an organisation, an application and a development
// environment from one authentication. The second run, with the same provider
// identity, creates nothing and hands back what already exists. Both are the
// same code path a reader points at GitHub — only the provider is local, so
// the walk needs no account and no network (research R8).
//
// The stand-in listens on a FIXED port, because the api has to be told where
// the provider is before it starts:
//
// PROVIDER=http://127.0.0.1:4199
// RELAY_OAUTH_GITHUB_CLIENT_ID=walk \
// RELAY_OAUTH_GITHUB_CLIENT_SECRET=walk \
// RELAY_OAUTH_GITHUB_TOKEN_URL=$PROVIDER/token \
// RELAY_OAUTH_GITHUB_USER_URL=$PROVIDER/user \
// RELAY_OAUTH_GITHUB_AUTHORIZE_URL=$PROVIDER/authorize \
// node services/api/dist/main.js &
// node scripts/signup-walk.mjs
import { createServer } from "node:http";
const API = process.env.RELAY_API_URL ?? "http://127.0.0.1:4000";
/** A stand-in GitHub: one token endpoint, one profile endpoint. */
const ACCOUNT_ID = process.env.RELAY_WALK_ACCOUNT ?? `walk-${Date.now()}`;
const provider = createServer((req, res) => {
res.setHeader("content-type", "application/json");
if (req.url?.startsWith("/token")) {
res.end(JSON.stringify({ access_token: "gho_walk", token_type: "bearer" }));
return;
}
res.end(JSON.stringify({ id: ACCOUNT_ID, login: "tuan", name: "Tuan" }));
});
const PROVIDER_PORT = Number(process.env.RELAY_WALK_PROVIDER_PORT ?? 4199);
await new Promise((resolve) => provider.listen(PROVIDER_PORT, resolve));
console.log(`stand-in provider on ${PROVIDER_PORT}, account ${ACCOUNT_ID}\n`);
/** Walk the flow the way a browser does: follow the redirect, keep the cookie,
* come back with the state the server minted. */
async function signUp(label) {
const start = await fetch(`${API}/auth/github/start`, { redirect: "manual" });
const location = start.headers.get("location");
const cookie = (start.headers.get("set-cookie") ?? "").split(";")[0];
console.log(`${label} → GET /auth/github/start`);
console.log(` ${start.status} redirect to ${location?.slice(0, 60)}…`);
console.log(` cookie: ${cookie?.split("=")[0]} (HttpOnly, SameSite=Lax)`);
const state = new URL(location).searchParams.get("state");
const res = await fetch(
`${API}/auth/github/callback?code=walk-code&state=${state}`,
{ headers: { cookie } },
);
const body = await res.json();
console.log(`${label} → GET /auth/github/callback`);
console.log(` ${res.status} created=${body.created}`);
console.log(` organisation ${body.organisation?.id}`);
console.log(` application ${body.application?.id}`);
console.log(
` environment ${body.environment?.id} (${body.environment?.kind})\n`,
);
return body;
}
const first = await signUp("first authentication ");
const second = await signUp("second authentication");
console.log(
first.organisation?.id === second.organisation?.id
? "same organisation both times — one identity, one workspace (FR-TEN-01/02)"
: "DIFFERENT organisations — signup is not idempotent, which is a bug",
);
// And the state binding, refused: the value is real, the cookie is not sent.
const start = await fetch(`${API}/auth/github/start`, { redirect: "manual" });
const stolen = new URL(start.headers.get("location")).searchParams.get("state");
const forged = await fetch(
`${API}/auth/github/callback?code=walk-code&state=${stolen}`,
);
console.log(
`\nsame state, no cookie: ${forged.status} — the binding is what makes state work`,
);
provider.close();
process.exit(0);stand-in provider on 4199, account walk-1786186996322
first authentication → GET /auth/github/start
302 redirect to http://127.0.0.1:4199/authorize?client_id=walk&redirect_uri=…
cookie: relay_oauth_state (HttpOnly, SameSite=Lax)
first authentication → GET /auth/github/callback
200 created=true
organisation 63de1195-4f28-4c46-a3bd-85ebcb6f8869
application 2eb17d73-72f0-4fb6-bf5c-dcb9808abcab
environment dbd313f4-5818-480d-bf5a-b5a79cf25cdc (development)
second authentication → GET /auth/github/start
302 redirect to http://127.0.0.1:4199/authorize?client_id=walk&redirect_uri=…
cookie: relay_oauth_state (HttpOnly, SameSite=Lax)
second authentication → GET /auth/github/callback
200 created=false
organisation 63de1195-4f28-4c46-a3bd-85ebcb6f8869
application 2eb17d73-72f0-4fb6-bf5c-dcb9808abcab
environment dbd313f4-5818-480d-bf5a-b5a79cf25cdc (development)
same organisation both times — one identity, one workspace (FR-TEN-01/02)
same state, no cookie: 400 — the binding is what makes state workRead the two created values together. One authentication built a tenant; the
second built nothing and handed back the same three ids. And the last line is
the trap above, refused: a real state value with no cookie behind it is worth
nothing.
The exercise is the build. Then make the boundary prove itself:
psql. Watch
the database refuse it, and notice that no application code was involved in
that refusal.environment_id to humans as a nullable column and re-run the
isolation tests. They pass — that is the point of the TRAP. Then write down
which guarantee you just moved from the schema into somebody's memory.state
from a different terminal with no cookie. 400, before any network call:
confirm with the api's logs that the provider was never contacted.If you are stuck, the tag holds the answer key: part3-ch1.
UNIQUE (application_id, kind) with
a two-value CHECK is FR-TEN-04, and nothing can race past it.ADD COLUMN … NOT NULL cannot apply to a
populated table, and the generator has no way to know what the existing rows
should hold.state needs a cookie, not a signature: a value the server minted proves
the server minted it, not that this browser asked for it.