Building Relay

Part 3 · Chapter 3.1

Tenants all the way down

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

Phần 2 kết thúc với một suite xanh và một tuyên bố biện hộ được: message tới nơi đúng một lần, đúng thứ tự, xuyên các instance, qua một đường hầm. Thứ nó không kết thúc cùng là một sản phẩm mà người khác dùng được. Mọi request trong Phần 2 đều gọi tên tenant của nó bằng một header mà client đơn giản là tự khẳng định, và mọi tenant trong Phần 2 đều do một helper test tạo ra. Chẳng có cách nào để trở thành một khách hàng.

Chương này dựng cái đó: các khoang chứa mà thế giới của một khách hàng sống trong đó, và hành vi duy nhất tạo ra chúng. Nó cũng trả một món nợ mà bạn đã chứng kiến tôi vay. Chương 2.1 cần một dòng applications để thoả một khoá ngoại, chẳng có định nghĩa nào cho nó, và để lại thứ này:

// 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.

Đây chính là chương ấy, và cái stub ra đi.

Ba khoang chứa, và vì sao không phải hai

SRS đòi hỏi một hình dạng cụ thể, trong bốn yêu cầu mà đọc thành một câu thì dễ hơn. Một con người tạo ra một tổ chức bằng cách xác thực với một nhà cung cấp và chẳng cần cung cấp gì khác (FR-TEN-01). Tổ chức đó giữ được nhiều ứng dụng, mỗi cái có dữ liệu, key và hạn mức độc lập (FR-TEN-03). Mỗi ứng dụng có đúng hai environment, developmentproduction, với credential, hạn mức và kho dữ liệu riêng (FR-TEN-04). Và tạo một tổ chức thì tự động tạo luôn một ứng dụng và một environment development, mà chẳng cần thêm đầu vào nào (FR-TEN-02).

Ba tầng là nhiều hơn một tầng so với hầu hết mọi người sẽ vẽ ra, nên đáng hỏi mỗi tầng để làm gì. Tổ chức là nơi việc tính tiền và quyền sở hữu của con người sống — nó là thứ mà một công ty là. Ứng dụng là nơi một sản phẩm sống: một công ty ship hai app thì muốn hai bộ channel, hai bộ key, và một hoá đơn. Còn environment là nơi dữ liệu sống, bởi ship bất cứ thứ gì cũng nghĩa là phải có chỗ nào đó để làm hỏng nó trước đã.

Tầng cuối cùng đó là tầng bạn đã dựng rồi.

flowchart TB
    subgraph above["PHÍA TRÊN tenant boundary — ai sở hữu account"]
      org["organisations"]
      app["applications<br/>(FR-TEN-03: nhiều per organisation)"]
      hum["humans<br/>(một người, một provider account)"]
      mem["memberships<br/>(owner · admin · member — FR-TEN-07)"]
    end
    subgraph below["PHÍA DƯỚI — mọi row mang environment_id"]
      env["environments<br/>(đúng hai: development · production — FR-TEN-04)"]
      rest["users · channels · members · messages<br/>(toàn bộ Phần 2)"]
    end
    org --> app
    app --> env
    env --> rest
    org --- mem
    mem --- hum
    note["Đường này là cả chương. Phía trên, không row nào có<br/>environment_id — một người không phải tenant. Phía dưới,<br/>mọi row có một environment_id và repository require nó<br/>(constitution I, FR-TEN-06)"]
    below ~~~ note
Ranh giới mà chương này kẻ ra. Bên trên nó, các dòng nói ai sở hữu một tài khoản và chẳng mang cột tenant nào. Bên dưới nó, mọi dòng đều mang environment_id và repository từ chối truy vấn khi không có một cái.

Những cái bảng mà SAD chưa bao giờ viết

