From 689f8bdd9695a038125d4af8ba2e5ec91fccf947 Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 5 Nov 2026 16:07:01 -0800 Subject: [PATCH 1/4] Prove self-host setup races, setup rollback, forged creators and the switcher's unknown answer Five callers racing to set up a fresh instance leave one owner, organization and root record. A setup that fails at its last write leaves no rows and a retry succeeds. Creation ignores a body naming the owner and refuses the owner's own API key and MCP OAuth token, each shown valid where it was issued. The switcher offers no creation while the capability answer is pending or failed, and recovers on reload. Co-authored-by: Cursor --- e2e/test-plan.ts | 46 ++++++ .../self-host-organization-creation.spec.ts | 92 ++++++++++++ .../self-host-organization-switcher.spec.ts | 79 ++++++++++ e2e/tests/self-host-setup-integrity.spec.ts | 138 ++++++++++++++++++ 4 files changed, 355 insertions(+) create mode 100644 e2e/tests/self-host-setup-integrity.spec.ts diff --git a/e2e/test-plan.ts b/e2e/test-plan.ts index 396ba1f38f..5ede864484 100644 --- a/e2e/test-plan.ts +++ b/e2e/test-plan.ts @@ -227,6 +227,39 @@ export const scenarios = plan({ local: na("Local has no hosted organizations."), }, }, + selfHostSetupRace: { + legacyStorage: true, + file: "self-host-setup-integrity.spec.ts", + title: + "five callers racing to set up a fresh self-host leave one owner, one organization and one root record", + targets: { + "self-host": scheduled, + cloud: na("Cloud has no instance setup."), + local: na("Local has no hosted organizations."), + }, + }, + selfHostSetupRollback: { + legacyStorage: true, + file: "self-host-setup-integrity.spec.ts", + title: + "a self-host setup that fails partway leaves no account, organization or root record, and a retry succeeds", + targets: { + "self-host": scheduled, + cloud: na("Cloud has no instance setup."), + local: na("Local has no hosted organizations."), + }, + }, + selfHostOrganizationCreationForgedCallers: { + fixtures: "actors", + file: "self-host-organization-creation.spec.ts", + title: + "self-host organization creation ignores a body naming the owner and refuses the owner's own API key and OAuth token", + targets: { + "self-host": scheduled, + cloud: na("Cloud lets every signed-in account create a team; its onboarding decides."), + local: na("Local has no hosted organizations."), + }, + }, selfHostOrganizationSwitcher: { fixtures: "actors", file: "self-host-organization-switcher.spec.ts", @@ -240,6 +273,19 @@ export const scenarios = plan({ local: na("Local has no hosted organizations."), }, }, + selfHostOrganizationSwitcherCapabilityUnavailable: { + fixtures: "actors", + file: "self-host-organization-switcher.spec.ts", + title: + "the self-host switcher offers no creation while the capability answer is pending or failed, and recovers on reload", + targets: { + "self-host": scheduled, + cloud: na( + "Cloud offers creation to every signed-in account; its switcher has its own scenarios.", + ), + local: na("Local has no hosted organizations."), + }, + }, selfHostOrganizationCreationBootstrap: { legacyStorage: true, file: "self-host-organization-creation.spec.ts", diff --git a/e2e/tests/self-host-organization-creation.spec.ts b/e2e/tests/self-host-organization-creation.spec.ts index a93da3626a..66e4847952 100644 --- a/e2e/tests/self-host-organization-creation.spec.ts +++ b/e2e/tests/self-host-organization-creation.spec.ts @@ -11,6 +11,10 @@ import { Api, body, type Session } from "../support/api.ts"; import { HostedLive, withCase, withHostedCase } from "../support/case.ts"; import { Organization, Resource } from "../support/contracts.ts"; import { legacyStorage } from "../support/legacy-storage.ts"; +import { McpClient } from "../support/mcp-client.ts"; +import { consentTo } from "../support/mcp-connections.ts"; +import { Target } from "../support/platform.ts"; +import { targetHosts } from "../support/role-hosts.ts"; import { serverControl } from "../support/server-control.ts"; import { scenarios } from "../test-plan.ts"; @@ -22,6 +26,8 @@ const Members = Schema.Struct({ ), }); const Configuration = Schema.Struct({ setup: Schema.Boolean }); +const SessionUser = Schema.Struct({ user: Schema.Struct({ id: Schema.String }) }); +const Capabilities = Schema.Struct({ createOrganization: Schema.Boolean }); const create = (session: Session, name: string, headers?: Record) => Effect.flatMap(Api, (api) => @@ -141,6 +147,92 @@ layer(HostedLive, { excludeTestServices: true })("Self-host organization creatio ), ); + it.effect(scenarios.selfHostOrganizationCreationForgedCallers.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const api = yield* Api, + actors = yield* Actors, + target = yield* Target; + const root = actors.organization.id; + const anonymous = yield* api.session(); + const owner = yield* body( + SessionUser, + yield* api.request(actors.owner, "GET", "/api/auth/get-session"), + ); + const capabilities = (session: Session, headers?: Record) => + api.request(session, "GET", "/api/auth/self-host/capabilities", undefined, headers); + const answer = (session: Session) => + capabilities(session).pipe( + Effect.flatMap((response) => body(Capabilities, response)), + Effect.map((value) => value.createOrganization), + ); + expect(yield* answer(actors.owner)).toBe(true); + expect(yield* answer(actors.admin)).toBe(false); + expect(yield* answer(actors.member)).toBe(false); + expect((yield* capabilities(anonymous)).status).toBe(401); + + // Better Auth takes the creator from the session; a body naming the owner changes nothing. + const spoof = (session: Session) => + api.request(session, "POST", "/api/auth/organization/create", { + name: "Spoofed team", + slug: `spoof-${randomUUID().slice(0, 8)}`, + keepCurrentActiveOrganization: true, + userId: owner.user.id, + }); + expect((yield* spoof(actors.admin)).status).toBe(403); + expect((yield* spoof(actors.member)).status).toBe(403); + expect((yield* spoof(anonymous)).status).toBe(401); + + const key = yield* body( + Key, + yield* api.request(actors.owner, "POST", "/api/auth/api-key/create", { + name: "Owner key", + }), + ); + const consent = yield* consentTo(actors.owner, `${targetHosts(target).mcp}/mcp`, { + "x-executor-organization": root, + }); + expect(consent.status, JSON.stringify(consent)).toBe(200); + const oauth = yield* consent.tokens; + // Each credential is the owner's and valid where it was issued: the API key at the + // organization API, the OAuth token, bound to the MCP resource, through an MCP client. + expect( + (yield* api.request(anonymous, "GET", `/api/organizations/${root}/access`, undefined, { + authorization: `Bearer ${Redacted.value(key.key)}`, + })).status, + ).toBe(200); + yield* (yield* McpClient).connect(oauth, "creation-refusal", { organization: root }); + for (const [label, token] of [ + ["API key", Redacted.value(key.key)], + ["OAuth access token", Redacted.value(oauth)], + ] as const) { + const bearer = { authorization: `Bearer ${token}`, "x-executor-organization": root }; + expect((yield* create(anonymous, `${label} team`, bearer)).status, label).toBe(401); + expect((yield* capabilities(anonymous, bearer)).status, label).toBe(401); + expect( + (yield* api.request( + anonymous, + "POST", + "/api/auth/organization/create", + { + name: `${label} spoof`, + slug: `bearer-${randomUUID().slice(0, 8)}`, + keepCurrentActiveOrganization: true, + userId: owner.user.id, + }, + bearer, + )).status, + `${label} naming the owner`, + ).toBe(401); + } + expect((yield* listed(actors.owner)).map((organization) => organization.id)).toEqual([ + root, + ]); + }).pipe(Effect.provide(McpClient.layer)), + ), + ); + it.effect(scenarios.selfHostOrganizationCreationBootstrap.title, (context) => withCase( context, diff --git a/e2e/tests/self-host-organization-switcher.spec.ts b/e2e/tests/self-host-organization-switcher.spec.ts index 79d8b98456..075ce11c8b 100644 --- a/e2e/tests/self-host-organization-switcher.spec.ts +++ b/e2e/tests/self-host-organization-switcher.spec.ts @@ -120,4 +120,83 @@ layer(HostedLive, { excludeTestServices: true })("Self-host organization switche }), ), ); + it.effect(scenarios.selfHostOrganizationSwitcherCapabilityUnavailable.title, (context) => + withHostedCase( + context, + Effect.gen(function* () { + const actors = yield* Actors, + browser = yield* Browser; + const root = actors.organization; + const origin = targetHosts(yield* Target).browser; + let arrived = () => {}; + const arrival = new Promise((resolve) => { + arrived = resolve; + }); + let release = () => {}; + const held = new Promise((resolve) => { + release = resolve; + }); + const creation = (page: Page) => + page.getByRole("menuitem", { name: "Create organization", exact: true }).count(); + const openSwitcher = (page: Page) => + switcher(page) + .click() + .then(() => + page.getByRole("menuitemradio", { name: "Evidence lab", exact: true }).waitFor(), + ); + + yield* browser.login(actors.owner); + yield* browser.use("Hold the capability answer", (page) => + page.route("**/api/auth/self-host/capabilities", (route) => { + arrived(); + return held.then(() => + route.fulfill({ + status: 503, + contentType: "application/json", + body: JSON.stringify({ message: "Authentication is unavailable. Try again." }), + }), + ); + }), + ); + yield* browser.use("The owner opens the root's apps", (page) => + page.goto(`${origin}/org/${root.slug}/apps`).then(() => arrival), + ); + expect( + yield* browser.use("While the answer is pending, no create action", (page) => + openSwitcher(page).then(() => creation(page)), + ), + ).toBe(0); + yield* browser.checkpoint("No create action while the capability is pending"); + yield* browser.use("Close the switcher", (page) => page.keyboard.press("Escape")); + + yield* browser.use("The capability answer fails", (page) => + Promise.all([ + page.waitForResponse((response) => + response.url().endsWith("/api/auth/self-host/capabilities"), + ), + Promise.resolve().then(release), + ]), + ); + expect( + yield* browser.use("After a failed answer, no create action", (page) => + openSwitcher(page).then(() => creation(page)), + ), + ).toBe(0); + yield* browser.checkpoint("No create action after the capability fails"); + yield* browser.use("Close the switcher", (page) => page.keyboard.press("Escape")); + + yield* browser.use("The server answers again", (page) => + page.unroute("**/api/auth/self-host/capabilities"), + ); + expect( + yield* browser.use("Reload the root's apps", (page) => openApps(page, origin, root.slug)), + ).toEqual({ createOrganization: true }); + expect( + yield* browser.use("Once the server answers, the owner may create", (page) => + openSwitcher(page).then(() => creation(page)), + ), + ).toBe(1); + }), + ), + ); }); diff --git a/e2e/tests/self-host-setup-integrity.spec.ts b/e2e/tests/self-host-setup-integrity.spec.ts new file mode 100644 index 0000000000..db400970e8 --- /dev/null +++ b/e2e/tests/self-host-setup-integrity.spec.ts @@ -0,0 +1,138 @@ +/** + * First setup creates the instance's only owner, organization and root record together. Callers + * racing to set up a fresh instance leave exactly one of each, and a setup that fails partway + * leaves nothing behind, so a retry starts from an empty instance. + */ +import { expect, layer } from "@effect/vitest"; +import { Effect, Schema } from "effect"; +import { password } from "../support/actors.ts"; +import { Api, body, type Session } from "../support/api.ts"; +import { HostedLive, withCase } from "../support/case.ts"; +import { Organization } from "../support/contracts.ts"; +import { legacyStorage } from "../support/legacy-storage.ts"; +import { serverControl } from "../support/server-control.ts"; +import { scenarios } from "../test-plan.ts"; + +const Configuration = Schema.Struct({ setup: Schema.Boolean }); + +const countQueries = [ + { + sql: `select + (select count(*)::int from "user") as users, + (select count(*)::int from "account") as accounts, + (select count(*)::int from "organization") as organizations, + (select count(*)::int from "member") as members, + (select count(*)::int from "session") as sessions`, + }, + { sql: `select "id", "organizationId" from "selfHostRoot"` }, +] as const; + +const setup = (session: Session, index: number) => + Effect.flatMap(Api, (api) => + api.request(session, "POST", "/api/auth/self-host/setup", { + name: `Racer ${index}`, + email: `racer-${index}@example.test`, + password, + organizationName: `Race team ${index}`, + }), + ); + +const configuration = (session: Session) => + Effect.flatMap(Api, (api) => + api + .request(session, "GET", "/api/auth/self-host/config") + .pipe(Effect.flatMap((response) => body(Configuration, response))), + ); + +layer(HostedLive, { excludeTestServices: true })("Self-host setup integrity", (it) => { + it.effect(scenarios.selfHostSetupRace.title, (context) => + withCase( + context, + Effect.gen(function* () { + const api = yield* Api; + const sessions = yield* Effect.forEach([0, 1, 2, 3, 4], () => api.session()); + expect((yield* configuration(sessions[0]!)).setup).toBe(true); + const statuses = yield* Effect.forEach( + sessions, + (session, index) => setup(session, index).pipe(Effect.map((response) => response.status)), + { concurrency: "unbounded" }, + ); + expect(statuses.toSorted()).toEqual([200, 403, 403, 403, 403]); + const winner = statuses.indexOf(200); + const organizations = yield* body( + Schema.Array(Organization), + yield* api.request(sessions[winner]!, "GET", "/api/auth/organization/list"), + ); + expect(organizations).toHaveLength(1); + expect((yield* configuration(sessions[0]!)).setup).toBe(false); + + const [[counts] = [], root = []] = yield* legacyStorage(countQueries); + expect(counts).toEqual({ + users: 1, + accounts: 1, + organizations: 1, + members: 1, + sessions: 1, + }); + expect(root).toEqual([{ id: "root", organizationId: organizations[0]!.id }]); + }), + ), + ); + + it.effect(scenarios.selfHostSetupRollback.title, (context) => + withCase( + context, + Effect.gen(function* () { + const api = yield* Api; + // The membership is the last row setup writes, after the user, account, organization + // and root record. + yield* legacyStorage([ + { + sql: `create function e2e_refuse_member() returns trigger language plpgsql as $$ + begin raise exception 'synthetic setup failure'; end $$`, + }, + { + sql: `create trigger e2e_refuse_member before insert on "member" + for each row execute function e2e_refuse_member()`, + }, + ]); + yield* serverControl("start"); + const failed = yield* api.session(); + const first = yield* setup(failed, 1); + expect(first.status).toBeGreaterThanOrEqual(500); + expect((yield* configuration(failed)).setup).toBe(true); + const [[afterFailure] = [], rootAfterFailure = []] = yield* legacyStorage(countQueries); + expect(afterFailure).toEqual({ + users: 0, + accounts: 0, + organizations: 0, + members: 0, + sessions: 0, + }); + expect(rootAfterFailure).toEqual([]); + + yield* legacyStorage([ + { sql: `drop trigger e2e_refuse_member on "member"` }, + { sql: "drop function e2e_refuse_member()" }, + ]); + yield* serverControl("start"); + const retried = yield* api.session(); + expect((yield* setup(retried, 2)).status).toBe(200); + const organizations = yield* body( + Schema.Array(Organization), + yield* api.request(retried, "GET", "/api/auth/organization/list"), + ); + expect(organizations).toHaveLength(1); + const [[afterRetry] = [], rootAfterRetry = []] = yield* legacyStorage(countQueries); + expect(afterRetry).toEqual({ + users: 1, + accounts: 1, + organizations: 1, + members: 1, + sessions: 1, + }); + expect(rootAfterRetry).toEqual([{ id: "root", organizationId: organizations[0]!.id }]); + }), + ), + ); +}); From 75565d7420d64ad35d3bb92cec2b51cf451c7be5 Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 5 Nov 2026 16:26:17 -0800 Subject: [PATCH 2/4] Prove SSO admission against the self-host image The released-image suite gains two scenarios that run the native image with SSO configured against the synthetic OpenID provider. A sidecar owns the container network and forwards the provider's HTTPS address, so the image trusts it through NODE_EXTRA_CA_CERTS. A verified, approved address without a membership, an unverified address and an unapproved domain are refused with their codes and no session or membership. An invited member who connected SSO signs in, and is refused once removed. Co-authored-by: Cursor --- e2e/docker-release.config.ts | 1 + .../docker-release-sso-admission.spec.ts | 332 ++++++++++++++++++ 2 files changed, 333 insertions(+) create mode 100644 e2e/tests/docker-release-sso-admission.spec.ts diff --git a/e2e/docker-release.config.ts b/e2e/docker-release.config.ts index d0303c8b6f..7cb3e89079 100644 --- a/e2e/docker-release.config.ts +++ b/e2e/docker-release.config.ts @@ -12,6 +12,7 @@ export default defineConfig({ "e2e/tests/docker-release-watchdog.spec.ts", "e2e/tests/docker-release-app-isolation.spec.ts", "e2e/tests/docker-oauth-renewal.spec.ts", + "e2e/tests/docker-release-sso-admission.spec.ts", "e2e/tests/workflow-engine.spec.ts", ], testTimeout: 180_000, diff --git a/e2e/tests/docker-release-sso-admission.spec.ts b/e2e/tests/docker-release-sso-admission.spec.ts new file mode 100644 index 0000000000..a6a0f93cf2 --- /dev/null +++ b/e2e/tests/docker-release-sso-admission.spec.ts @@ -0,0 +1,332 @@ +/** + * The self-host image admits only invited members through SSO. The synthetic OpenID provider runs + * on the runner. A sidecar owns the container network: it publishes the product's port and + * forwards the provider's HTTPS address, so the image reads discovery from the same + * `https://127.0.0.1` issuer and trusts its certificate through `NODE_EXTRA_CA_CERTS`, without + * disabling TLS checks. The sign-in flow runs over HTTP exactly as a browser follows it. + */ +import { randomBytes } from "node:crypto"; +import { createServer } from "node:net"; +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Config, Console, Effect, Exit, Schedule, Schema } from "effect"; +import { FetchHttpClient } from "effect/http"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; +import { driver } from "../support/platform.ts"; +import { selfHostSsoIdp, type IdentityMode } from "../support/sso.ts"; + +const password = "Synthetic-release-password-123!"; +const forwarder = `const net = require("node:net"); +const [port, host] = [Number(process.argv[1]), process.argv[2]]; +net.createServer((client) => { + const upstream = net.connect(port, host); + client.pipe(upstream).pipe(client); + client.on("error", () => upstream.destroy()); + upstream.on("error", () => client.destroy()); +}).listen(port, "127.0.0.1");`; + +const freePort = driver( + "allocate port", + () => + new Promise((resolve, reject) => { + const listener = createServer(); + listener.once("error", reject); + listener.listen(0, "127.0.0.1", () => { + const address = listener.address(); + listener.close(() => + address === null || typeof address === "string" + ? reject(new Error("No test port")) + : resolve(address.port), + ); + }); + }), +); + +/** A cookie jar for one person's requests, following redirects by hand as a browser does. */ +const client = (origin: string) => { + const cookies = new Map(); + const send = (url: string, init: RequestInit = {}) => + driver(`${init.method ?? "GET"} ${new URL(url).pathname}`, (signal) => + fetch(url, { + ...init, + signal, + redirect: "manual", + headers: { + ...(new URL(url).origin === origin ? { origin } : {}), + ...(init.body === undefined ? {} : { "content-type": "application/json" }), + ...(cookies.size === 0 || new URL(url).origin !== origin + ? {} + : { cookie: [...cookies].map(([name, value]) => `${name}=${value}`).join("; ") }), + }, + }), + ).pipe( + Effect.tap((response) => + Effect.sync(() => { + if (new URL(url).origin !== origin) return; + for (const header of response.headers.getSetCookie()) { + const [pair = ""] = header.split(";"); + const at = pair.indexOf("="); + const [name, value] = [pair.slice(0, at), pair.slice(at + 1)]; + if (value === "" || /max-age=0/i.test(header)) cookies.delete(name); + else cookies.set(name, value); + } + }), + ), + ); + const json = (path: string, data?: unknown) => + send( + `${origin}${path}`, + data === undefined ? {} : { method: "POST", body: JSON.stringify(data) }, + ); + const read = (path: string) => + json(path).pipe(Effect.flatMap((response) => driver(`read ${path}`, () => response.json()))); + return { send, json, read, cookies }; +}; + +/** + * Start the provider, the sidecar and the image under test with SSO configured, and set up the + * owner. Everything is removed when the scope closes. + */ +const ssoImage = Effect.gen(function* () { + const processes = yield* ChildProcessSpawner.ChildProcessSpawner; + const image = yield* Config.String("EXECUTOR_E2E_DOCKER_IMAGE"); + const docker = (args: readonly string[]) => + processes.string(ChildProcess.make("docker", args, { extendEnv: true })); + const port = yield* freePort; + const origin = `http://127.0.0.1:${port}`; + const idp = yield* selfHostSsoIdp(origin); + const issuerPort = new URL(idp.issuer).port; + const name = `executor-sso-${randomBytes(6).toString("hex")}`; + yield* Effect.acquireRelease( + docker([ + "run", + "--detach", + "--name", + `${name}-network`, + "--add-host", + "sso-idp.e2e.internal:host-gateway", + "--publish", + `127.0.0.1:${port}:8080`, + "node:24.22.1-bookworm-slim", + "node", + "-e", + forwarder, + issuerPort, + "sso-idp.e2e.internal", + ]), + () => docker(["rm", "--force", `${name}-network`]).pipe(Effect.orDie), + ); + const environment = { + DO_NOT_TRACK: "1", + PORT: "8080", + BETTER_AUTH_URL: origin, + BETTER_AUTH_SECRET: randomBytes(32).toString("hex"), + EXECUTOR_ENCRYPTION_KEY: randomBytes(32).toString("hex"), + SSO_DISCOVERY_URL: idp.discoveryUrl, + SSO_CLIENT_ID: "synthetic-sso-client", + SSO_CLIENT_SECRET: "synthetic-sso-secret", + SSO_ALLOWED_DOMAINS: "sso.example.test", + NODE_EXTRA_CA_CERTS: "/run/sso-ca.pem", + }; + yield* Effect.acquireRelease( + processes.string( + ChildProcess.make( + "docker", + [ + "run", + "--detach", + "--name", + name, + "--init", + "--network", + `container:${name}-network`, + "--volume", + `${idp.certificate}:/run/sso-ca.pem:ro`, + ...Object.keys(environment).flatMap((variable) => ["--env", variable]), + image, + ], + { env: environment, extendEnv: true }, + ), + ), + () => docker(["rm", "--force", "--volumes", name]).pipe(Effect.orDie), + ); + yield* Effect.addFinalizer((exit) => + Exit.isFailure(exit) + ? docker(["logs", name]).pipe(Effect.flatMap(Console.error), Effect.ignore) + : Effect.void, + ); + const owner = client(origin); + yield* owner.json("/health").pipe( + Effect.flatMap((response) => + response.status === 200 ? Effect.void : Effect.fail("not ready"), + ), + Effect.retry({ schedule: Schedule.spaced("250 millis"), times: 240 }), + ); + const imageId = (yield* docker(["inspect", "--format", "{{.Image}}", name])).trim(); + const build = (yield* owner.json("/")).headers.get("executor-build"); + expect( + (yield* owner.json("/api/auth/self-host/setup", { + name: "SSO Owner", + email: "owner@sso.example.test", + password, + organizationName: "SSO root", + })).status, + ).toBe(200); + const [organization] = yield* owner + .read("/api/auth/organization/list") + .pipe( + Effect.flatMap( + Schema.decodeUnknownEffect(Schema.Array(Schema.Struct({ id: Schema.String }))), + ), + ); + if (organization === undefined) return yield* Effect.die("Setup created no organization"); + /** + * Follow one OAuth start (`/sign-in/social` or `/link-social`, as the dashboard calls them) through the provider and back to + * the product's callback, and return where the callback sends the browser. + */ + const ssoFlow = ( + person: ReturnType, + email: string, + mode: IdentityMode, + start: "/api/auth/sign-in/social" | "/api/auth/link-social", + ) => + Effect.gen(function* () { + yield* idp.configure(email, mode); + const started = yield* person + .json( + start, + start === "/api/auth/sign-in/social" + ? { provider: "sso", callbackURL: "/", errorCallbackURL: "/login" } + : { + provider: "sso", + callbackURL: "/account/security", + errorCallbackURL: "/account/security", + }, + ) + .pipe( + Effect.flatMap((response) => + driver("read OAuth start", () => response.text()).pipe( + Effect.map((text) => ({ status: response.status, text })), + ), + ), + ); + const { url } = yield* Schema.decodeUnknownEffect( + Schema.fromJsonString(Schema.Struct({ url: Schema.String })), + )(started.text).pipe( + Effect.mapError( + () => new Error(`${start} answered ${started.status}: ${started.text.slice(0, 300)}`), + ), + ); + // The browser cannot trust the synthetic certificate; it reaches the same sign-in page on + // the provider's loopback HTTP listener, as the browser scenarios route it. + const authorize = new URL(url); + const provider = yield* person.send(`${idp.origin}${authorize.pathname}${authorize.search}`); + const callback = provider.headers.get("location"); + if (callback === null) return yield* Effect.die("The provider did not redirect back"); + const answered = yield* person.send(callback); + return new URL(answered.headers.get("location") ?? "/", origin); + }); + const session = (person: ReturnType) => + person + .read("/api/auth/get-session") + .pipe( + Effect.flatMap( + Schema.decodeUnknownEffect( + Schema.NullOr(Schema.Struct({ user: Schema.Struct({ email: Schema.String }) })), + ), + ), + ); + return { origin, owner, organization, ssoFlow, session, imageId, build }; +}); + +const signIn = ( + sso: Effect.Success, + person: ReturnType, + email: string, + mode: IdentityMode, +) => sso.ssoFlow(person, email, mode, "/api/auth/sign-in/social"); + +it.live("the self-host image refuses SSO for anyone without a membership and says why", () => + Effect.scoped( + Effect.gen(function* () { + const sso = yield* ssoImage; + yield* Console.log(`image ${sso.imageId} build ${sso.build}`); + const expectRefused = (email: string, mode: IdentityMode, code: string) => + Effect.gen(function* () { + const person = client(sso.origin); + const landed = yield* signIn(sso, person, email, mode); + expect(landed.pathname, email).toBe("/login"); + expect(landed.searchParams.get("error"), email).toBe(code); + expect(yield* sso.session(person), email).toBeNull(); + }); + // Verified, from the approved domain, never invited: SSO adds no one. + yield* expectRefused("stranger@sso.example.test", "valid", "sso_not_member"); + yield* expectRefused("unverified@sso.example.test", "unverified", "sso_email_unverified"); + yield* expectRefused("member@outside.example.test", "valid", "sso_domain_not_allowed"); + const members = yield* sso.owner.read( + `/api/auth/organization/list-members?organizationId=${sso.organization.id}`, + ); + expect(members).toMatchObject({ total: 1, members: [{ role: "owner" }] }); + }), + ).pipe(Effect.provide([NodeServices.layer, FetchHttpClient.layer])), +); + +it.live( + "the self-host image signs in an invited member through SSO and refuses them once removed", + () => + Effect.scoped( + Effect.gen(function* () { + const sso = yield* ssoImage; + yield* Console.log(`image ${sso.imageId} build ${sso.build}`); + const invited = yield* sso.owner + .json("/api/auth/organization/invite-member", { + organizationId: sso.organization.id, + email: "member@sso.example.test", + role: "member", + }) + .pipe( + Effect.flatMap((response) => driver("read invitation", () => response.json())), + Effect.flatMap(Schema.decodeUnknownEffect(Schema.Struct({ id: Schema.String }))), + ); + const member = client(sso.origin); + expect( + (yield* member.json("/api/auth/self-host/register", { + invitation: invited.id, + email: "member@sso.example.test", + name: "SSO Member", + password, + })).status, + ).toBe(200); + expect(yield* member.read("/api/auth/organization/list")).toMatchObject([ + { id: sso.organization.id }, + ]); + // The registered member connects SSO from Security; the link is not a sign-in. + const linked = yield* sso.ssoFlow( + member, + "member@sso.example.test", + "valid", + "/api/auth/link-social", + ); + expect(linked.searchParams.get("error")).toBeNull(); + + const returning = client(sso.origin); + const admitted = yield* signIn(sso, returning, "member@sso.example.test", "valid"); + expect(admitted.searchParams.get("error")).toBeNull(); + expect(yield* sso.session(returning)).toMatchObject({ + user: { email: "member@sso.example.test" }, + }); + + expect( + (yield* sso.owner.json("/api/auth/organization/remove-member", { + organizationId: sso.organization.id, + memberIdOrEmail: "member@sso.example.test", + })).status, + ).toBe(200); + const removed = client(sso.origin); + const refused = yield* signIn(sso, removed, "member@sso.example.test", "valid"); + expect(refused.pathname).toBe("/login"); + expect(refused.searchParams.get("error")).toBe("sso_not_member"); + expect(yield* sso.session(removed)).toBeNull(); + }), + ).pipe(Effect.provide([NodeServices.layer, FetchHttpClient.layer])), +); From 4b144f658081d0639967182b4fa2c905ae4d2119 Mon Sep 17 00:00:00 2001 From: shuv Date: Thu, 5 Nov 2026 16:46:55 -0800 Subject: [PATCH 3/4] Add a fail-closed offline gate for the self-host root record scripts/inspect-self-host-root.ts loads an executor-host export archive into an in-memory PGlite and compares it with the identity the operator recorded before the upgrade: one organization ID and its full set of owners. It resolves PGlite from the repository, refuses any version but the product's 0.5.8 before running it, and requires Postgres 18. It passes only when the archive has exactly one root record, id root, naming that organization, the organization's owners are exactly the recorded set, and the journal records 1 root_organization. It exits 1 when the gate fails, 2 for an invalid invocation or baseline and 3 when the archive or PGlite cannot be used, and prints a fixed report without rows, SQL errors or stack traces. e2e:root-inspection runs it as a separate process over synthetic PGlite archives for each pass, failure, baseline and error case. Co-authored-by: Cursor --- e2e/ci-selection.ts | 1 + e2e/self-host-root-inspection.config.ts | 6 + e2e/tests/self-host-root-inspection.spec.ts | 469 ++++++++++++++++++++ package.json | 1 + scripts/inspect-self-host-root.ts | 210 +++++++++ 5 files changed, 687 insertions(+) create mode 100644 e2e/self-host-root-inspection.config.ts create mode 100644 e2e/tests/self-host-root-inspection.spec.ts create mode 100644 scripts/inspect-self-host-root.ts diff --git a/e2e/ci-selection.ts b/e2e/ci-selection.ts index 85d7bbd01f..2f200339d1 100644 --- a/e2e/ci-selection.ts +++ b/e2e/ci-selection.ts @@ -591,6 +591,7 @@ const suiteConfigs: Record Promise> = { "export-public.config.ts": () => import("./export-public.config.ts"), "docker-release.config.ts": () => import("./docker-release.config.ts"), "self-host-native.config.ts": () => import("./self-host-native.config.ts"), + "self-host-root-inspection.config.ts": () => import("./self-host-root-inspection.config.ts"), "installer.config.ts": () => import("./installer.config.ts"), "local-bootstrap.config.ts": () => import("./local-bootstrap.config.ts"), "npm-launcher.config.ts": () => import("./npm-launcher.config.ts"), diff --git a/e2e/self-host-root-inspection.config.ts b/e2e/self-host-root-inspection.config.ts new file mode 100644 index 0000000000..409fa31205 --- /dev/null +++ b/e2e/self-host-root-inspection.config.ts @@ -0,0 +1,6 @@ +import { defineConfig } from "vitest/config"; + +/** The offline self-host root gate, run as a separate process over synthetic PGlite archives. */ +export default defineConfig({ + test: { include: ["e2e/tests/self-host-root-inspection.spec.ts"], testTimeout: 60_000 }, +}); diff --git a/e2e/tests/self-host-root-inspection.spec.ts b/e2e/tests/self-host-root-inspection.spec.ts new file mode 100644 index 0000000000..577baf338c --- /dev/null +++ b/e2e/tests/self-host-root-inspection.spec.ts @@ -0,0 +1,469 @@ +/** + * The offline root-record gate (`scripts/inspect-self-host-root.ts`) run as an operator runs it: + * a separate Node process over a PGlite data-directory archive and a recorded baseline. The + * archives are real PGlite dumps with the product's table and column names. Every case checks + * the exit code, the fixed report, and that neither stream carries rows, SQL errors, stack + * traces or the session token planted in each archive. + */ +import { expect, it } from "@effect/vitest"; +import * as NodeServices from "@effect/platform-node/NodeServices"; +import { Effect, FileSystem, Path, Schema, Stream } from "effect"; +import { ChildProcess, ChildProcessSpawner } from "effect/process"; + +const root = "org-root-1"; +const owners = ["user-owner-a", "user-owner-b"]; +const canary = "CANARY-SESSION-TOKEN-7d1f"; + +const schema = [ + `create table "organization" ("id" text primary key, "name" text not null, "slug" text not null)`, + `create table "member" ("id" text primary key, "organizationId" text not null, "userId" text not null, "role" text not null)`, + `create table "session" ("id" text primary key, "token" text not null)`, + `insert into "session" values ('session-1', '${canary}')`, +]; +const rootTable = `create table "selfHostRoot" ("id" text not null, "organizationId" text not null)`; +const journalTable = `create table private_self_host_migrations (migration_id integer primary key, created_at timestamptz not null default now(), name text not null)`; +const organizations = [ + `insert into "organization" values ('${root}', 'Root', 'executor'), ('org-second', 'Second', 'second')`, + `insert into "member" values + ('m1', '${root}', 'user-owner-a', 'owner'), + ('m2', '${root}', 'user-owner-b', 'owner'), + ('m3', '${root}', 'user-admin', 'admin'), + ('m4', 'org-second', 'user-outsider', 'owner')`, +]; +const recorded = [`insert into "selfHostRoot" values ('root', '${root}')`]; +const journal = [ + `insert into private_self_host_migrations (migration_id, name) values (1, 'root_organization')`, +]; +/** An upgraded install whose root has two legitimate owners. */ +const valid = [...schema, rootTable, journalTable, ...organizations, ...recorded, ...journal]; + +/** Builds one archive: `node --eval builder `. */ +const builder = ` +import { readFileSync, writeFileSync } from "node:fs"; +import { PGlite } from "@electric-sql/pglite"; +const [input, output] = process.argv.slice(1); +const db = new PGlite(); +try { + for (const statement of JSON.parse(readFileSync(input, "utf8"))) await db.exec(statement); + writeFileSync(output, new Uint8Array(await (await db.dumpDataDir("none")).arrayBuffer())); +} finally { + await db.close(); +} +`; + +const baseline = (ownerUserIds: readonly string[] = owners, organizationId = root) => + JSON.stringify({ version: 1, organizationId, ownerUserIds }); + +const Report = Schema.Struct({ + gate: Schema.Literals(["pass", "fail", "invalid", "error"]), + reason: Schema.optionalKey(Schema.String), + missingOwners: Schema.optionalKey(Schema.Array(Schema.String)), + unexpectedOwners: Schema.optionalKey(Schema.Number), + checks: Schema.optionalKey(Schema.Record(Schema.String, Schema.Boolean)), +}); + +const workspace = Effect.gen(function* () { + const fs = yield* FileSystem.FileSystem, + path = yield* Path.Path, + processes = yield* ChildProcessSpawner.ChildProcessSpawner; + const directory = yield* fs.makeTempDirectoryScoped({ prefix: "executor-root-inspection-" }); + let files = 0; + const file = (contents: string | Uint8Array) => + Effect.gen(function* () { + const target = path.join(directory, `fixture-${files++}`); + yield* typeof contents === "string" + ? fs.writeFileString(target, contents) + : fs.writeFile(target, contents); + return target; + }); + /** + * A PGlite data-directory dump of a database built from `statements`, as export writes it. The + * dump is made in its own Node process with the repository's pinned PGlite. + */ + const dump = (statements: readonly string[]) => + Effect.gen(function* () { + const input = yield* file(JSON.stringify(statements)); + const output = `${input}.tar`; + yield* processes.string( + ChildProcess.make("node", ["--input-type=module", "--eval", builder, input, output], { + cwd: path.resolve("."), + }), + ); + return yield* fs.readFile(output); + }); + const archive = (statements: readonly string[]) => Effect.flatMap(dump(statements), file); + /** Run the gate as `node