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/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/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/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/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])), +); 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-root-inspection.spec.ts b/e2e/tests/self-host-root-inspection.spec.ts new file mode 100644 index 0000000000..4fa0544b70 --- /dev/null +++ b/e2e/tests/self-host-root-inspection.spec.ts @@ -0,0 +1,620 @@ +/** + * 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(); +} +`; + +/** The full journals 7c4922535596a6fe86938a8acbe0078107ce2bca (#81) writes on self-host. */ +const hostedTable = `create table private_hosted_migrations (migration_id integer primary key, created_at timestamptz not null default now(), name text not null)`; +const hostedJournal = [ + "baseline", + "api_key_memberships", + "upgrade_executor_apps", + "data_steps", + "upgrade_executor_apps", + "upgrade_pinned_executor_apps", + "require_connection_targets", + "oauth_client_access", + "app_account_links", +].map( + (name, index) => + `insert into private_hosted_migrations (migration_id, name) values (${index + 1}, '${name}')`, +); +/** An install #81 just upgraded: one organization and exactly the journals #81 writes. */ +const rolloutReady = [ + ...schema, + rootTable, + journalTable, + hostedTable, + `insert into "organization" values ('${root}', 'Root', 'executor')`, + `insert into "member" values + ('m1', '${root}', 'user-owner-a', 'owner'), + ('m2', '${root}', 'user-owner-b', 'owner'), + ('m3', '${root}', 'user-admin', 'admin')`, + ...recorded, + ...journal, + ...hostedJournal, +]; + +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)), + journals: Schema.optionalKey(Schema.Record(Schema.String, Schema.Json)), +}); + +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