SAD §6.1 định nghĩa environments và mọi thứ bên dưới nó, bằng SQL, chính xác. Nó chẳng định nghĩa applications lẫn bất cứ thứ gì bên trên — environments tham chiếu applications(id) rồi tài liệu đơn giản là dừng lại. Nên ba bảng bên dưới được suy ra từ SRS, chứ không trích từ SAD, và chúng nói thế ngay trong schema, nơi một người đọc sẽ gặp chúng. Đó cũng chính là cơ chế mà 2.1 dùng cho members: khi các tài liệu hết chỗ nói, chương ghi lại một quyết định chứ không trình bày một phát minh như thể một bản đặc tả.

services/api/src/db/schema.ts
@@ -21,19 +21,93 @@ import {
 // runner applies it. The four tenant-bearing tables reproduce §6.1
 // column-for-column, constraints and DR citations included. Deliberately
 // absent, with named arrivals: message_edits (edit chapter), outbox
 // (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. 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: 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(
   "environments",
   {
     id: uuid("id").primaryKey(),
@@ -48,12 +122,16 @@ export const environments = pgTable(
   },
   (t) => [
     check(
       "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),
   ],
 );
 
 export const users = pgTable(
   "users",
   {

Hai chi tiết trong bản diff đó làm nhiều việc hơn vẻ ngoài của chúng.

UNIQUE (application_id, kind) trên environments chính là FR-TEN-04. Ràng buộc CHECK trên kind từ 2.1 đã giới hạn cột đó ở hai giá trị hợp lệ; một unique index trên (application_id, kind) do đó cho phép nhiều nhất một dòng cho mỗi giá trị, tức là nhiều nhất hai environment. Không trigger, không SELECT count(*) mà một request đồng thời có thể chạy vượt qua — quy tắc là một hình dạng, và hình dạng thì không cần phải được ghi nhớ.

humans chẳng có environment_id. Sự vắng mặt đó là dòng quan trọng nhất của chương, và nó có một ADR riêng.

flowchart LR
    subgraph rejected["Shape hấp dẫn — rejected (ADR-18)"]
      one["một users table<br/>environment_id NULLABLE<br/>(null = platform human)"]
      cost["nullable tenant column là shape<br/>Principle I cấm: repository không còn<br/>refuse unscoped query BY CONSTRUCTION,<br/>và FR-TEN-05 thành code review"]
      one --> cost
    end
    subgraph chosen["Thứ chương này build"]
      humans["humans — identified by<br/>(provider, provider_account_id)<br/>không bao giờ có environment_id"]
      users["users — identified by<br/>external_id của customer<br/>environment_id NOT NULL"]
    end
    humans -.->|"không bao giờ merged, không row nào crossing"| users
ADR-18 trong một bức tranh: cái hình dạng rẻ hơn thì cần một cột tenant cho phép null, mà đó chính là thứ làm cho sự cô lập thực thi được theo cấu tạo.

Migration không chịu áp dụng

Với schema đã viết xong, drizzle-kit sinh ra SQL và — theo đúng quy trình của ADR-16 — chúng ta đọc nó trước khi nó chạy. Thứ đầu tiên nó muốn làm là bất khả thi:

ALTER TABLE "applications" ADD COLUMN "organisation_id" uuid NOT NULL;

Câu lệnh đó không thể thành công với bất kỳ database nào vốn đã có các dòng application, và database của mọi người đọc đều có: createEnvironment của 2.1 đã tạo ra chúng từ khi Phần 2 bắt đầu. Database của tôi giữ 237 dòng. Một cột NOT NULL không thể tới trên một bảng đã có dữ liệu mà chẳng có ai nói các dòng đang có phải chứa gì — và bộ sinh thì chẳng có cách nào biết.

Nên câu lệnh được viết lại bằng tay, theo đúng cái hình dạng mà bài toán này luôn mang: thêm nó cho phép null, backfill, rồi mới ràng buộc.

services/api/migrations/0002_tenancy.sql
-- 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");

Phần backfill cho mỗi ứng dụng mồ côi một tổ chức riêng và tái dùng uuid của ứng dụng làm uuid của tổ chức, nên sau đó dòng dõi vẫn đọc được: một tổ chức mà id trùng với một ứng dụng là một tổ chức mà migration này bịa ra. Sau khi áp dụng nó, cả 237 ứng dụng đều có một tổ chức và cột đã là NOT NULL, và đó là bằng chứng duy nhất quan trọng.

Đăng ký: một hành vi, năm dòng

Việc cung cấp thuộc về bề mặt quản trị của tầng repository — cái hàm duy nhất trong file đó vẫn luôn được phép chạy mà không cần một phạm vi tenant, bởi nó là thao tác tạo ra một phạm vi. Nó đã tồn tại sẵn, và 2.1 đã giải thích nó cho bạn: createEnvironment tạo ra một ứng dụng và một environment. Giờ nó tạo thêm một tổ chức nữa, và có thêm một người anh em làm trọn việc cho một lần đăng ký.

services/api/src/db/repository.ts
@@ -1,20 +1,30 @@
 import { randomUUID } from "node:crypto";
 
 import { and, asc, desc, eq, gt, lt, sql, type SQL } from "drizzle-orm";
 
 import type { Db } from "./client";
-import { 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 the tenancy chapter 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
 //   at call sites. Cross-tenant reads return null/empty: no data, and no
 //   reveal that the foreign id even exists (FR-TEN-05).
 //
@@ -31,26 +41,207 @@ export interface Environment {
 }
 
 export async function createEnvironment(
   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.
+  //
+  // The tenancy chapter 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 (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 {
   id: string;
   external_id: string;
   display_name: string | null;
 }

Hãy để ý thứ không xảy ra: chẳng có cánh cửa thứ hai nào mở vào việc tạo tenant. Nguyên tắc I cho rằng sự cô lập sống ở một chỗ, và "cái thứ tạo ra các tenant" đúng là loại thao tác không nên với tới được từ hai hướng.

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 vào HttpOnly cookie
    A-->>B: 302 tới provider, mang cùng state
    B->>P: authorize (consent screen)
    P-->>B: 302 quay về với code + state
    B->>A: GET /auth/github/callback?code&state
    A->>A: state từ query PHẢI bằng cookie —<br/>check TRƯỚC mọi provider call
    A->>P: POST token endpoint (code -> access token)
    A->>P: GET user endpoint (đây là ai?)
    A->>DB: một transaction: human · organisation ·<br/>application · environment(development) · owner
    A-->>B: 200 {organisation, application, environment, created}
    Note over A,DB: năm rows hoặc không row nào (FR-TEN-02) —<br/>và authentication thứ hai không tạo gì
Luồng từ đầu tới cuối. Phép kiểm state xảy ra trước bất kỳ lời gọi nào tới nhà cung cấp — một callback chưa xác minh thì không bao giờ được làm cho server này nói chuyện với GitHub thay mặt một kẻ tấn công.

Tham số state không phải trang trí

Luồng authorization-code gồm ba bước, và nó được viết tay ở đây vì cùng lý do mà chương 2.5 đấu dây lần nâng cấp WebSocket bằng tay: một thư viện strategy sẽ giấu đi đúng cái phần mà bạn tới để học, và sẽ thêm phụ thuộc vào một service mà hiến pháp của nó nói là nhàm chán theo thiết kế. fetch của Node làm hai lời gọi HTTP; zod xác thực cả hai câu trả lời, bởi một payload không được xác thực thì không thất bại ở nơi nó tới — và ở đây payload quyết định ai đó là ai.

services/api/src/tenancy/oauth.schema.ts
import { z } from "zod";
 
// What a provider is allowed to say back. 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>;

Rồi tới phần dễ sai một cách tinh vi. state tồn tại để chứng minh rằng callback thuộc về đúng cái trình duyệt đã khởi động luồng. Cũng thật hấp dẫn khi muốn làm nó không trạng thái — ký một giá trị ngẫu nhiên, xác minh chữ ký lúc quay về, chẳng cần cookie. Phiên bản đó xác minh rất đẹp và chẳng ngăn được gì: một kẻ tấn công tự khởi động luồng, lấy cái state hoàn toàn hợp lệ mà server đã tạo, rồi đút cho một nạn nhân cái URL callback. Chữ ký kiểm ra đúng, bởi server thực sự đã tạo ra nó. Thứ mà trình duyệt của nạn nhân không có là cái cookie.

services/api/src/tenancy/state-cookie.ts
import { randomBytes, timingSafeEqual } from "node:crypto";
 
// The CSRF binding for the OAuth flow.
//
// `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 chứ không phải Strict, bởi lần chuyển hướng quay về của nhà cung cấp là một lần điều hướng xuyên site và Strict sẽ giữ lại cookie đúng vào lúc nó được cần. Secure ở mọi nơi trừ một base URL http trơn, bởi một người đọc trên localhost nếu không thì sẽ chẳng bao giờ nhận được nó và sẽ chẳng có gì để chỉ vào khi luồng thất bại.

services/api/src/tenancy/oauth.provider.ts
import {
  profileSchema,
  providerErrorSchema,
  tokenResponseSchema,
  type Profile,
} from "./oauth.schema";
 
// The authorization-code flow, by hand.
//
// 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;
}

Một nét lạ của nhà cung cấp kiếm được một dòng code và đáng một câu: GitHub trả lời một code hỏng bằng HTTP 200 kèm một object lỗi. Đoạn code chỉ parse hình dạng thành công sẽ đọc access_token: undefined như một token rồi đi tiếp. Đó là lý do hình dạng lỗi được parse trước, và lý do hai kiểu thất bại được phân biệt trên đường ra — một người từ chối là một 400, còn một nhà cung cấp trả lời thứ mà hợp đồng không cho phép là một 502, bởi bên gọi chẳng làm gì sai.

services/api/src/tenancy/signup.controller.ts
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 (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 (the credentials chapter
  // retires it).
//
// What these routes do NOT do: issue a session. A session is a credential,
// credentials are the credentials chapter's subject, and the dashboard that would consume one is
// Part 5. The callback reports what it created and stops there.
 
/** 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,
    };
  }
}
services/api/src/tenancy/tenancy.module.ts
import { Module } from "@nestjs/common";
 
import { SignupController } from "./signup.controller";
 
// Signup's home. 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 {}
services/api/src/app.module.ts
@@ -5,22 +5,23 @@ import {
 } from "@nestjs/common";
 import { APP_FILTER } from "@nestjs/core";
 
 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";
 
 // The application described as a module graph — ADR-15's convention for the
 // wide surface Phases 2-4 will grow. Registering the error filter as a
 // provider (APP_FILTER) instead of wiring it in main.ts means every entry
 // point — including tests — gets the same error envelope for free.
 @Module({
-  imports: [MessagesModule, InternalModule],
+  imports: [MessagesModule, InternalModule, TenancyModule],
   controllers: [HealthController],
   providers: [
     { provide: LOGGER, useFactory: apiLogger },
     { provide: APP_FILTER, useClass: ProtocolErrorFilter },
     RequestContextMiddleware,
   ],

Hãy nhìn xem controller này không mang gì: cái EnvironmentContextGuard mà mọi controller khác đã khoác từ 2.2. Hai route này tồn tại để xác lập ai đó là ai, và chưa có tenant nào tồn tại để một header gọi tên. Guard được áp theo từng controller, nên một route tiền-tenant đơn giản là không dùng một cái — và cái đường nối mà guard đó bảo vệ thì chương này không đụng tới.

Tự thiết lập nó

Các endpoint là cấu hình, không phải hằng số, và đó là thứ cho phép lane test trỏ vào một bản thay thế cục bộ còn một bản triển khai thật thì trỏ vào GitHub Enterprise. Để chạy luồng thật, hãy đăng ký một ứng dụng OAuth với nhà cung cấp của bạn, đặt callback thành http://localhost:4000/auth/github/callback, rồi 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=

Khi không có client id và secret, /auth/github/start trả lời 404 chứ không chuyển hướng vào một luồng không thể hoàn tất. Và cùng cái quy tắc đã áp cho DATABASE_URL từ 1.2 thì cũng áp cho client secret đó: chỉ là cấu hình lúc chạy — không bao giờ là một đối số build, không bao giờ nướng vào một tầng image, không bao giờ nằm trong một bundle phía client.

Thứ chương này để lại cho sau, một cách có chủ đích

Ba thứ mà một sản phẩm thật sẽ có, và các chương sở hữu chúng:

Không session. Sau callback bạn nhận một body JSON mô tả những gì đã được tạo, rồi server quên bạn. Một session là một credential; credential là chủ đề của chương credentials ("hai credential, một sai lầm"), và dashboard sẽ tiêu thụ một cái thì ở Phần 5. Bịa ra một credential thứ ba ở đây để chương credentials ngay lập tức làm lại chính là thứ mà Nguyên tắc VII tồn tại để ngăn.

Không quản lý vai trò. Lệnh đăng ký làm cho con người đang xác thực thành owner, và bộ từ vựng vai trò (owner, admin, member) tồn tại trong schema bởi FR-TEN-07 gọi tên nó. Lời mời, thăng chức và gỡ bỏ thì chưa được dựng.

Các đường nối chế độ dev ở lại. Mọi bề mặt của Phần 2 vẫn gọi tên tenant của nó bằng header x-relay-environment, và gateway vẫn xác minh token bằng một dev secret. Tenancy thật bên trên chúng chẳng làm cái nào đáng tin hơn, và chương credentials mới là nơi cả hai nghỉ hưu. Chẳng gì về chương này là lý do để tin cái header đó.

Các bài test, và chúng giữ gì

Bảy bất biến, chia giữa các lane theo thứ chúng cần. Phần ràng buộc và hợp đồng với nhà cung cấp là các hàm thuần tuý trên đầu vào của chúng, nên chúng chạy trong cái lane không-Docker mà một người đọc dùng ở mỗi lần lưu:

services/api/src/tenancy/oauth.test.ts
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, 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);
  });
});

Phần còn lại cần database thật, và một bài cần một nhà cung cấp — nên suite khởi động một cái, trên một cổng thật, trả lời đúng thứ mà bài test nói. Luồng thì không bị giả lập; chỉ có bên ở đầu kia là giả.

services/api/src/tenancy/signup.itest.ts
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 — 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)

Hai trong số các bài test đó từng sai trước khi đúng, và lý do đáng được bạn chú ý. Cả hai đều giả định một danh tính mới tinh — nhưng lệnh đăng ký là đẳng xâm theo từng danh tính, và database thì không bị dọn sạch giữa các lần chạy. Phiên bản đầu tiên của bài test người-chủ-quay-lại khẳng định created: true trên một lần đăng ký mà danh tính của nó đã bị một bài test trước đó trong cùng file dùng rồi, còn bài test trần-environment thì thừa hưởng một ứng dụng mà một lần chạy trước của suite đã thêm sẵn một environment production vào. Chẳng cái nào là bug sản phẩm; cả hai đều là các bài test khẳng định đối chiếu với lịch sử của suite thay vì đối chiếu với hành vi. Cách sửa là bài học của 2.1 nâng lên một tầng: cho mỗi bài test một danh tính riêng, theo đúng cách mỗi suite tích hợp có một environment riêng.

Ở tag đó các lane đọc ra: 86 bài test unit không cần Docker (config 6, service-kit 3, protocol 26, api 18, gateway 33) và 60 bài test tích hợp trên 11 file — 44 bài của api với Postgres, 8 bài của gateway với Redis, và suite hành trình của 2.8 vẫn 8 bài, không đụng tới.

Hãy đi bộ qua nó

Lần đi bộ chẳng cần tài khoản nhà cung cấp nào: nó khởi động một bản thay thế trên một cổng cố định rồi trỏ api vào đó.

scripts/signup-walk.mjs
// The tenancy chapter'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 work

ĐƯỢC SỬA bởi chương credentials. Bản ghi này là thứ lần đi bộ in ra ở tag part3-ch1. Từ chương credentials trở đi thì callback cũng trao luôn API key đầu tiên của environment — thêm một dòng, api key rk_dev_… (shown once), chỉ có mặt khi createdtrue. Chẳng gì ở trên thay đổi; một field được thêm vào. Nếu bạn đang chạy code ở HEAD chứ không ở tag của chương này, thì dòng đó là lý do.

Hãy đọc hai giá trị created cùng nhau. Một lần xác thực đã dựng một tenant; lần thứ hai chẳng dựng gì và trao lại đúng ba cái id ấy. Còn dòng cuối là cái bẫy ở trên, bị từ chối: một giá trị state thật mà chẳng có cookie đằng sau thì chẳng đáng gì.

Tới lượt bạn

Bài tập chính là phần dựng. Rồi hãy bắt cái ranh giới tự chứng minh nó:

  1. Thử cho một ứng dụng một environment thứ ba, thẳng từ psql. Hãy xem database từ chối nó, và để ý rằng chẳng có code ứng dụng nào dính dáng vào lời từ chối ấy.
  2. Thêm environment_id vào humans như một cột cho phép null rồi chạy lại các bài test cô lập. Chúng xanh — đó chính là điểm mấu chốt của cái BẪY. Rồi hãy viết ra xem bạn vừa dời bảo đảm nào từ schema vào trí nhớ của ai đó.
  3. Hoàn tất một lần đăng ký, rồi tự tay dựng một callback thứ hai dùng cùng cái state từ một terminal khác không có cookie. 400, trước bất kỳ lời gọi mạng nào: hãy xác nhận bằng log của api rằng nhà cung cấp chưa hề được liên hệ.
  4. Đăng ký hai lần với hai tài khoản nhà cung cấp khác nhau rồi xác nhận mỗi bên nhận được tổ chức, ứng dụng và environment riêng — rồi thử đọc channel của tenant A bằng header environment của tenant B và nhận được câu trả lời rỗng mà FR-TEN-05 đòi hỏi.

Nếu bạn bí, cái tag giữ đáp án: part3-ch1.

Những điều rút ra

  • Ba khoang chứa, mỗi cái một việc: tổ chức là việc tính tiền và quyền sở hữu, ứng dụng là một sản phẩm, và environment là tenant — tầng duy nhất mà ở đó sự cô lập là một cột (FR-TEN-03/04).
  • Hai quần thể, không bao giờ trộn lẫn (ADR-18): cái bảng đơn rẻ tiền thì cần một cột tenant cho phép null, và chính cột đó biến một bảo đảm cấu trúc thành một lời góp ý lúc review.
  • Một unique index có thể là một yêu cầu: UNIQUE (application_id, kind) cùng một ràng buộc CHECK hai-giá-trị chính là FR-TEN-04, và chẳng gì chạy vượt qua nó được.
  • Migration sinh ra thì phải được đọc: ADD COLUMN … NOT NULL không áp dụng được lên một bảng đã có dữ liệu, và bộ sinh thì chẳng có cách nào biết các dòng đang có phải chứa gì.
  • state cần một cookie, không phải một chữ ký: một giá trị server tạo ra thì chứng minh rằng server đã tạo ra nó, chứ không chứng minh rằng chính trình duyệt này đã xin.
  • Đăng ký thì cho bạn workspace của riêng bạn: tính đẳng xâm khoá theo tổ chức được sở hữu thì xác định cho mọi trường hợp, kể cả cái trường hợp mà các lời mời rồi sẽ làm cho với tới được.