diff --git a/.changeset/member-directory-auth-cutover.md b/.changeset/member-directory-auth-cutover.md new file mode 100644 index 0000000000..dec9f04338 --- /dev/null +++ b/.changeset/member-directory-auth-cutover.md @@ -0,0 +1,11 @@ +--- +"@executor-js/cloud": patch +"@executor-js/api": patch +"@executor-js/host-selfhost": patch +--- + +Cloud now authorizes every protected request against the local membership mirror through the shared `MemberDirectory` seam: the per-request org membership check, the admin gates on the account and admin planes, the org switcher's organization list, and the free-organization limit all read the mirror instead of calling WorkOS. WorkOS is now a write target and an event source only. The seam gains `membershipsOf(accountId)` and `membershipById(organizationId, membershipId)` on both hosts. + +The mirror is trusted only while it is **ready**: the backfill has written every organization and the Events reconciler has drained the stream within the last ten minutes (both recorded on the `workos_sync` row). Until then the membership check falls back to WorkOS, exactly as before, so a member the backfill has not written yet is not locked out and a member revoked while the reconciler was down is not let in. The deploy runs `scripts/ensure-workos-mirror-ready.ts` after the migrations: it runs the backfill if needed, drains the events stream itself if the reconciler has not recently (so the gate never waits on a cron this same deploy ships), and fails the deploy if the mirror is still not ready. An organization the mirror does not hold at all (one that predates the mirror and nobody has signed in to since) is resolved from WorkOS on demand for a caller WorkOS confirms as its member, so CLI and MCP tokens naming such an organization are not refused. Deleting an organization now cancels billing before deleting the WorkOS organization, and a retry after a partial deletion is admitted from the mirror even while the mirror is not ready. + +**Ops step (cloud):** add the `WORKOS_API_KEY` secret to the `production` GitHub environment so the deploy gate can run the backfill. diff --git a/.changeset/member-directory-readers.md b/.changeset/member-directory-readers.md new file mode 100644 index 0000000000..6cc71965cc --- /dev/null +++ b/.changeset/member-directory-readers.md @@ -0,0 +1,10 @@ +--- +"@executor-js/cloud": patch +"@executor-js/api": patch +"@executor-js/react": patch +"@executor-js/sdk": patch +--- + +Member lists, the admin users page, and seat counts on cloud now read from the local membership mirror through the shared `MemberDirectory` seam instead of fanning out one WorkOS read per member. The admin users page gains an email/name search. + +**Deploy prerequisite (cloud):** `bun run --cwd apps/cloud db:backfill-workos-mirror:prod` must complete before this build is deployed, and its printed membership count should match WorkOS. Until the backfill has stamped the mirror's marker, seat reporting to Autumn is skipped with a warning (never a partial count) and member lists show only members who have signed in since the mirror shipped. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 2f0d97c242..12b83997c8 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -54,6 +54,19 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} + # The build below authorizes every request from the local membership + # mirror. This runs the mirror backfill if it has not completed, drains + # the WorkOS events stream itself if the reconciler has not recently + # (it does not wait on the cron, which this same deploy may be the one + # to ship), and FAILS the deploy if the mirror is still not ready — see + # scripts/ensure-workos-mirror-ready.ts. + - name: Backfill and verify the membership mirror + run: bun run scripts/ensure-workos-mirror-ready.ts + working-directory: apps/cloud + env: + DATABASE_URL: ${{ secrets.DATABASE_URL }} + WORKOS_API_KEY: ${{ secrets.WORKOS_API_KEY }} + deploy-cloud: name: Deploy cloud runs-on: blacksmith-4vcpu-ubuntu-2404 diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 328d096b52..b7871ba869 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -36,6 +36,7 @@ "db:backfill-workos-mirror:dev": "op run --env-file=.env.op -- bun run scripts/backfill-workos-mirror.ts", "db:drain-workos-events:prod": "op run --env-file=.env.production -- bun run scripts/drain-workos-events.ts", "db:drain-workos-events:dev": "op run --env-file=.env.op -- bun run scripts/drain-workos-events.ts", + "db:ensure-workos-mirror-ready:prod": "op run --env-file=.env.production -- bun run scripts/ensure-workos-mirror-ready.ts", "routes:gen": "bun scripts/gen-routes.ts", "vendor-wasm": "bun run scripts/vendor-quickjs-wasm.ts" }, diff --git a/apps/cloud/scripts/ensure-workos-mirror-ready.ts b/apps/cloud/scripts/ensure-workos-mirror-ready.ts new file mode 100644 index 0000000000..d10b826d3e --- /dev/null +++ b/apps/cloud/scripts/ensure-workos-mirror-ready.ts @@ -0,0 +1,115 @@ +/* oxlint-disable executor/no-try-catch-or-throw -- boundary: out-of-band deploy gate over a raw postgres connection */ +// --------------------------------------------------------------------------- +// Deploy gate: make the membership mirror READY before the build that +// authorizes from it goes live, and fail the deploy if it cannot be. +// +// bun run db:ensure-workos-mirror-ready:prod # op run --env-file=.env.production +// (deploy.yml runs it after the migrations, before the cloud deploy) +// +// Readiness is the SAME rule the request path applies +// (`src/auth/mirror-readiness-store.ts`): the one-off backfill has written +// every organization (`workos_sync.backfill_completed_at`) AND the events +// reconciler has drained the stream within its lag budget +// (`workos_sync.drained_at`). Until both hold the deployed build reads +// membership from WorkOS instead of the mirror, so an unready mirror never +// locks anyone out or lets a revoked member in — but a deploy that leaves it +// unready would run every request through that fallback, which is the state +// this whole cutover exists to leave behind. So this gate: +// 1. reads the readiness row; +// 2. if the backfill has not completed, RUNS it (scripts/backfill-workos-mirror.ts, +// idempotent) and reads again; +// 3. if the reconciler has not drained recently, DRAINS the stream itself +// (scripts/drain-workos-events.ts: the same replay the Worker's cron +// runs, over this connection) and reads again — never merely waits for +// the cron: this gate runs BEFORE the build that carries the cron may +// have been deployed, and a gate that only waited could not pass until +// the reconciler build had shipped on its own, by hand. A cron that is +// already live is safe beside it (the cursor's compare-and-set gives +// the stream one owner at a time); +// 4. exits 0 only when the mirror is ready, and 1 with the reason otherwise. +// Needs DATABASE_URL and WORKOS_API_KEY (the backfill and the drain read WorkOS). +// --------------------------------------------------------------------------- + +import { spawnSync } from "node:child_process"; +import { dirname, resolve } from "node:path"; +import { fileURLToPath } from "node:url"; + +import { drizzle } from "drizzle-orm/postgres-js"; +import postgres from "postgres"; + +import { + MirrorReadinessState, + describeMirrorReadiness, + readMirrorReadiness, +} from "../src/auth/mirror-readiness-store"; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const BACKFILL_SCRIPT = resolve(__dirname, "backfill-workos-mirror.ts"); +const DRAIN_SCRIPT = resolve(__dirname, "drain-workos-events.ts"); + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + console.error("DATABASE_URL is not set"); + process.exit(1); +} + +const usesLocalDatabase = + connectionString.includes("127.0.0.1") || connectionString.includes("localhost"); + +const sql = postgres(connectionString, { + max: 1, + prepare: false, + ...(usesLocalDatabase ? {} : { ssl: "require" as const }), +}); +const db = drizzle(sql); + +const log = (line: string) => console.log(`[mirror-ready] ${line}`); + +const readiness = () => readMirrorReadiness(db, new Date()); + +// The backfill and drain scripts own their own WorkOS + database wiring; +// running them as subprocesses (with this process's env) keeps that wiring +// in one place. +const runScript = (what: string, script: string) => { + if (!process.env.WORKOS_API_KEY) { + throw new Error(`WORKOS_API_KEY is not set; the mirror ${what} cannot run`); + } + const result = spawnSync("bun", ["run", script], { + stdio: "inherit", + env: process.env, + }); + if (result.status !== 0) { + throw new Error(`the mirror ${what} exited with status ${result.status ?? "unknown"}`); + } +}; + +try { + let state = await readiness(); + log(describeMirrorReadiness(state)); + + if (MirrorReadinessState.$is("BackfillPending")(state)) { + log("backfill not completed; running scripts/backfill-workos-mirror.ts"); + runScript("backfill", BACKFILL_SCRIPT); + state = await readiness(); + log(describeMirrorReadiness(state)); + } + + if (MirrorReadinessState.$is("ReconcilerStale")(state)) { + log("events stream not drained recently; running scripts/drain-workos-events.ts"); + runScript("drain", DRAIN_SCRIPT); + state = await readiness(); + log(describeMirrorReadiness(state)); + } + + if (!MirrorReadinessState.$is("Ready")(state)) { + console.error( + `[mirror-ready] the membership mirror is not ready: ${describeMirrorReadiness(state)}. ` + + "The deployed build would read membership from WorkOS on every request until it is. " + + "Check that WorkOS is reachable and the backfill has run, then rerun the deploy.", + ); + process.exit(1); + } + log("the membership mirror is ready"); +} finally { + await sql.end({ timeout: 5 }); +} diff --git a/apps/cloud/src/account/account-api.ts b/apps/cloud/src/account/account-api.ts index 1e8075c866..37b4cb26eb 100644 --- a/apps/cloud/src/account/account-api.ts +++ b/apps/cloud/src/account/account-api.ts @@ -5,10 +5,12 @@ import { AccountProvider, makeAccountApiLayer, requestScopedMiddleware, + type MemberDirectory, } from "@executor-js/api/server"; import { ApiKeyService } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; +import { MirrorReadiness } from "../auth/mirror-readiness"; import { WorkOsMirror } from "../auth/workos-mirror"; import { sessionFromSealed, type Session } from "../auth/middleware"; import { WorkOSClient } from "../auth/workos"; @@ -46,7 +48,8 @@ import { AccountCaller, workosAccountProvider } from "./workos-account-service"; // Builds the WorkOS `AccountProvider` per request, providing it to the handler. // Long-lived `WorkOSClient | AutumnService` come from the surrounding context // (Autumn provided by `makeAccountApiLive` for the seat-gate); the per-request -// `UserStoreService` is supplied by the combined `rsLive` layer. +// `UserStoreService` / `WorkOsMirror` / `MemberDirectory` are supplied by the +// combined `rsLive` layer. // `ApiKeyService.WorkOS` is built here on top of the boot `WorkOSClient`. const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvider }>()( Effect.gen(function* () { @@ -97,11 +100,15 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi * (the seat-gate) stays a residual requirement, satisfied by the app `boot`. */ export const workosAccountMiddleware = ( - rsLive: Layer.Layer, + rsLive: Layer.Layer< + DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness + >, ) => AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer; export const makeAccountApiLive = ( - rsLive: Layer.Layer, + rsLive: Layer.Layer< + DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness + >, ) => { // Cloud builds the WorkOS `AccountProvider` INSIDE the request body (so it // closes over the per-request postgres socket), so it can't be a self- diff --git a/apps/cloud/src/account/org-api-key-revoke.node.test.ts b/apps/cloud/src/account/org-api-key-revoke.node.test.ts index 10353ccecd..02228f3702 100644 --- a/apps/cloud/src/account/org-api-key-revoke.node.test.ts +++ b/apps/cloud/src/account/org-api-key-revoke.node.test.ts @@ -1,11 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; -import { AccountProvider } from "@executor-js/api/server"; +import { AccountProvider, MemberDirectory } from "@executor-js/api/server"; import { AccountError, AccountForbidden } from "@executor-js/api"; import { ApiKeyService, OrgApiKeyNotFound } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; +import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness"; import { ORG_SELECTOR_HEADER } from "../auth/organization"; import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; import { WorkOsMirror } from "../auth/workos-mirror"; @@ -63,32 +64,12 @@ const session = (accountId: string) => ({ refreshedSession: null, }); -/** Membership roles: only ADMIN carries the `admin` role slug. */ +// Membership is read from the mirror, never from WorkOS: revoke makes no +// WorkOS call at all. const stubWorkOS = Layer.succeed( WorkOSClient, new Proxy({} as WorkOSClientService, { - get: (_target, prop) => { - if (prop === "listUserMemberships") { - return (userId: string) => - Effect.succeed({ - data: [{ userId, organizationId: ORG, status: "active" }], - }); - } - if (prop === "getUserOrgMembership") { - return (organizationId: string, userId: string) => - Effect.succeed( - organizationId === ORG - ? { - id: `om_${userId}`, - userId, - organizationId, - role: { slug: userId === ADMIN ? "admin" : "member" }, - } - : null, - ); - } - return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); - }, + get: (_target, prop) => () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`), }), ); @@ -101,7 +82,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ upsertOrganization: async (org: { id: string; name: string }) => ({ ...org, slug: org.id, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, @@ -110,7 +91,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ id, name: `Org ${id}`, slug: id, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, @@ -119,11 +100,12 @@ const stubUsers = Layer.succeed(UserStoreService)({ id: slug, name: `Org ${slug}`, slug, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, }), + markOrganizationDeleted: async () => null, deleteOrganizationCascade: async () => {}, }), ), @@ -147,6 +129,39 @@ const stubMirror = Layer.succeed(WorkOsMirror)({ organizationBackfilledAt: () => Effect.die("revoke does not report seats"), }); +// The mirror as the directory reads it: both are active members of ORG, and +// only ADMIN carries the `admin` role. Revoke reads the caller's membership +// (the org check and the admin gate) and nothing else. +// The mirror is READY in these tests (backfill complete, reconciler caught +// up), so membership is read from the stubbed directory, never from WorkOS. +const stubReadiness = Layer.succeed(MirrorReadiness)({ + state: () => Effect.succeed(MirrorReadinessState.Ready()), +}); + +const stubDirectory = Layer.succeed(MemberDirectory)({ + membership: (accountId, organizationId) => + Effect.succeed( + organizationId === ORG + ? { + accountId, + membershipId: `om_${accountId}`, + organizationId, + email: null, + name: null, + avatarUrl: null, + role: accountId === ADMIN ? "admin" : "member", + status: "active" as const, + lastActiveAt: null, + } + : null, + ), + membershipById: () => Effect.die("revoke does not look up by membership id"), + membershipsOf: () => Effect.die("revoke does not list the caller's memberships"), + members: () => Effect.die("revoke does not list members"), + membersById: () => Effect.die("revoke does not batch members"), + findByEmail: () => Effect.die("revoke does not resolve emails"), +}); + const stubAutumn = Layer.succeed(AutumnService)({ use: () => Effect.die("revoke does not touch billing"), ensureCustomer: () => Effect.die("revoke does not touch billing"), @@ -185,6 +200,8 @@ const providerWith = (accountId: string) => { stubWorkOS, stubUsers, stubMirror, + stubDirectory, + stubReadiness, stubApiKeys, stubAutumn, Layer.succeed(AccountCaller)({ session: session(accountId) }), diff --git a/apps/cloud/src/account/workos-account-service.ts b/apps/cloud/src/account/workos-account-service.ts index beb9ef86a3..e7abd95aa1 100644 --- a/apps/cloud/src/account/workos-account-service.ts +++ b/apps/cloud/src/account/workos-account-service.ts @@ -1,6 +1,6 @@ import { Context, Effect, Layer } from "effect"; -import { AccountProvider, type AccountHeaders } from "@executor-js/api/server"; +import { AccountProvider, MemberDirectory, type AccountHeaders } from "@executor-js/api/server"; import { AccountError, AccountForbidden, @@ -11,7 +11,9 @@ import { import { ApiKeyService } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; import type { Session } from "../auth/middleware"; +import { MirrorReadiness } from "../auth/mirror-readiness"; import { WorkOSClient } from "../auth/workos"; +import { ensureOrganizationBackfilled, mirrorInvitedMember } from "../auth/mirror-feeders"; import { WorkOsMirror, mirrorMembershipFromWorkOs } from "../auth/workos-mirror"; import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; import { AutumnService } from "../extensions/billing/service"; @@ -66,7 +68,14 @@ const toAccountError = () => Effect.fail(new AccountError({ message: "Account re export const workosAccountProvider: Layer.Layer< AccountProvider, never, - WorkOSClient | UserStoreService | WorkOsMirror | ApiKeyService | AutumnService | AccountCaller + | WorkOSClient + | UserStoreService + | WorkOsMirror + | MemberDirectory + | MirrorReadiness + | ApiKeyService + | AutumnService + | AccountCaller > = Layer.effect(AccountProvider)( Effect.gen(function* () { const workos = yield* WorkOSClient; @@ -77,6 +86,10 @@ export const workosAccountProvider: Layer.Layer< // written through to the local mirror so the member list and the seat // count read the change without waiting for the Events reconciler. const mirror = yield* WorkOsMirror; + // Membership READS come from the mirror through the shared directory: the + // admin gate, the member list and the seat count are one local query + // each, never a WorkOS read. + const directory = yield* MemberDirectory; // The caller, resolved once per request by the cookie-only session // middleware (account-api.ts) — the same credential `SessionAuthLive` @@ -85,10 +98,18 @@ export const workosAccountProvider: Layer.Layer< const caller = yield* AccountCaller; // Capture the resolved service context once so the method bodies — which - // call `authorizeOrganization` (yields `WorkOSClient` + `UserStoreService`) — - // can be erased to `R = never`, as the neutral AccountProvider shape - // requires. Provided per method below. - const ctx = yield* Effect.context(); + // call `authorizeOrganization` (yields `MemberDirectory` + `UserStoreService` + // + `WorkOSClient`), the mirror feeders, and the seat reporter — can be + // erased to `R = never`, as the neutral AccountProvider shape requires. + // Provided per method below. + const ctx = yield* Effect.context< + | WorkOSClient + | UserStoreService + | AutumnService + | MemberDirectory + | MirrorReadiness + | WorkOsMirror + >(); // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly // as the old inline `requireSession` did. @@ -102,10 +123,11 @@ export const workosAccountProvider: Layer.Layer< // org is a browser-global pinned to whichever org WorkOS last touched, so // falling back to it scopes a multi-org user's request to the WRONG org // (see workos-auth-provider.resolveSessionPrincipal). Membership is - // re-checked live, so the header is a selector, not a trust boundary — - // and two browser tabs on different orgs each send their own header, so + // re-checked against the mirror, so the header is a selector, not a trust + // boundary — and two browser tabs on different orgs each send their own header, so // they stay independent (see organization.ts). Yields the session + - // resolved org, or AccountNoOrganization. + // resolved org (carrying the caller's `memberRole` from that same + // membership read), or AccountNoOrganization. const requireOrganization = (headers: AccountHeaders) => Effect.gen(function* () { const session = yield* requireSession(); @@ -122,30 +144,41 @@ export const workosAccountProvider: Layer.Layer< }); // Mirror of org/handlers `requireAdmin`, but scoped to the resolved org. - const requireAdmin = (accountId: string, organizationId: string) => - Effect.gen(function* () { - const membership = yield* workos - .getUserOrgMembership(organizationId, accountId) - .pipe(Effect.catchTag("WorkOSError", toAccountError)); - if (!membership || membership.role?.slug !== "admin") { - return yield* new AccountForbidden(); - } - }); - - // Mirror of org/handlers `assertMembershipInSessionOrg` — ownership check so - // an admin can't mutate a membership id from another org. + // `authorizeOrganization` already read the caller's mirrored membership, + // required it to be ACTIVE, and normalized its role into `memberRole` — + // so the gate is that one value, not a second read of the same row. A + // pending admin invite is not an admin, and a member removed or demoted + // moments ago is denied as soon as the write-through or the Events + // reconciler has landed the change. + const requireAdmin = (org: { readonly memberRole: "admin" | "member" }) => + org.memberRole === "admin" ? Effect.void : Effect.fail(new AccountForbidden()); + + // Ownership check so an admin can't mutate a membership id from another + // org: the id must name a row the mirror holds for THIS org (any status — + // revoking a pending invite is a delete too). One point read on the + // membership id, scoped to the org: the member list the admin acted from + // is read from the same mirror, so every id it shows resolves here; a + // foreign or unknown id does not. A read failure is the same 500 as the + // admin gate's, never a refusal dressed up as "not yours". const assertMembershipInOrg = (organizationId: string, membershipId: string) => Effect.gen(function* () { - const membership = yield* workos - .getOrgMembership(membershipId) - .pipe(Effect.catchCause(() => Effect.succeed(null))); - if (!membership || membership.organizationId !== organizationId) { + const membership = yield* directory + .membershipById(organizationId, membershipId) + .pipe(Effect.catchTag("MemberDirectoryError", toAccountError)); + if (!membership) { return yield* new AccountForbidden(); } return membership; }); - // Mirror of org/handlers `getMemberSeats` — live seat usage from WorkOS. + // Seat usage: memberships from the local directory (active + pending, the + // `members` default), pending invitations live from WorkOS — invitations + // are not mirrored. The directory is trusted for a COUNT only once this + // organization's membership list has been scanned from WorkOS in full: + // login and write-through record single memberships, so an organization + // the one-off backfill did not cover holds a partial list, and counting + // it would admit invitations past the plan limit. The scan runs here, + // once, when the organization's mark is missing. const getMemberSeats = (organizationId: string) => Effect.gen(function* () { const customer = yield* autumn.use((client) => @@ -154,15 +187,16 @@ export const workosAccountProvider: Layer.Layer< const planId = selectActiveMemberLimitPlan(customer.subscriptions); const limit = getMemberLimitForPlan(planId); - // `listOrgMembers` returns active members AND pending memberships (an - // invited user shows up as status "pending"); `listPendingInvitations` + yield* ensureOrganizationBackfilled(organizationId).pipe(Effect.provideContext(ctx)); + // The directory reports active members AND pending memberships (an + // invited user is mirrored with status "pending"); `listPendingInvitations` // returns the same invited users again. `countSeatsUsed` dedupes them // so an outstanding invite is not counted twice. - const memberships = yield* workos.listOrgMembers(organizationId); + const memberships = yield* directory.members(organizationId); const invitations = yield* workos.listPendingInvitations(organizationId); return { - used: countSeatsUsed(memberships.data, invitations.data.length), + used: countSeatsUsed(memberships, invitations.data.length), granted: limit ?? 0, unlimited: limit === null, }; @@ -270,8 +304,8 @@ export const workosAccountProvider: Layer.Layer< // mint for themselves. listOrgApiKeys: (headers) => Effect.gen(function* () { - const { session, org } = yield* requireOrganization(headers); - yield* requireAdmin(session.accountId, org.id); + const { org } = yield* requireOrganization(headers); + yield* requireAdmin(org); const keys = yield* apiKeys .listOrgKeys({ organizationId: org.id }) .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); @@ -280,8 +314,8 @@ export const workosAccountProvider: Layer.Layer< createOrgApiKey: (headers, name) => Effect.gen(function* () { - const { session, org } = yield* requireOrganization(headers); - yield* requireAdmin(session.accountId, org.id); + const { org } = yield* requireOrganization(headers); + yield* requireAdmin(org); const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH); if (!trimmed) { return yield* new AccountError({ @@ -302,8 +336,8 @@ export const workosAccountProvider: Layer.Layer< // silent success and not a 500. revokeOrgApiKey: (headers, apiKeyId) => Effect.gen(function* () { - const { session, org } = yield* requireOrganization(headers); - yield* requireAdmin(session.accountId, org.id); + const { org } = yield* requireOrganization(headers); + yield* requireAdmin(org); yield* apiKeys.revokeOrgKey({ organizationId: org.id, keyId: apiKeyId }).pipe( Effect.catchTag("ApiKeyManagementError", toAccountError), Effect.catchTag("OrgApiKeyNotFound", () => @@ -328,29 +362,23 @@ export const workosAccountProvider: Layer.Layer< Effect.catchCause(() => Effect.succeed({ used: 0, granted: 0, unlimited: false })), ); - const memberships = yield* workos - .listOrgMembers(org.id) - .pipe(Effect.catchTag("WorkOSError", toAccountError)); - - const members = yield* Effect.all( - memberships.data.map((m) => - Effect.gen(function* () { - const user = yield* workos.getUser(m.userId); - return { - id: m.id, - userId: m.userId, - email: user.email, - name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null, - avatarUrl: user.profilePictureUrl ?? null, - role: m.role?.slug ?? "member", - status: m.status, - lastActiveAt: user.lastSignInAt ?? null, - isCurrentUser: m.userId === session.accountId, - }; - }), - ), - { concurrency: 5 }, - ).pipe(Effect.catchTag("WorkOSError", toAccountError)); + // One directory read (active + pending, ordered by email) with the + // profile already joined — no per-member WorkOS user fetch. + const directoryMembers = yield* directory + .members(org.id) + .pipe(Effect.catchTag("MemberDirectoryError", toAccountError)); + + const members = directoryMembers.map((m) => ({ + id: m.membershipId, + userId: m.accountId, + email: m.email, + name: m.name, + avatarUrl: m.avatarUrl, + role: m.role, + status: m.status, + lastActiveAt: m.lastActiveAt === null ? null : new Date(m.lastActiveAt).toISOString(), + isCurrentUser: m.accountId === session.accountId, + })); return { members, seats }; }), @@ -368,8 +396,8 @@ export const workosAccountProvider: Layer.Layer< inviteMember: (headers, body) => Effect.gen(function* () { - const { session, org } = yield* requireOrganization(headers); - yield* requireAdmin(session.accountId, org.id); + const { org } = yield* requireOrganization(headers); + yield* requireAdmin(org); yield* reserveMemberSlot(org.id); const invitation = yield* workos .sendInvitation({ @@ -378,13 +406,30 @@ export const workosAccountProvider: Layer.Layer< ...(body.roleSlug ? { roleSlug: body.roleSlug } : {}), }) .pipe(Effect.catchTag("WorkOSError", toAccountError)); + // Write-through: WorkOS creates a PENDING membership for the invitee + // alongside the invitation, and the member list (the "Invited" row + // and its revoke button) reads memberships from the mirror only, so + // the row must land now — the Events reconciler is not on this path. + const mirrored = yield* mirrorInvitedMember(org.id, invitation.email).pipe( + Effect.provideContext(ctx), + Effect.catchTags({ + WorkOSError: toAccountError, + WorkOsMirrorError: toAccountError, + }), + ); + if (!mirrored) { + yield* Effect.logWarning("inviteMember: no pending membership for the invitee yet", { + organizationId: org.id, + invitationId: invitation.id, + }); + } return { id: invitation.id, email: invitation.email }; }), removeMember: (headers, membershipId) => Effect.gen(function* () { - const { session, org } = yield* requireOrganization(headers); - yield* requireAdmin(session.accountId, org.id); + const { org } = yield* requireOrganization(headers); + yield* requireAdmin(org); const membership = yield* assertMembershipInOrg(org.id, membershipId); yield* workos .deleteOrgMembership(membershipId) @@ -394,18 +439,18 @@ export const workosAccountProvider: Layer.Layer< // delete — or a role change issued before it and delivered after // — is refused however it is stamped, while a replacement // membership WorkOS creates for the same member (a new id) is - // not. No WorkOS instant is in hand (WorkOS answers a delete with - // no time): the row keeps its own stamp, never the local clock, - // which read after WorkOS answered could post-date that - // replacement. + // not. No WorkOS instant is in hand (`null`: WorkOS answers a + // delete with no time, and the row was read from the mirror): the + // row keeps its own stamp, never the local clock, which read + // after WorkOS answered could post-date that replacement. yield* mirror .deleteMembership( { id: membershipId, - accountId: membership.userId, + accountId: membership.accountId, organizationId: membership.organizationId, }, - new Date(membership.updatedAt), + null, ) .pipe(Effect.catchTag("WorkOsMirrorError", toAccountError)); yield* forkReportMemberSeats(org.id).pipe(Effect.provideContext(ctx)); @@ -414,8 +459,8 @@ export const workosAccountProvider: Layer.Layer< updateMemberRole: (headers, membershipId, roleSlug) => Effect.gen(function* () { - const { session, org } = yield* requireOrganization(headers); - yield* requireAdmin(session.accountId, org.id); + const { org } = yield* requireOrganization(headers); + yield* requireAdmin(org); yield* assertMembershipInOrg(org.id, membershipId); const updated = yield* workos .updateOrgMembershipRole(membershipId, roleSlug) @@ -428,8 +473,8 @@ export const workosAccountProvider: Layer.Layer< updateOrgName: (headers, name) => Effect.gen(function* () { - const { session, org } = yield* requireOrganization(headers); - yield* requireAdmin(session.accountId, org.id); + const { org } = yield* requireOrganization(headers); + yield* requireAdmin(org); const updated = yield* workos .updateOrganization(org.id, name) .pipe(Effect.catchTag("WorkOSError", toAccountError)); diff --git a/apps/cloud/src/admin/admin-users-api.node.test.ts b/apps/cloud/src/admin/admin-users-api.node.test.ts new file mode 100644 index 0000000000..b29390be1a --- /dev/null +++ b/apps/cloud/src/admin/admin-users-api.node.test.ts @@ -0,0 +1,202 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { AdminUsersForbidden } from "@executor-js/api"; +import { MemberDirectory, type DirectoryMember } from "@executor-js/api/server"; + +import { ApiKeyService } from "../auth/api-keys"; +import { UserStoreService } from "../auth/context"; +import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness"; +import { ORG_SELECTOR_HEADER } from "../auth/organization"; +import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; +import { authorizeTenant } from "./admin-users-api"; + +// --------------------------------------------------------------------------- +// The admin plane's SESSION credential: an admin member of the selected org, +// resolved against the membership mirror through the shared `MemberDirectory`. +// The org-key credential is pinned in `auth/org-api-key-auth.node.test.ts`; +// this file pins the session branch of `authorizeTenant`: +// - an ACTIVE `admin` membership yields the tenant id +// - an active plain member is refused +// - a pending admin invite is refused (not an admin until accepted) +// - no WorkOS call is made past session authentication +// --------------------------------------------------------------------------- + +const ORG = "org_tenant"; +const createdAt = new Date("2026-01-01T00:00:00.000Z"); + +const mirrored = ( + accountId: string, + overrides: Partial = {}, +): DirectoryMember => ({ + accountId, + membershipId: `om_${accountId}`, + organizationId: ORG, + email: null, + name: null, + avatarUrl: null, + role: "member", + status: "active", + lastActiveAt: null, + ...overrides, +}); + +// The mirror as the directory reads it for ORG. +const memberships = new Map([ + ["user_admin", mirrored("user_admin", { role: "admin" })], + ["user_member", mirrored("user_member")], + ["user_invited_admin", mirrored("user_invited_admin", { role: "admin", status: "pending" })], +]); + +// The mirror is READY in these tests (backfill complete, reconciler caught +// up), so membership is read from the stubbed directory, never from WorkOS. +const stubReadiness = Layer.succeed(MirrorReadiness)({ + state: () => Effect.succeed(MirrorReadinessState.Ready()), +}); + +const stubDirectory = Layer.succeed(MemberDirectory)({ + membership: (accountId, organizationId) => + Effect.succeed(organizationId === ORG ? (memberships.get(accountId) ?? null) : null), + membershipById: () => Effect.die("tenant authorization does not look up by membership id"), + membershipsOf: () => Effect.die("tenant authorization reads one membership, not the list"), + members: () => Effect.die("tenant authorization does not list members"), + membersById: () => Effect.die("tenant authorization does not batch members"), + findByEmail: () => Effect.die("tenant authorization does not resolve emails"), +}); + +// No Authorization header in these tests: the api-key path falls through to +// the session path without validating anything. +const stubApiKeys = Layer.succeed(ApiKeyService)({ + validate: () => Effect.die("no bearer credential is presented"), + listUserKeys: () => Effect.die("tenant authorization does not list keys"), + createUserKey: () => Effect.die("tenant authorization does not create keys"), + revokeUserKey: () => Effect.die("tenant authorization does not revoke keys"), + listOrgKeys: () => Effect.die("tenant authorization does not list keys"), + createOrgKey: () => Effect.die("tenant authorization does not create keys"), + revokeOrgKey: () => Effect.die("tenant authorization does not revoke keys"), +}); + +// The mirror's account row as `ensureAccount` mints it: id only, profile +// columns unfilled until a WorkOS user payload arrives. +const bareAccount = (id: string) => ({ + id, + email: null, + firstName: null, + lastName: null, + avatarUrl: null, + workosUpdatedAt: null, + lastSignInAt: null, + createdAt, +}); + +// The selector is an org id, so only `getOrganization` is reached; the org +// row is already mirrored. +const stubUsers = Layer.succeed(UserStoreService)({ + use: (_op, fn) => + Effect.promise(() => + fn({ + ensureAccount: async (id: string) => bareAccount(id), + getAccount: async (id: string) => bareAccount(id), + upsertOrganization: async (org: { id: string; name: string }) => ({ + ...org, + slug: org.id, + backfilledAt: createdAt, + deletedAt: null, + workosUpdatedAt: null, + createdAt, + }), + getOrganization: async (id: string) => ({ + id, + name: `Org ${id}`, + slug: id, + backfilledAt: createdAt, + deletedAt: null, + workosUpdatedAt: null, + createdAt, + }), + getOrganizationBySlug: async (slug: string) => ({ + id: slug, + name: `Org ${slug}`, + slug, + backfilledAt: createdAt, + deletedAt: null, + workosUpdatedAt: null, + createdAt, + }), + markOrganizationDeleted: async () => null, + deleteOrganizationCascade: async () => {}, + }), + ), +}); + +// Authorization scans an organization the backfill never covered before it +// reads the mirror (`auth/organization.ts`); every org row above is marked +// backfilled, so the scan is never reached and the mirror is never written. +const stubMirror = Layer.succeed( + WorkOsMirror, + new Proxy({} as WorkOsMirrorShape, { + get: (_target, prop) => () => Effect.die(`unexpected WorkOsMirror.${String(prop)} call`), + }), +); + +// Only session authentication is served; membership is read from the mirror, +// so any other WorkOS call fails the test. +const stubWorkOS = (userId: string) => + Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => { + if (prop === "authenticateRequest") { + return () => + Effect.succeed({ userId, email: `${userId}@placeholder.test`, organizationId: null }); + } + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); + }, + }), + ); + +const authorizeAs = (userId: string) => + authorizeTenant( + new Request("https://admin.invalid", { + headers: { cookie: "wos-session=sealed", [ORG_SELECTOR_HEADER]: ORG }, + }), + ).pipe( + Effect.provide( + Layer.mergeAll( + stubDirectory, + stubApiKeys, + stubUsers, + stubWorkOS(userId), + stubMirror, + stubReadiness, + ), + ), + ); + +describe("authorizeTenant · admin session", () => { + it.effect("an active admin resolves the selected org as the tenant", () => + Effect.gen(function* () { + const tenant = yield* authorizeAs("user_admin"); + expect(tenant).toBe(ORG); + }), + ); + + it.effect("an active plain member is forbidden", () => + Effect.gen(function* () { + const error = yield* Effect.flip(authorizeAs("user_member")); + expect(error, "this plane serves the whole tenant; a member is not enough").toBeInstanceOf( + AdminUsersForbidden, + ); + }), + ); + + it.effect("a pending admin invite is forbidden", () => + Effect.gen(function* () { + const error = yield* Effect.flip(authorizeAs("user_invited_admin")); + expect(error, "an admin role that is still pending is not an admin").toBeInstanceOf( + AdminUsersForbidden, + ); + }), + ); +}); diff --git a/apps/cloud/src/admin/admin-users-api.ts b/apps/cloud/src/admin/admin-users-api.ts index cae66fcb5f..a6ade66a0a 100644 --- a/apps/cloud/src/admin/admin-users-api.ts +++ b/apps/cloud/src/admin/admin-users-api.ts @@ -8,8 +8,9 @@ // validated it and reported which org owns it, and there is no member // behind it to check membership for. This is the machine credential // (a customer's backend calling us). -// 2. an admin SESSION member -> the console. Requires a live `getUserOrgMembership` -// whose role slug is `admin` AND whose status is `active`, matching the +// 2. an admin SESSION member -> the console. Requires the caller's mirrored +// membership (the shared `MemberDirectory` over the local membership +// mirror) to carry the `admin` role AND `active` status, matching the // strictest existing cloud guard (`auth/handlers.ts`'s org-delete check) — // a pending admin invite is not an admin. // A plain member session, or a USER-scoped api key, is refused: both name one @@ -25,28 +26,24 @@ // every query by that tenant. // --------------------------------------------------------------------------- -import { env } from "cloudflare:workers"; import { HttpRouter } from "effect/unstable/http"; -import { Context, Effect, Layer, Option } from "effect"; +import { Effect, Layer } from "effect"; import { AdminUsersProvider, DbProvider, HostConfig, + MemberDirectory, PluginsProvider, + adminUserDirectoryFromMembers, getAdminUser, listAdminUserConnections, listAdminUsers, listAdminUsersWithConnections, makeAdminUsersApiLayer, makePlatformExecutor, - normalizeAdminUserEmail, platformViewOf, requestScopedMiddleware, - type AdminEmailResolver, - type AdminIdentityDirectory, - type AdminUserDirectory, - type AdminUserIdentity, type AdminUsersHeaders, } from "@executor-js/api/server"; import { @@ -59,6 +56,8 @@ import type { Executor } from "@executor-js/sdk"; import { ApiKeyService } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; +import { MirrorReadiness } from "../auth/mirror-readiness"; +import { WorkOsMirror } from "../auth/workos-mirror"; import { isPlatformAuth, resolveBearerAuth } from "../auth/workos-auth-provider"; import { orgSelectorFromRequest, authorizeOrganizationSelector } from "../auth/organization"; import { WorkOSClient } from "../auth/workos"; @@ -71,13 +70,14 @@ import { CloudExecutionSeamsLayer } from "../engine/execution-stack"; * Returns only the organization id: nothing downstream needs to know WHICH of * the two credentials got the caller here, and keeping the acting member out of * the return value means no admin read can accidentally become subject-scoped. + * Exported for its test only. */ -const authorizeTenant = ( +export const authorizeTenant = ( request: Request, ): Effect.Effect< string, AdminUsersUnauthorized | AdminUsersForbidden, - WorkOSClient | ApiKeyService | UserStoreService + WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror > => Effect.gen(function* () { // (1) The bearer path. `resolveBearerAuth` (not `resolveApiKeyPrincipal`, @@ -96,7 +96,8 @@ const authorizeTenant = ( return yield* new AdminUsersForbidden(); } - // (2) The session path: a live admin membership in the selected org. + // (2) The session path: an active admin membership in the selected org, + // read from the mirror. const workos = yield* WorkOSClient; const session = yield* workos .authenticateRequest(request) @@ -105,141 +106,19 @@ const authorizeTenant = ( const selector = orgSelectorFromRequest(request) ?? session.organizationId; if (!selector) return yield* new AdminUsersForbidden(); - // Re-checks live membership, so the org selector header can only ever name - // an org the caller already belongs to. + // Re-checks membership against the mirror, so the org selector header can + // only ever name an org the caller already belongs to. That read requires + // an ACTIVE membership and reports its role as `memberRole`, so a pending + // admin invite never resolves and the admin gate is that one value — not + // a second read of the same row. const org = yield* authorizeOrganizationSelector(session.userId, selector).pipe( Effect.catchCause(() => Effect.succeed(null)), ); if (!org) return yield* new AdminUsersForbidden(); - - const membership = yield* workos - .getUserOrgMembership(org.id, session.userId) - .pipe(Effect.catchCause(() => Effect.succeed(null))); - // A pending admin invite is not an active admin — require both. - if (!membership || membership.status !== "active" || membership.role?.slug !== "admin") { - return yield* new AdminUsersForbidden(); - } + if (org.memberRole !== "admin") return yield* new AdminUsersForbidden(); return org.id; }); -/** - * How many user-detail reads run at once. Matches the account plane's own - * member listing (`workos-account-service.ts`), which fans out the same way for - * the same reason. - */ -const IDENTITY_CONCURRENCY = 5; - -/** - * Cloud's member directory: `externalId` → email/name. - * - * THE JOIN KEY is the membership's `userId` — the WorkOS `user_...` that - * `workos-auth-provider.ts` binds as `accountId` on every credential path, and - * therefore what the subject table records in `external_id`. The membership's - * own `id` is an `om_...` row id and joins to nothing. - * - * WHY THIS IS TWO CALLS AND NOT ONE. The membership list is read once per - * request and is the authority on who belongs to the org, but WorkOS's - * `listOrganizationMemberships` carries no user detail and offers no - * include/expand — email and name only exist on the user resource. The SDK does - * expose a batched `listUsers({ organizationId })`, but the pinned - * `@executor-js/emulate` WorkOS emulator serves only `GET - * /user_management/users/:id`, so taking that path would leave every cloud e2e - * user unnamed. So: ONE membership read per request, then user detail fetched - * only for the ids ON THIS PAGE — never for the whole org, and never once per - * row of some larger list. An id that is not an active/pending member is not - * fetched at all and reports absent identity, which is the honest answer for a - * member who left while their connections remain. - */ -const identityDirectory = - (organizationId: string, context: Context.Context): AdminIdentityDirectory => - (externalIds) => - Effect.gen(function* () { - const workos = yield* WorkOSClient; - const memberships = yield* workos.listOrgMembers(organizationId); - const wanted = new Set(externalIds); - const memberIds = memberships.data - .map((membership) => membership.userId) - .filter((userId) => wanted.has(userId)); - - const resolved = yield* Effect.all( - memberIds.map((userId) => - workos.getUser(userId).pipe( - Effect.map( - (user) => - [ - userId, - { - email: user.email, - displayName: [user.firstName, user.lastName].filter(Boolean).join(" ") || null, - }, - ] as const, - ), - // One unreadable user must not cost the whole page its names. - Effect.catchCause(() => Effect.succeed(null)), - ), - ), - { concurrency: IDENTITY_CONCURRENCY }, - ); - - const identities = new Map(); - for (const entry of resolved) if (entry) identities.set(entry[0], entry[1]); - return identities; - }).pipe(Effect.provideContext(context)); - -/** - * Cloud's REVERSE directory lookup: email → the WorkOS `user_...` id. - * - * Production asks WorkOS for the email AND organization in one request. Both - * filters matter: email makes the lookup indexed rather than one `getUser` - * request per member, while organization keeps the reverse lookup bound to the - * same tenant as the platform view. - * - * The pinned `@executor-js/emulate` WorkOS emulator has no list-users route. - * `WORKOS_API_URL` is the explicit test/dev emulator override, so that path - * retains the membership scan until the emulator supports the production - * query. The fallback still starts from the tenant's membership list and can - * never return a user from another organization. - * - * CASING: WorkOS preserves whatever casing an email was created with (and the - * emulator compares byte-exact), so the directory value is normalized here - * before comparison, against an argument the seam already normalized. - */ -export const emailResolver = - (organizationId: string, context: Context.Context): AdminEmailResolver => - (email) => - Effect.gen(function* () { - const workos = yield* WorkOSClient; - - if (!env.WORKOS_API_URL) { - const users = yield* workos.listUsers({ email, organizationId }); - return users.data[0]?.id ?? null; - } - - const memberships = yield* workos.listOrgMembers(organizationId); - const userIds = memberships.data.map((membership) => membership.userId); - - // Emulator compatibility only. Short-circuit once the normalized email - // matches so the fallback makes as few unsupported-detail reads as it can. - const match = yield* Effect.findFirst(userIds, (userId) => - workos.getUser(userId).pipe( - Effect.map((user) => normalizeAdminUserEmail(user.email ?? "") === email), - // One unreadable user must not fail the whole lookup — it simply - // cannot be the match. - Effect.catchCause(() => Effect.succeed(false)), - ), - ); - return Option.getOrNull(match); - }).pipe(Effect.provideContext(context)); - -/** Both directions of cloud's directory, built once per authorized request. */ -const userDirectory = ( - organizationId: string, - context: Context.Context, -): AdminUserDirectory => ({ - identities: identityDirectory(organizationId, context), - resolveEmail: emailResolver(organizationId, context), -}); - /** * Authorize, then run `body` against the tenant's platform view. * @@ -255,7 +134,15 @@ const withPlatformView = => Effect.gen(function* () { const organizationId = yield* authorizeTenant( @@ -264,8 +151,9 @@ const withPlatformView = new AdminUsersError({ message: "Failed to open the platform view" })), ); - // The authorized tenant is handed to the body so an identity join reads the - // SAME org the reads are scoped to — never one named by client input. + // The authorized tenant is handed to the body so the directory reads the + // SAME org the storage reads are scoped to — never one named by client + // input. return yield* Effect.ensuring( body(executor, organizationId), executor.close().pipe(Effect.ignore), @@ -275,22 +163,50 @@ const withPlatformView = = Layer.effect(AdminUsersProvider)( Effect.gen(function* () { const context = yield* Effect.context< - WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig + | WorkOSClient + | ApiKeyService + | UserStoreService + | MemberDirectory + | MirrorReadiness + | WorkOsMirror + | DbProvider + | PluginsProvider + | HostConfig >(); + const directory = yield* MemberDirectory; + // The authorized tenant is what scopes the directory, so every read below + // asks the same org the platform view was opened for. + const userDirectory = (organizationId: string) => + adminUserDirectoryFromMembers(directory, organizationId); return AdminUsersProvider.of({ listUsers: (headers, options) => withPlatformView(headers, (executor, organizationId) => platformViewOf(executor).pipe( Effect.flatMap((admin) => - listAdminUsers(admin, options, userDirectory(organizationId, context)), + listAdminUsers(admin, options, userDirectory(organizationId)), ), ), ).pipe(Effect.provideContext(context)), @@ -298,7 +214,7 @@ export const workosAdminUsersProvider: Layer.Layer< withPlatformView(headers, (executor, organizationId) => platformViewOf(executor).pipe( Effect.flatMap((admin) => - listAdminUsersWithConnections(admin, options, userDirectory(organizationId, context)), + listAdminUsersWithConnections(admin, options, userDirectory(organizationId)), ), ), ).pipe(Effect.provideContext(context)), @@ -312,7 +228,7 @@ export const workosAdminUsersProvider: Layer.Layer< withPlatformView(headers, (executor, organizationId) => platformViewOf(executor).pipe( Effect.flatMap((admin) => - getAdminUser(admin, identifier, userDirectory(organizationId, context)), + getAdminUser(admin, identifier, userDirectory(organizationId)), ), ), ).pipe(Effect.provideContext(context)), @@ -322,8 +238,9 @@ export const workosAdminUsersProvider: Layer.Layer< // Builds the provider per request, providing it to the handlers. Long-lived // `WorkOSClient | ApiKeyService` come from the surrounding boot context; the -// per-request `DbService`/`UserStoreService` (and the execution seams built -// over them) are supplied by the combined `requestScopedMiddleware`. +// per-request `DbService`/`UserStoreService`/`MemberDirectory` (and the +// execution seams built over them) are supplied by the combined +// `requestScopedMiddleware`. const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()( Effect.gen(function* () { const longLived = yield* Effect.context(); @@ -348,7 +265,9 @@ const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUser * `/api` prefix as the rest of the cloud router. */ export const makeCloudAdminUsersRoutes = ( - rsLive: Layer.Layer, + rsLive: Layer.Layer< + DbService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror + >, options: Parameters[1] = {}, ) => makeAdminUsersApiLayer( diff --git a/apps/cloud/src/admin/admin-users-email.node.test.ts b/apps/cloud/src/admin/admin-users-email.node.test.ts deleted file mode 100644 index 463c83df09..0000000000 --- a/apps/cloud/src/admin/admin-users-email.node.test.ts +++ /dev/null @@ -1,127 +0,0 @@ -import { env } from "cloudflare:workers"; -import { expect, it } from "@effect/vitest"; -import { Data, Effect, Layer } from "effect"; - -import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; -import { emailResolver } from "./admin-users-api"; - -// Cloud's REVERSE directory lookup: email -> the WorkOS `user_...` id that the -// subject table records in `external_id`. Production resolves it with one -// tenant-scoped list-users query. The WorkOS emulator lacks that route, so -// tests/dev retain the membership-backed scan exercised below. - -const ORG = "org_placeholder"; -const OTHER_ORG = "org_other"; - -class WorkOSUnavailable extends Data.TaggedError("WorkOSUnavailable")<{ - readonly userId: string; -}> {} - -const DIRECTORY = [ - // Same email in another tenant must never win either lookup path. - { id: "user_foreign", email: "ada@placeholder.test", organizationId: OTHER_ORG }, - // WorkOS preserves submitted casing, while the resolver seam is normalized. - { id: "user_ada", email: "Ada@Placeholder.test", organizationId: ORG }, - { id: "user_grace", email: "grace@placeholder.test", organizationId: ORG }, - { id: "user_nameless", email: null, organizationId: ORG }, -] as const; - -const stubWorkOS = (calls: string[], unreadableUserIds: ReadonlySet) => - Layer.succeed( - WorkOSClient, - new Proxy({} as WorkOSClientService, { - get: (_target, prop) => { - if (prop === "listUsers") { - return (params: { email: string; organizationId: string }) => { - calls.push(`listUsers:${params.organizationId}:${params.email}`); - return Effect.succeed({ - data: DIRECTORY.filter( - (user) => - user.organizationId === params.organizationId && - user.email?.toLowerCase() === params.email, - ), - }); - }; - } - if (prop === "listOrgMembers") { - return (organizationId: string) => { - calls.push(`listOrgMembers:${organizationId}`); - return Effect.succeed({ - data: DIRECTORY.filter((user) => user.organizationId === organizationId).map( - (user) => ({ userId: user.id, organizationId }), - ), - }); - }; - } - if (prop === "getUser") { - return (userId: string) => { - calls.push(`getUser:${userId}`); - if (unreadableUserIds.has(userId)) { - return Effect.fail(new WorkOSUnavailable({ userId })); - } - const user = DIRECTORY.find((candidate) => candidate.id === userId); - if (!user) return Effect.die(`unexpected user ${userId}`); - return Effect.succeed(user); - }; - } - return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); - }, - }), - ); - -const resolve = ( - email: string, - calls: string[], - emulator = false, - unreadableUserIds: ReadonlySet = new Set(), -) => { - const previousApiUrl = env.WORKOS_API_URL; - return Effect.gen(function* () { - yield* Effect.sync(() => - Object.assign(env, { - WORKOS_API_URL: emulator ? "http://workos-emulator.invalid" : undefined, - }), - ); - const context = yield* Effect.context(); - return yield* emailResolver(ORG, context)(email); - }).pipe( - Effect.provide(stubWorkOS(calls, unreadableUserIds)), - Effect.ensuring(Effect.sync(() => Object.assign(env, { WORKOS_API_URL: previousApiUrl }))), - ); -}; - -it.effect("resolves an email with one tenant-scoped WorkOS query", () => - Effect.gen(function* () { - const calls: string[] = []; - expect(yield* resolve("ada@placeholder.test", calls)).toBe("user_ada"); - expect(calls).toEqual([`listUsers:${ORG}:ada@placeholder.test`]); - }), -); - -it.effect("returns null from one query when the organization has no matching email", () => - Effect.gen(function* () { - const calls: string[] = []; - expect(yield* resolve("nobody@placeholder.test", calls)).toBeNull(); - expect(calls).toEqual([`listUsers:${ORG}:nobody@placeholder.test`]); - }), -); - -it.effect("keeps the emulator fallback tenant-scoped and case-insensitive", () => - Effect.gen(function* () { - const calls: string[] = []; - expect(yield* resolve("ada@placeholder.test", calls, true)).toBe("user_ada"); - expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada"]); - expect(calls).not.toContain("getUser:user_foreign"); - expect(calls.some((call) => call.startsWith("listUsers:"))).toBe(false); - }), -); - -it.effect("lets the emulator fallback continue past one unreadable member", () => - Effect.gen(function* () { - const calls: string[] = []; - expect(yield* resolve("grace@placeholder.test", calls, true, new Set(["user_ada"]))).toBe( - "user_grace", - ); - expect(calls).toEqual([`listOrgMembers:${ORG}`, "getUser:user_ada", "getUser:user_grace"]); - }), -); diff --git a/apps/cloud/src/api/layers.ts b/apps/cloud/src/api/layers.ts index bcc5c5221d..c693425995 100644 --- a/apps/cloud/src/api/layers.ts +++ b/apps/cloud/src/api/layers.ts @@ -2,10 +2,16 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServer } from "effect/unstable/http"; import { Layer } from "effect"; -import { makeProtectedApiLayer, requestScopedMiddleware } from "@executor-js/api/server"; +import { + makeProtectedApiLayer, + requestScopedMiddleware, + type MemberDirectory, +} from "@executor-js/api/server"; import { SessionAuthLive } from "../auth/middleware-live"; import { UserStoreService } from "../auth/context"; +import { cloudMemberDirectoryLayer } from "../auth/member-directory"; +import { MirrorReadiness } from "../auth/mirror-readiness"; import { WorkOsMirror } from "../auth/workos-mirror"; import { CloudAuthPublicHandlers, @@ -27,12 +33,26 @@ import { CoreSharedServices } from "../auth/workos"; const DbLive = DbService.Live; const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); const WorkOsMirrorLive = WorkOsMirror.Live.pipe(Layer.provide(DbLive)); +// The shared `MemberDirectory` read seam over the membership mirror — the +// same per-request socket the mirror writes through. +const MemberDirectoryLive = cloudMemberDirectoryLayer.pipe(Layer.provide(DbLive)); +// Whether the mirror may authorize this request at all (backfill complete, +// reconciler caught up) — read on the same socket before the membership row. +const MirrorReadinessLive = MirrorReadiness.Live.pipe(Layer.provide(DbLive)); // Per-request layer. Anything that opens an I/O object (postgres.js socket, // fetch stream readers, anything backed by a `Writable`) MUST live here — // `provideRequestScoped` rebuilds it per request so Cloudflare Workers' // I/O isolation is satisfied. See `api.request-scope.test.ts`. -export const RequestScopedServicesLive = Layer.mergeAll(DbLive, UserStoreLive, WorkOsMirrorLive); +export const RequestScopedServicesLive: Layer.Layer< + DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness +> = Layer.mergeAll( + DbLive, + UserStoreLive, + WorkOsMirrorLive, + MemberDirectoryLive, + MirrorReadinessLive, +); // Boot-scoped layer. Built once at worker boot, reused across requests. // Safe for config, in-memory caches, the global tracer provider, and @@ -57,7 +77,9 @@ export const BootSharedServices = Layer.mergeAll( // handler reads it for the free-organizations-per-user limit gate — one of the // few app-only billing touchpoints. (It is NOT on the neutral boot core.) export const makeNonProtectedApiLive = ( - rsLive: Layer.Layer, + rsLive: Layer.Layer< + DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness + >, ) => HttpApiBuilder.layer(NonProtectedApi).pipe( Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), @@ -72,7 +94,11 @@ export const makeNonProtectedApiLive = ( // the account and protected APIs. The `getDomainVerificationLink` handler also // gates on billing, so `AutumnService.Default` is provided here, not on the // neutral boot core. -export const makeOrgApiLive = (rsLive: Layer.Layer) => +export const makeOrgApiLive = ( + rsLive: Layer.Layer< + DbService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror + >, +) => HttpApiBuilder.layer(OrgHttpApi).pipe( Layer.provide(OrgHandlers), Layer.provide(orgAuthMiddleware(rsLive)), diff --git a/apps/cloud/src/api/protected-api-key-auth.node.test.ts b/apps/cloud/src/api/protected-api-key-auth.node.test.ts index 7be135a9e4..4aaf7cc1fb 100644 --- a/apps/cloud/src/api/protected-api-key-auth.node.test.ts +++ b/apps/cloud/src/api/protected-api-key-auth.node.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; +import { MemberDirectory } from "@executor-js/api/server"; + import { ApiKeyService } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; +import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness"; import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; import { resolveProtectedPrincipal } from "./protected"; const createdAt = new Date("2026-01-01T00:00:00.000Z"); @@ -45,20 +49,43 @@ const stubWorkOS = Layer.succeed( WorkOSClient, new Proxy({} as WorkOSClientService, { get: (_target, prop) => { - if (prop === "listUserMemberships") { - return (userId: string) => - Effect.succeed({ - data: - userId === "user_123" - ? [{ userId, organizationId: "org_123", status: "active" }] - : [], - }); - } return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); }, }), ); +// The mirror as the directory reads it: user_123 holds an active membership in +// org_123 and nothing else. Membership is never read from WorkOS. +// The mirror is READY in these tests (backfill complete, reconciler caught +// up), so membership is read from the stubbed directory, never from WorkOS. +const stubReadiness = Layer.succeed(MirrorReadiness)({ + state: () => Effect.succeed(MirrorReadinessState.Ready()), +}); + +const stubDirectory = Layer.succeed(MemberDirectory)({ + membership: (accountId, organizationId) => + Effect.succeed( + accountId === "user_123" && organizationId === "org_123" + ? { + accountId, + membershipId: `om_${accountId}_${organizationId}`, + organizationId, + email: null, + name: null, + avatarUrl: null, + role: "member", + status: "active" as const, + lastActiveAt: null, + } + : null, + ), + membershipById: () => Effect.die("bearer resolution does not look up by membership id"), + membershipsOf: () => Effect.die("bearer resolution reads one membership, not the list"), + members: () => Effect.die("bearer resolution does not list members"), + membersById: () => Effect.die("bearer resolution does not batch members"), + findByEmail: () => Effect.die("bearer resolution does not resolve emails"), +}); + const stubUsers = Layer.succeed(UserStoreService)({ use: (_op, fn) => Effect.promise(() => @@ -68,7 +95,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ upsertOrganization: async (org: { id: string; name: string }) => ({ ...org, slug: `org-slug-${org.id}`, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, @@ -77,7 +104,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ id, name: `Org ${id}`, slug: `org-slug-${id}`, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, @@ -86,19 +113,32 @@ const stubUsers = Layer.succeed(UserStoreService)({ id: "org_by_slug", name: `Org ${slug}`, slug, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, }), + markOrganizationDeleted: async () => null, deleteOrganizationCascade: async () => {}, }), ), }); +// Authorization scans an organization the backfill never covered before it +// reads the mirror (`auth/organization.ts`); every org row above is marked +// backfilled, so the scan is never reached and the mirror is never written. +const stubMirror = Layer.succeed( + WorkOsMirror, + new Proxy({} as WorkOsMirrorShape, { + get: (_target, prop) => () => Effect.die(`unexpected WorkOsMirror.${String(prop)} call`), + }), +); + const run = (request: Request) => resolveProtectedPrincipal(request).pipe( - Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers)), + Effect.provide( + Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror, stubReadiness), + ), ); describe("protected API key auth", () => { diff --git a/apps/cloud/src/api/protected-jwt-auth.node.test.ts b/apps/cloud/src/api/protected-jwt-auth.node.test.ts index e4e2c041d5..02abb0b53e 100644 --- a/apps/cloud/src/api/protected-jwt-auth.node.test.ts +++ b/apps/cloud/src/api/protected-jwt-auth.node.test.ts @@ -2,10 +2,14 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from "jose"; +import { MemberDirectory } from "@executor-js/api/server"; + import { ApiKeyService } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; +import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness"; import type { JwtBearerConfig } from "../auth/workos-auth-provider"; import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; import { resolveProtectedPrincipal } from "./protected"; const createdAt = new Date("2026-01-01T00:00:00.000Z"); @@ -62,20 +66,43 @@ const stubWorkOS = Layer.succeed( WorkOSClient, new Proxy({} as WorkOSClientService, { get: (_target, prop) => { - if (prop === "listUserMemberships") { - return (userId: string) => - Effect.succeed({ - data: - userId === "user_123" - ? [{ userId, organizationId: "org_123", status: "active" }] - : [], - }); - } return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); }, }), ); +// The mirror as the directory reads it: user_123 holds an active membership in +// org_123 and nothing else. Membership is never read from WorkOS. +// The mirror is READY in these tests (backfill complete, reconciler caught +// up), so membership is read from the stubbed directory, never from WorkOS. +const stubReadiness = Layer.succeed(MirrorReadiness)({ + state: () => Effect.succeed(MirrorReadinessState.Ready()), +}); + +const stubDirectory = Layer.succeed(MemberDirectory)({ + membership: (accountId, organizationId) => + Effect.succeed( + accountId === "user_123" && organizationId === "org_123" + ? { + accountId, + membershipId: `om_${accountId}_${organizationId}`, + organizationId, + email: null, + name: null, + avatarUrl: null, + role: "member", + status: "active" as const, + lastActiveAt: null, + } + : null, + ), + membershipById: () => Effect.die("bearer resolution does not look up by membership id"), + membershipsOf: () => Effect.die("bearer resolution reads one membership, not the list"), + members: () => Effect.die("bearer resolution does not list members"), + membersById: () => Effect.die("bearer resolution does not batch members"), + findByEmail: () => Effect.die("bearer resolution does not resolve emails"), +}); + const stubUsers = Layer.succeed(UserStoreService)({ use: (_op, fn) => Effect.promise(() => @@ -85,7 +112,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ upsertOrganization: async (org: { id: string; name: string }) => ({ ...org, slug: `org-slug-${org.id}`, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, @@ -94,7 +121,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ id, name: `Org ${id}`, slug: `org-slug-${id}`, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, @@ -103,19 +130,32 @@ const stubUsers = Layer.succeed(UserStoreService)({ id: "org_by_slug", name: `Org ${slug}`, slug, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, }), + markOrganizationDeleted: async () => null, deleteOrganizationCascade: async () => {}, }), ), }); +// Authorization scans an organization the backfill never covered before it +// reads the mirror (`auth/organization.ts`); every org row above is marked +// backfilled, so the scan is never reached and the mirror is never written. +const stubMirror = Layer.succeed( + WorkOsMirror, + new Proxy({} as WorkOsMirrorShape, { + get: (_target, prop) => () => Effect.die(`unexpected WorkOsMirror.${String(prop)} call`), + }), +); + const run = (request: Request, jwt: JwtBearerConfig) => resolveProtectedPrincipal(request, jwt).pipe( - Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers)), + Effect.provide( + Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror, stubReadiness), + ), ); const request = (token: string) => diff --git a/apps/cloud/src/api/protected.ts b/apps/cloud/src/api/protected.ts index 417525d823..a9308025ef 100644 --- a/apps/cloud/src/api/protected.ts +++ b/apps/cloud/src/api/protected.ts @@ -10,11 +10,14 @@ import { requestScopedMiddleware, RouterConfigLive, type IdentityFailure, + type MemberDirectory, } from "@executor-js/api/server"; import { cloudPlugins, type CloudPlugins } from "../plugins"; import { ApiKeyService } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; +import { MirrorReadiness } from "../auth/mirror-readiness"; +import { WorkOsMirror } from "../auth/workos-mirror"; import { cloudIdentityFailureStrategy, workosIdentityLayer } from "../auth/workos-auth-provider"; import { AutumnService } from "../extensions/billing/service"; import { DbService } from "../db/db"; @@ -32,8 +35,8 @@ export { // One `HttpRouter` middleware that: // 1. resolves identity via the NEUTRAL `IdentityProvider` (api-key BEATS sealed -// session, decided INSIDE cloud's `workosIdentityLayer`), verifying live org -// membership, +// session, decided INSIDE cloud's `workosIdentityLayer`), verifying org +// membership against the local mirror, // 2. builds the per-request executor + engine, // 3. provides `AuthContext` + the execution-stack services to the handler. // @@ -93,9 +96,13 @@ const ExecutionStackMiddleware = makeExecutionStackMiddleware< // executor plane that meters, not to the neutral boot core. (`/autumn`, the // account seat-gate, and the createOrganization free-limit gate each provide it // where they run.) -export const makeProtectedApiLive = (rsLive: Layer.Layer) => { +export const makeProtectedApiLive = ( + rsLive: Layer.Layer< + DbService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror + >, +) => { // The neutral `IdentityProvider`, built per request: it reads `UserStoreService` - // from `rsLive` and the WorkOS control plane (`WorkOSClient` + `ApiKeyService`, + // + `MemberDirectory` from `rsLive` and the WorkOS control plane (`WorkOSClient` + `ApiKeyService`, // stateless config — no per-request I/O socket) for the org-resolution path. // `orDie` because a WorkOS config error is unrecoverable. const identityLive = workosIdentityLayer.pipe( diff --git a/apps/cloud/src/api/router.ts b/apps/cloud/src/api/router.ts index 227dc20b22..7bb7da2479 100644 --- a/apps/cloud/src/api/router.ts +++ b/apps/cloud/src/api/router.ts @@ -1,9 +1,14 @@ import { Layer } from "effect"; import { HttpRouter } from "effect/unstable/http"; -import { RouterConfigLive, requestScopedMiddleware } from "@executor-js/api/server"; +import { + RouterConfigLive, + requestScopedMiddleware, + type MemberDirectory, +} from "@executor-js/api/server"; import { UserStoreService } from "../auth/context"; +import { MirrorReadiness } from "../auth/mirror-readiness"; import { WorkOsMirror } from "../auth/workos-mirror"; import { DbService } from "../db/db"; import { makeAccountApiLive } from "../account/account-api"; @@ -31,7 +36,9 @@ import { makeProtectedApiLive } from "./protected"; // assert per-request semantics — see // `apps/cloud/src/api.request-scope.node.test.ts`. export const makeApiLive = ( - requestScopedLive: Layer.Layer, + requestScopedLive: Layer.Layer< + DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness + >, ) => { const BillingRoutesLive = AutumnRoutesLive.pipe( Layer.provide(requestScopedMiddleware(requestScopedLive).layer), diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index 3f69c0ce6c..4ff1fc25e1 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -1,7 +1,7 @@ import { HttpApiEndpoint, HttpApiGroup } from "effect/unstable/httpapi"; import { Schema } from "effect"; import { UserStoreError, WorkOSError, WorkOsMirrorError } from "./errors"; -import { NoOrganization } from "@executor-js/api/server"; +import { MemberDirectoryError, NoOrganization } from "@executor-js/api/server"; import { SessionAuth } from "./middleware"; const AuthUser = Schema.Struct({ @@ -166,6 +166,16 @@ export class OrganizationDeletionForbidden extends Schema.TaggedErrorClass()( + "OrganizationDeletionIncomplete", + { step: Schema.Literals(["billing"]) }, + { httpApiStatus: 500 }, +) {} + export const AUTH_PATHS = { login: "/api/auth/login", logout: "/api/auth/logout", @@ -173,8 +183,10 @@ export const AUTH_PATHS = { } as const; // The login callback and the org handlers feed the membership mirror, so a -// mirror write failure is one of their wire errors (same 500 as a store failure). -const AuthErrors = [UserStoreError, WorkOSError, WorkOsMirrorError] as const; +// mirror write failure is one of their wire errors (same 500 as a store +// failure); the session handlers READ it (membership, the org list, the admin +// gate), so a directory read failure is one too. +const AuthErrors = [UserStoreError, WorkOSError, WorkOsMirrorError, MemberDirectoryError] as const; const McpApprovalErrors = [ NoOrganization, McpExecutionNotFoundError, @@ -216,7 +228,7 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") .add( HttpApiEndpoint.get("organizations", "/auth/organizations", { success: AuthOrganizationsResponse, - error: WorkOSError, + error: [WorkOSError, UserStoreError, MemberDirectoryError], }), ) .add( @@ -230,7 +242,12 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") HttpApiEndpoint.post("deleteOrganization", "/auth/delete-organization", { payload: DeleteOrganizationBody, success: DeleteOrganizationResponse, - error: [...AuthErrors, NoOrganization, OrganizationDeletionForbidden], + error: [ + ...AuthErrors, + NoOrganization, + OrganizationDeletionForbidden, + OrganizationDeletionIncomplete, + ], }), ) .add( diff --git a/apps/cloud/src/auth/doc-gate.ts b/apps/cloud/src/auth/doc-gate.ts index 16d6145bc9..cc33730c4a 100644 --- a/apps/cloud/src/auth/doc-gate.ts +++ b/apps/cloud/src/auth/doc-gate.ts @@ -41,6 +41,9 @@ import { makeDbLayer } from "../db/db"; import { makeUserStoreLayer, UserStoreService } from "./context"; import { parseCookie } from "./cookies"; import { LAST_ORG_COOKIE } from "./last-org-cookie"; +import { makeMemberDirectoryLayer } from "./member-directory"; +import { makeMirrorReadinessLayer } from "./mirror-readiness"; +import { makeWorkOsMirrorLayer } from "./workos-mirror"; import { sealedSessionDisplayName } from "./middleware"; import { authorizeOrganizationSelector } from "./organization"; import { loginPath, safeReturnTo } from "./return-to"; @@ -164,18 +167,27 @@ const organizationDisplay = async ( : { name: "", slug: "" }; }; -// Live membership check for the last-org cookie's slug. Same authorize path -// as any org selector — the cookie is a preference, so a slug the user can't -// access (stale after removal/deletion, or forged) resolves to null and the -// bare path falls through to today's canonicalize-onto-session-org behavior. -// Per-request store layers for the same reason as organizationDisplay. +// Membership check (against the local mirror) for the last-org cookie's slug. +// Same authorize path as any org selector — the cookie is a preference, so a +// slug the user can't access (stale after removal/deletion, or forged) resolves +// to null and the bare path falls through to today's +// canonicalize-onto-session-org behavior. Per-request store layers for the +// same reason as organizationDisplay; both stores share the one socket. const authorizeLastOrgSlug = async ( userId: string, slug: string, ): Promise<{ readonly id: string } | null> => { + const dbLive = makeDbLayer(); const exit = await getRuntime().runPromiseExit( authorizeOrganizationSelector(userId, slug).pipe( - Effect.provide(Layer.provide(makeUserStoreLayer(), makeDbLayer())), + Effect.provide( + Layer.mergeAll( + makeUserStoreLayer(), + makeMemberDirectoryLayer(), + makeMirrorReadinessLayer(), + makeWorkOsMirrorLayer(), + ).pipe(Layer.provide(dbLive)), + ), ), ); return Exit.isSuccess(exit) ? exit.value : null; @@ -257,7 +269,7 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( // contract is untouched because an unknown-but-valid slug in the URL reads // as slugged, not bare. When the cookie matches the session's own org (the // overwhelmingly common single-org case) the client-side OrgSlugGate - // already canonicalizes onto it, so skip the live membership check and the + // already canonicalizes onto it, so skip the membership check and the // redirect entirely. const lastOrgSlug = parseCookie(cookieHeader, LAST_ORG_COOKIE); const firstSegment = pathname.split("/")[1] ?? ""; diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index e7184f253a..06d29df4e6 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -10,8 +10,9 @@ import { McpExecutionNotFoundError, McpSessionForbiddenError, OrganizationDeletionForbidden, + OrganizationDeletionIncomplete, } from "./api"; -import { NoOrganization } from "@executor-js/api/server"; +import { MemberDirectory, NoOrganization } from "@executor-js/api/server"; // Pure constants/codec module (no React) — safe in the backend graph. import { AUTH_HINT_COOKIE } from "@executor-js/react/multiplayer/auth-hint"; import { SessionContext, SessionCookies } from "./middleware"; @@ -35,7 +36,9 @@ import { ORG_SELECTOR_HEADER, authorizeOrganization, authorizeOrganizationSelector, + markOrganizationDeleted, resolveOrganization, + type AuthorizeOrganizationOptions, } from "./organization"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; @@ -103,27 +106,30 @@ const firstPathSegment = (path: string): string | null => { const requestedOrgSelectorFromReturnTo = (returnTo: string): string | null => firstPathSegment(returnTo); -const requireSelectedOrganization = Effect.gen(function* () { - const session = yield* SessionContext; - const headers = yield* requestHeaders; - const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; - if (!selector) { - return yield* new NoOrganization(); - } - - const org = yield* authorizeOrganizationSelector(session.accountId, selector).pipe( - Effect.catch(() => Effect.fail(new NoOrganization())), - ); - if (!org) { - return yield* new NoOrganization(); - } - - return { - ...session, - organizationId: org.id, - memberRole: org.memberRole, - }; -}); +const selectedOrganization = (options: AuthorizeOrganizationOptions = {}) => + Effect.gen(function* () { + const session = yield* SessionContext; + const headers = yield* requestHeaders; + const selector = headers[ORG_SELECTOR_HEADER] ?? session.organizationId; + if (!selector) { + return yield* new NoOrganization(); + } + + const org = yield* authorizeOrganizationSelector(session.accountId, selector, options).pipe( + Effect.catch(() => Effect.fail(new NoOrganization())), + ); + if (!org) { + return yield* new NoOrganization(); + } + + return { + ...session, + organizationId: org.id, + memberRole: org.memberRole, + }; + }); + +const requireSelectedOrganization = selectedOrganization(); const getMcpSessionStub = (mcpSessionId: string) => mcpSessionStub(env.MCP_SESSION, mcpSessionId); @@ -397,20 +403,29 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( ) .handle("organizations", () => Effect.gen(function* () { - const workos = yield* WorkOSClient; + const directory = yield* MemberDirectory; const session = yield* SessionContext; - const memberships = yield* workos.listUserMemberships(session.accountId); + // The caller's memberships (active + pending, as WorkOS listed them + // before) from the local mirror — one indexed read, no WorkOS call. + const memberships = yield* directory.membershipsOf(session.accountId); // Resolve through the mirror (not WorkOS directly) so each org's // URL slug is minted/read — the switcher navigates to `/`. + // An org marked deleted (its deletion is in progress or failed + // part-way, see deleteOrganization) refuses every session, so it + // is not a place the switcher can go. const organizations = yield* Effect.all( - memberships.data.map((m) => + memberships.map((m) => resolveOrganization(m.organizationId).pipe( - Effect.map((org) => ({ - id: org.id, - name: org.name, - slug: org.slug, - })), + Effect.map((org) => + org.deletedAt === null + ? { + id: org.id, + name: org.name, + slug: org.slug, + } + : null, + ), Effect.orElseSucceed(() => null), ), ), @@ -431,10 +446,10 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( const autumn = yield* AutumnService; const name = payload.name.trim(); - const memberships = yield* workos.listUserMemberships(session.accountId); - const activeMemberships = memberships.data.filter( - (membership) => membership.status === "active", - ); + // The free-organizations-per-user limit counts the caller's ACTIVE + // memberships, read from the local mirror. + const directory = yield* MemberDirectory; + const activeMemberships = yield* directory.membershipsOf(session.accountId, ["active"]); if (isOverFreeOrganizationLimit(activeMemberships)) { const paidOrganizationIds = yield* Effect.all( @@ -536,15 +551,18 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( // Target the caller's currently-selected org (honors the org-selector // header, same as the other org-scoped auth handlers). NoOrganization - // when the session has no org to act on. - const session = yield* requireSelectedOrganization; + // when the session has no org to act on. An org already MARKED + // deleted still resolves here — and only here — so an admin whose + // earlier attempt failed after the mark can send it again and finish. + const session = yield* selectedOrganization({ deleted: "allow" }); const organizationId = session.organizationId; - // Admin-only. Live WorkOS check so a member removed/demoted moments - // ago can't delete the workspace. A pending admin invite is not an - // active admin, so require active status too. - const membership = yield* workos.getUserOrgMembership(organizationId, session.accountId); - if (!membership || membership.status !== "active" || membership.role?.slug !== "admin") { + // Admin-only. `requireSelectedOrganization` already read the caller's + // mirrored membership, required it ACTIVE (a pending admin invite is + // not an admin) and reported its role, so the gate is that one + // value: a member removed or demoted moments ago is denied once the + // write-through or the Events reconciler has landed the change. + if (session.memberRole !== "admin") { return yield* new OrganizationDeletionForbidden(); } @@ -555,20 +573,78 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( return yield* new OrganizationDeletionForbidden(); } - // WorkOS FIRST. Once the org is gone there, membership authorization - // fails for every member, so the workspace is truly deleted even if a - // later local step lags (leftover local rows become unreachable, not - // user-visible). The reverse order risks the org resurrecting as an - // empty workspace when a later request re-mirrors it with a new slug. - yield* workos.deleteOrganization(organizationId); - - // Purge all local tenant data, secrets, and the org's memberships in - // one transaction, leaving the org row as a tombstone marked deleted - // (so a login that fetched its membership list before the deletion - // cannot re-mint the org afterwards). If this fails after the WorkOS - // delete already succeeded, the org is gone for everyone - // (unreachable) but its secrets/tenant rows linger orphaned — alert - // loudly so that window gets swept, then surface the failure. + // Four steps, each idempotent, so a request that failed part-way + // can be sent again and finish the job. The local purge is the LAST + // step that can fail: it removes the org's membership rows — the + // admin's own among them, the row that admits the retry above — so + // nothing that can fail may run after it, or the retry it needs + // would be refused at the door. And the WorkOS delete comes AFTER + // billing: it is the one step that makes the org unrecoverable + // outside this database, so nothing that can fail runs between it + // and the purge except the purge itself — a billing failure leaves + // the WorkOS org intact, the memberships still live there, and the + // retry admitted by WorkOS and mirror alike. + // + // 1. Mark the org deleted LOCALLY. Membership is authorized from the + // local mirror (`authorizeOrganization`), not from WorkOS, so + // this — not the WorkOS delete — is what revokes every member's + // access, and it happens before anything that can fail leaves + // the org half-deleted. From here on every session is refused + // at once, whether or not the steps below land. + yield* markOrganizationDeleted(organizationId); + + // 2. Cancel billing. "No such customer" is a retry after this step + // landed (or an org that was never provisioned) — nothing to + // cancel, and not a failure. Any other Autumn failure surfaces + // as an incomplete deletion: the WorkOS delete and the purge + // below must not run until billing is cancelled, because after + // them the admin can no longer send the request again. + yield* autumn + .use((client) => client.customers.delete({ customerId: organizationId })) + .pipe( + Effect.catchTag("AutumnCustomerNotFoundError", () => + Effect.logInfo( + "deleteOrganization: Autumn has no customer for the org; nothing to cancel", + { organizationId }, + ), + ), + Effect.tapError((error) => + Effect.logError( + "deleteOrganization: org marked deleted but the Autumn customer could not be deleted; retry the deletion", + { organizationId, error }, + ), + ), + Effect.mapError(() => new OrganizationDeletionIncomplete({ step: "billing" })), + ); + + // 3. Delete the WorkOS org (cascades its memberships, invitations, + // and domains there). "Already deleted" (404) is a retry after + // the purge failed, not a failure: fall through. + yield* workos + .deleteOrganization(organizationId) + .pipe( + Effect.catchTag("WorkOSError", (error) => + error.status === 404 + ? Effect.logInfo( + "deleteOrganization: WorkOS org already deleted; finishing the deletion", + { organizationId }, + ) + : Effect.fail(error), + ), + ); + + // 4. Purge all local tenant data, secrets, and the org's memberships + // in one transaction, keeping the org row as a tombstone marked + // deleted (step 1's mark stands; a login that fetched its + // membership list before the deletion cannot re-mint the org + // afterwards). If this fails, the org is already unreachable + // (step 1) but its secrets/tenant rows linger — alert loudly, + // surface the failure, and the admin retries: the transaction + // rolled back, so their membership row still admits them (read + // from the mirror even while it is not ready — WorkOS no longer + // lists the org's members); step 1 keeps its mark, and steps 2 + // and 3 tolerate the gone customer and org, so the retry reaches + // this purge again. const deletedAt = new Date(yield* Clock.currentTimeMillis); yield* users .use("deleteOrganizationCascade", (s) => @@ -577,28 +653,12 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( .pipe( Effect.tapError((error) => Effect.logError( - "deleteOrganization: WorkOS org deleted but local purge failed, tenant data and secrets orphaned", + "deleteOrganization: org marked deleted, removed from WorkOS and Autumn, but local purge failed, tenant data and secrets orphaned; retry the deletion", { organizationId, error }, ), ), ); - // Cancel billing. Best-effort: the org is already deleted, so a - // lingering Autumn customer is a billing loose end (log loudly) rather - // than a correctness failure that should 500 the caller. - yield* autumn - .use((client) => client.customers.delete({ customerId: organizationId })) - .pipe( - // Includes the "customer never existed" answer: nothing to cancel - // is a fine outcome for a deleted org, and it is still worth a line. - Effect.catch((error) => - Effect.logWarning("deleteOrganization: failed to delete Autumn customer", { - organizationId, - error, - }), - ), - ); - // The caller's session is pinned to the now-deleted org — clear it so // the browser bounces to login and rehydrates to another membership // (or the create-org screen when they have none left). diff --git a/apps/cloud/src/auth/last-org-cookie.ts b/apps/cloud/src/auth/last-org-cookie.ts index ef6242604f..897afe2212 100644 --- a/apps/cloud/src/auth/last-org-cookie.ts +++ b/apps/cloud/src/auth/last-org-cookie.ts @@ -12,7 +12,7 @@ // - the login callback prefers it when picking the org for a fresh session // with a bare returnTo (handlers.ts) // -// It is a PREFERENCE, never an authority: both readers re-check live membership +// It is a PREFERENCE, never an authority: both readers re-check membership // through the same authorize path as any org selector, so a stale or forged // value at worst falls back to today's behavior. Not HttpOnly — the client is // the writer. Deliberately NOT cleared on logout: surviving the session is what diff --git a/apps/cloud/src/auth/member-directory.ts b/apps/cloud/src/auth/member-directory.ts index 01174d88f3..d5fedb38a2 100644 --- a/apps/cloud/src/auth/member-directory.ts +++ b/apps/cloud/src/auth/member-directory.ts @@ -141,6 +141,32 @@ const makeService = (db: DrizzleDb): MemberDirectoryShape => { return row === undefined ? null : toMember(row); }), + // The unique index on `membership_id` makes this a point read; the org + // predicate is what refuses an id that belongs to another org. + membershipById: (organizationId, membershipId) => + read("membershipById", async () => { + const rows = await select() + .where( + and( + eq(memberships.organizationId, organizationId), + eq(memberships.membershipId, membershipId), + ), + ) + .limit(1); + const row = rows[0]; + return row === undefined ? null : toMember(row); + }), + + membershipsOf: (accountId, statuses = DEFAULT_MEMBER_STATUSES) => + read("membershipsOf", async () => { + const rows = await select() + .where( + and(eq(memberships.accountId, accountId), inArray(memberships.status, statuses), known), + ) + .orderBy(asc(memberships.organizationId)); + return toMembers(rows); + }), + members, membersById: (organizationId, accountIds, statuses = DEFAULT_MEMBER_STATUSES) => @@ -179,3 +205,13 @@ const makeService = (db: DrizzleDb): MemberDirectoryShape => { /** The cloud `MemberDirectory` over the per-request `DbService`. */ export const cloudMemberDirectoryLayer: Layer.Layer = Layer.effect(MemberDirectory)(Effect.map(DbService.asEffect(), ({ db }) => makeService(db))); + +/** + * A FRESH `MemberDirectory` layer (new layer value per call), for a service + * built once but invoked across many Workers requests — the MCP + * org-authorization seam and the document gate — for the same reason + * `makeUserStoreLayer` exists: a memoized const layer would pin the first + * request's postgres socket. See [[makeDbLayer]]. + */ +export const makeMemberDirectoryLayer = (): Layer.Layer => + Layer.effect(MemberDirectory)(Effect.map(DbService.asEffect(), ({ db }) => makeService(db))); diff --git a/apps/cloud/src/auth/mirror-feeders.node.test.ts b/apps/cloud/src/auth/mirror-feeders.node.test.ts index 3381943557..3ffd73fd97 100644 --- a/apps/cloud/src/auth/mirror-feeders.node.test.ts +++ b/apps/cloud/src/auth/mirror-feeders.node.test.ts @@ -12,11 +12,33 @@ // - the callback picks the landing org from that same list: a returnTo // slug or last-org cookie lands only in an ACTIVE membership, an unknown // or pending one falls through +// - `inviteMember` mirrors the PENDING membership WorkOS created for the +// invitee (found by email among the org's pending memberships), so the +// member list shows the invite and can revoke it // - `removeMember` tombstones the mirror row after the WorkOS delete, // stamped with the membership's last WorkOS state (never a local clock), // so a replay of the membership as it was before the delete cannot // restore it while a replacement WorkOS created meanwhile is accepted // - `updateMemberRole` writes the role WorkOS returned +// - deleting an org marks it deleted locally FIRST, so every member's +// session is refused at once even when the billing cancel, the WorkOS +// delete, or the local purge fails afterwards; billing is cancelled +// BEFORE the WorkOS delete, so a failed cancel leaves the WorkOS org +// intact and the retry finishes the deletion; a retry after WorkOS +// already deleted the org still runs the purge — even while the mirror +// is not ready, when WorkOS can no longer vouch for the admin; a marked +// org leaves the switcher +// - authorization scans an organization the backfill never covered (its +// `backfilled_at` is missing) from WorkOS before reading its mirror, +// once, so a member the mirror never recorded is admitted; an +// organization the mirror does not hold at all is resolved from WorkOS +// for a caller WorkOS confirms as its member, and minted for nobody else +// - the seat gate trusts the mirror's count only for an organization whose +// membership list was scanned from WorkOS in full: an unmarked one is +// scanned first (once), so a partial mirror never admits an invite past +// the plan limit +// - the seat reporter scans an unmarked organization before counting and +// never re-scans a marked one // - the backfill mirrors every org's members and counts what it wrote, // writes nothing on a dry run, converges on a re-run, tombstones a // membership WorkOS no longer lists — but never one written after its @@ -39,10 +61,11 @@ import { describe, expect, it } from "@effect/vitest"; import { sql } from "drizzle-orm"; -import { Effect, Exit, Fiber, Latch, Layer } from "effect"; +import { Effect, Exit, Fiber, Latch, Layer, Option } from "effect"; import { HttpRouter, HttpServer } from "effect/unstable/http"; import { HttpApiBuilder } from "effect/unstable/httpapi"; +import { AccountForbidden } from "@executor-js/api"; import { AccountProvider, MemberDirectory, @@ -53,17 +76,28 @@ import { import { AccountCaller, workosAccountProvider } from "../account/workos-account-service"; import { RequestScopedServicesLive } from "../api/layers"; import { DbService } from "../db/db"; -import { AutumnService } from "../extensions/billing/service"; +import { forkReportMemberSeats } from "../extensions/billing/member-seats"; +import { + AutumnCustomerNotFoundError, + AutumnError, + AutumnService, + type AutumnFailure, +} from "../extensions/billing/service"; import { ApiKeyService } from "./api-keys"; import { UserStoreService } from "./context"; -import { WorkOSError } from "./errors"; +import { UserStoreError, WorkOSError } from "./errors"; import { CloudAuthPublicHandlers, CloudSessionAuthHandlers, NonProtectedApi } from "./handlers"; import { LAST_ORG_COOKIE } from "./last-org-cookie"; import { encodeLoginState } from "./login-state"; import { cloudMemberDirectoryLayer } from "./member-directory"; import { SessionAuthLive } from "./middleware-live"; import { mirrorSignIn } from "./mirror-feeders"; -import { ORG_SELECTOR_HEADER } from "./organization"; +import { MirrorReadiness, MirrorReadinessState } from "./mirror-readiness"; +import { + ORG_SELECTOR_HEADER, + authorizeOrganization, + markOrganizationDeleted, +} from "./organization"; import { WorkOSClient, type WorkOSClientService } from "./workos"; import { WorkOsMirror, type WorkOsMirrorShape } from "./workos-mirror"; import { backfillOrganization, backfillWorkOsMirror } from "./workos-mirror-backfill"; @@ -143,6 +177,14 @@ const seedOrganization = (id: string) => ), ); +// The mirror is READY throughout (backfill complete, reconciler caught up): +// every membership read below is against the mirror, never WorkOS. The +// readiness rule itself is pinned in workos-mirror.node.test.ts and the +// fallback in org-selector-auth.node.test.ts. +const readyMirror = Layer.succeed(MirrorReadiness)({ + state: () => Effect.succeed(MirrorReadinessState.Ready()), +}); + const stubAutumn = Layer.succeed(AutumnService)({ use: () => Effect.die("feeders do not read billing"), ensureCustomer: () => Effect.void, @@ -210,13 +252,21 @@ describe("login callback", () => { listMetadata: { before: null, after: null }, }); }, - // The forked seat recount after login. - listOrgMembers: () => - Effect.succeed({ + // The landing org's seat recount scans the org from WorkOS the first + // time it is counted (its per-org backfill mark is missing); the + // scan lists the org's members and fetches each user. + listOrgMembers: (organizationId) => { + calls.push(`listOrgMembers:${organizationId}`); + return Effect.succeed({ object: "list" as const, - data: [] as never[], + data: listed.filter((m) => m.organizationId === organizationId) as never[], listMetadata: { before: null, after: null }, - }), + }); + }, + getUser: (id) => { + calls.push(`getUser:${id}`); + return Effect.succeed(workosUser(id) as never); + }, refreshSession: (_sealed, organizationId) => { refreshedInto.push(organizationId); return Effect.succeed("sealed-refreshed"); @@ -268,7 +318,17 @@ describe("login callback", () => { const response = await handler(callbackRequest({})); expect(response.status).toBe(302); - expect(calls, "one membership list for the whole callback").toEqual([ + expect( + calls, + "one membership list for the callback itself; the landing org, never scanned, is scanned once for its seat count", + ).toEqual([ + `listUserMemberships:${userId}`, + `listOrgMembers:${activeOrg}`, + `getUser:${userId}`, + ]); + calls.length = 0; + expect((await handler(callbackRequest({}))).status).toBe(302); + expect(calls, "a second sign-in lists memberships only: the org is now marked").toEqual([ `listUserMemberships:${userId}`, ]); @@ -511,6 +571,530 @@ describe("a delayed sign-in feeder", () => { }); }); +describe("session handlers read membership from the mirror", () => { + /** + * The session routes over the live request-scoped services. `workos` adds + * to the fake WorkOS (only session authentication by default: every + * membership read against WorkOS dies); `services` replaces the per-request + * layer, so a test can fail one store call on purpose. + */ + const sessionHandler = ( + userId: string, + options: { + readonly workos?: Partial; + readonly services?: Layer.Layer< + DbService | UserStoreService | WorkOsMirror | MemberDirectory + >; + readonly autumn?: Layer.Layer; + /** The mirror's readiness for this request; ready unless a test says otherwise. */ + readonly readiness?: Layer.Layer; + } = {}, + ) => + HttpRouter.toWebHandler( + HttpApiBuilder.layer(NonProtectedApi).pipe( + Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), + Layer.provide( + requestScopedMiddleware( + Layer.mergeAll( + options.services ?? RequestScopedServicesLive, + options.readiness ?? readyMirror, + ), + ).layer, + ), + Layer.provideMerge(SessionAuthLive), + Layer.provideMerge(options.autumn ?? stubAutumn), + Layer.provideMerge( + stubWorkOS({ + ...options.workos, + authenticateSealedSession: () => + Effect.succeed({ + userId, + email: `${userId}@placeholder.test`, + organizationId: null, + } as never), + }), + ), + Layer.provideMerge(HttpServer.layerServices), + Layer.provideMerge(RouterConfigLive), + ), + { disableLogger: true }, + ).handler; + + /** The org row as the mirror holds it, or null once purged. */ + const readOrganization = (org: string) => + Effect.runPromise( + Effect.flatMap(UserStoreService.asEffect(), (users) => + users.use("getOrganization", (s) => s.getOrganization(org)), + ).pipe( + Effect.provide(UserStoreService.Live.pipe(Layer.provide(DbService.Live))), + Effect.scoped, + ), + ); + + /** + * `authorizeOrganization` over the live stores and a READY mirror, as every + * protected request runs it; `workos` serves whatever the check may read + * from WorkOS (nothing, by default: any read dies). + */ + const authorize = ( + userId: string, + org: string, + workos: Layer.Layer = stubWorkOS({}), + ) => + Effect.runPromise( + authorizeOrganization(userId, org).pipe( + Effect.provide( + Layer.mergeAll( + UserStoreService.Live, + WorkOsMirror.Live, + cloudMemberDirectoryLayer, + readyMirror, + ).pipe(Layer.provideMerge(DbService.Live)), + ), + Effect.provide(workos), + Effect.scoped, + ), + ); + + /** Whether `userId` is authorized for `org` right now. */ + const authorized = async (userId: string, org: string) => (await authorize(userId, org)) !== null; + + /** A request-scoped layer whose `deleteOrganizationCascade` fails, everything else live. */ + const servicesWithFailingPurge = (purges: string[]) => + Layer.mergeAll( + Layer.effect(UserStoreService)( + Effect.map(UserStoreService.asEffect(), (live): UserStoreService["Service"] => ({ + use: (op, fn) => + op === "deleteOrganizationCascade" + ? Effect.sync(() => { + purges.push(op); + }).pipe( + Effect.flatMap(() => + Effect.fail(new UserStoreError({ operation: op, reason: "connection_closed" })), + ), + ) + : live.use(op, fn), + })), + ).pipe(Layer.provide(UserStoreService.Live)), + WorkOsMirror.Live, + cloudMemberDirectoryLayer, + ).pipe(Layer.provideMerge(DbService.Live)); + + const deletingAutumn = Layer.succeed(AutumnService)({ + use: () => Effect.succeed({} as never), + ensureCustomer: () => Effect.void, + checkExecutionBalance: () => Effect.die("deletion does not check balances"), + trackExecution: () => Effect.void, + setMemberSeats: () => Effect.void, + }); + + /** + * Mirror `org` — marked as scanned (an empty listing at T1), as the one-off + * backfill leaves every org, so authorization reads its mirror without a + * WorkOS scan — and `userId`'s membership in it; returns the org's slug. + */ + const seedMembership = async ( + userId: string, + org: string, + status: "active" | "pending", + role: "admin" | "member" = "member", + ) => { + const slug = await seedOrganization(org); + await Effect.runPromise( + Effect.flatMap(WorkOsMirror.asEffect(), (mirror) => + Effect.andThen( + mirror.applyOrganizationScan({ + organizationId: org, + listedAt: new Date(T1), + members: [], + }), + mirror.upsertMembership({ + id: `om_${userId}_${org}`, + accountId: userId, + organizationId: org, + role, + status, + updatedAt: new Date(T1), + }), + ), + ).pipe(Effect.provide(WorkOsMirror.Live.pipe(Layer.provide(DbService.Live))), Effect.scoped), + ); + return slug; + }; + + const deleteOrganizationRequest = (org: string) => + new Request("http://test.local/auth/delete-organization", { + method: "POST", + headers: { + cookie: "wos-session=sealed", + "content-type": "application/json", + [ORG_SELECTOR_HEADER]: org, + }, + body: JSON.stringify({ confirmName: `Org ${org}` }), + }); + + it("lists the caller's organizations from the mirror, with their slugs", async () => { + const userId = freshId("user"); + const activeOrg = freshId("org"); + const pendingOrg = freshId("org"); + const otherUser = freshId("user"); + const foreignOrg = freshId("org"); + const activeSlug = await seedMembership(userId, activeOrg, "active"); + const pendingSlug = await seedMembership(userId, pendingOrg, "pending"); + await seedMembership(otherUser, foreignOrg, "active"); + + const response = await sessionHandler(userId)( + new Request("http://test.local/auth/organizations", { + headers: { cookie: "wos-session=sealed" }, + }), + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { + organizations: { id: string; slug: string }[]; + activeOrganizationId: string | null; + }; + expect( + body.organizations.map((o) => [o.id, o.slug]).sort(), + "active and pending memberships, each with the mirror's slug; nobody else's", + ).toEqual( + [ + [activeOrg, activeSlug], + [pendingOrg, pendingSlug], + ].sort(), + ); + expect(body.activeOrganizationId).toBeNull(); + }); + + it("refuses to delete an org for a pending admin, before WorkOS is asked", async () => { + const userId = freshId("user"); + const org = freshId("org"); + // An admin role that is still pending: the org gate reads the mirror and + // requires an ACTIVE membership, so the invite grants no deletion right. + await seedMembership(userId, org, "pending", "admin"); + + const response = await sessionHandler(userId)(deleteOrganizationRequest(org)); + + // The selector resolves no active membership, so the request fails at the + // org check (NoOrganization) — the handler never reaches the WorkOS + // delete, which the stub would die on. + expect(response.status).toBe(403); + expect(await response.json()).toMatchObject({ _tag: "NoOrganization" }); + }); + + it("refuses to delete an org for an active plain member, before WorkOS is asked", async () => { + const userId = freshId("user"); + const org = freshId("org"); + await seedMembership(userId, org, "active", "member"); + + const response = await sessionHandler(userId)(deleteOrganizationRequest(org)); + + expect(response.status).toBe(403); + expect( + await response.json(), + "an active member who is not an admin may not delete the org", + ).toMatchObject({ _tag: "OrganizationDeletionForbidden" }); + }); + + it("revokes every member's access the moment deletion starts, even when the local purge fails, and finishes on a retry after WorkOS already deleted the org", async () => { + const admin = freshId("user"); + const member = freshId("user"); + const org = freshId("org"); + await seedMembership(admin, org, "active", "admin"); + await seedMembership(member, org, "active", "member"); + expect(await authorized(member, org), "live before the deletion").toBe(true); + + // First attempt: WorkOS deletes the org, then the local purge fails. + const workosDeletes: string[] = []; + const purges: string[] = []; + const failing = sessionHandler(admin, { + services: servicesWithFailingPurge(purges), + autumn: deletingAutumn, + workos: { + deleteOrganization: (organizationId) => + Effect.sync(() => { + workosDeletes.push(organizationId); + }), + }, + }); + const first = await failing(deleteOrganizationRequest(org)); + expect(first.status, "the failed purge is surfaced, not hidden").toBe(500); + expect(workosDeletes).toEqual([org]); + expect(purges).toEqual(["deleteOrganizationCascade"]); + expect( + (await readOrganization(org))?.deletedAt, + "the org was marked deleted BEFORE WorkOS was asked", + ).not.toBeNull(); + // Membership rows are still there (the purge did not run), yet nobody + // is authorized: the mark, not the WorkOS delete, revokes access. + expect(await authorized(member, org)).toBe(false); + expect(await authorized(admin, org)).toBe(false); + + // Retry: WorkOS now answers "already deleted"; the local purge completes. + const retry = sessionHandler(admin, { + autumn: deletingAutumn, + workos: { + deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })), + }, + }); + const second = await retry(deleteOrganizationRequest(org)); + expect(second.status, "the admin's own membership still admits the retry").toBe(200); + expect(await second.json()).toEqual({ success: true }); + expect( + (await readOrganization(org))?.deletedAt, + "the org row stays as a tombstone, marked deleted", + ).not.toBeNull(); + expect(await readMembers(org), "its memberships are purged").toEqual([]); + expect(await authorized(admin, org)).toBe(false); + }); + + it("finishes on a retry after the billing cancel failed, and only purges once billing is cancelled", async () => { + const admin = freshId("user"); + const member = freshId("user"); + const org = freshId("org"); + await seedMembership(admin, org, "active", "admin"); + await seedMembership(member, org, "active", "member"); + + // Autumn is down for the first attempt; on the retry it answers "no such + // customer" — the first attempt's cancel may have landed after all, or + // the org was never provisioned — which is nothing to cancel. + let billingCalls = 0; + const flakyAutumn = Layer.succeed(AutumnService)({ + use: () => + Effect.suspend(() => { + billingCalls += 1; + const failure: AutumnFailure = + billingCalls === 1 + ? new AutumnError({ message: "Autumn SDK request failed" }) + : new AutumnCustomerNotFoundError({ + message: "Autumn has no customer for this organization", + }); + return Effect.fail(failure); + }), + ensureCustomer: () => Effect.void, + checkExecutionBalance: () => Effect.die("deletion does not check balances"), + trackExecution: () => Effect.void, + setMemberSeats: () => Effect.void, + }); + const workosDeletes: string[] = []; + const handler = sessionHandler(admin, { + autumn: flakyAutumn, + workos: { + deleteOrganization: (organizationId) => + Effect.sync(() => { + workosDeletes.push(organizationId); + }), + }, + }); + + const first = await handler(deleteOrganizationRequest(org)); + expect(first.status, "the failed billing cancel is surfaced, not hidden").toBe(500); + expect(await first.json()).toMatchObject({ + _tag: "OrganizationDeletionIncomplete", + step: "billing", + }); + expect(workosDeletes, "the WorkOS org is NOT deleted before billing is cancelled").toEqual([]); + expect(billingCalls).toBe(1); + expect((await readOrganization(org))?.deletedAt, "the org is marked deleted").not.toBeNull(); + expect( + (await readMembers(org)).map((m) => m.accountId).sort(), + "the purge did NOT run: the membership rows are still there", + ).toEqual([admin, member].sort()); + expect(await authorized(member, org), "yet nobody is authorized: the mark stands").toBe(false); + + const second = await handler(deleteOrganizationRequest(org)); + expect(second.status, "the admin's own membership row still admits the retry").toBe(200); + expect(await second.json()).toEqual({ success: true }); + expect(workosDeletes, "WorkOS is asked once billing is cancelled").toEqual([org]); + expect(billingCalls, "billing is asked again and tolerates the gone customer").toBe(2); + expect(await readMembers(org), "and the purge ran: its memberships are gone").toEqual([]); + expect( + (await readOrganization(org))?.deletedAt, + "the org row stays as a tombstone", + ).not.toBeNull(); + expect(await authorized(admin, org)).toBe(false); + }); + + it("finishes on a retry while the mirror is not ready, after WorkOS already deleted the org", async () => { + const admin = freshId("user"); + const member = freshId("user"); + const org = freshId("org"); + await seedMembership(admin, org, "active", "admin"); + await seedMembership(member, org, "active", "member"); + + // First attempt: billing cancelled, WorkOS org deleted, local purge fails. + const purges: string[] = []; + const first = await sessionHandler(admin, { + services: servicesWithFailingPurge(purges), + autumn: deletingAutumn, + workos: { deleteOrganization: () => Effect.void }, + })(deleteOrganizationRequest(org)); + expect(first.status).toBe(500); + expect(purges).toEqual(["deleteOrganizationCascade"]); + + // The reconciler stalls before the retry. WorkOS no longer lists the org + // or the admin's membership in it — and the fallback must not ask it: + // the stub dies on `listUserMemberships`. The admin's own mirror row, + // which the failed purge left behind, is what admits the retry. + const retry = sessionHandler(admin, { + autumn: deletingAutumn, + readiness: Layer.succeed(MirrorReadiness)({ + state: () => Effect.succeed(MirrorReadinessState.ReconcilerStale({ drainedAt: null })), + }), + workos: { deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })) }, + }); + const second = await retry(deleteOrganizationRequest(org)); + expect(second.status, "the retry is admitted from the mirror, not WorkOS").toBe(200); + expect(await second.json()).toEqual({ success: true }); + expect(await readMembers(org), "and the purge ran").toEqual([]); + expect((await readOrganization(org))?.deletedAt).not.toBeNull(); + }); + + it("resolves an organization the mirror does not hold from WorkOS for its member, and mints it for nobody else", async () => { + const memberId = freshId("user"); + const outsider = freshId("user"); + const org = freshId("org"); + // Never seeded: the org predates the mirror and nobody has signed in to + // it since — a CLI token names it, and the JWT path has no login feeder. + const calls: string[] = []; + const workos = stubWorkOS({ + getUserOrgMembership: (organizationId, userId) => { + calls.push(`getUserOrgMembership:${userId}`); + return Effect.succeed( + userId === memberId + ? (workosMembership(userId, organizationId, { role: { slug: "admin" } }) as never) + : null, + ); + }, + getOrganization: (id) => { + calls.push(`getOrganization:${id}`); + return Effect.succeed({ + object: "organization", + id, + name: "Pre-mirror Org", + allowProfilesOutsideOrganization: false, + domains: [], + createdAt: T1, + updatedAt: T1, + externalId: null, + metadata: {}, + } as never); + }, + listOrgMembers: (organizationId) => { + calls.push(`listOrgMembers:${organizationId}`); + return Effect.succeed({ + object: "list" as const, + data: [workosMembership(memberId, org, { role: { slug: "admin" } })] as never[], + listMetadata: { before: null, after: null }, + }); + }, + getUser: (id) => { + calls.push(`getUser:${id}`); + return Effect.succeed(workosUser(id) as never); + }, + }); + + // A non-member first: WorkOS is asked for THEIR membership only, and + // nothing is minted. + expect(await authorize(outsider, org, workos)).toBeNull(); + expect(calls).toEqual([`getUserOrgMembership:${outsider}`]); + expect(await readOrganization(org), "no row for an org the caller is not in").toBeNull(); + + // The member: WorkOS confirms the membership, the org is minted and + // scanned once, and the caller is authorized from the scan's result. + const first = await authorize(memberId, org, workos); + expect(first?.memberRole).toBe("admin"); + expect(first?.name).toBe("Pre-mirror Org"); + expect(calls.slice(1)).toEqual([ + `getUserOrgMembership:${memberId}`, + `getOrganization:${org}`, + `listOrgMembers:${org}`, + `getUser:${memberId}`, + ]); + expect((await readMembers(org)).map((m) => m.accountId)).toEqual([memberId]); + + // Now held and marked: the next check reads the mirror alone. + const second = await authorize(memberId, org, workos); + expect(second?.id).toBe(org); + expect(calls, "no further WorkOS read").toHaveLength(5); + }); + + it("scans an organization the backfill never covered before authorizing from its mirror, once", async () => { + const userId = freshId("user"); + const outsider = freshId("user"); + const org = freshId("org"); + // The org row exists (mirrored lazily, or by another member's login) but + // was never scanned, and holds no membership rows at all: the caller is + // a WorkOS member the mirror has never recorded. + await seedOrganization(org); + const calls: string[] = []; + const workos = stubWorkOS({ + listOrgMembers: (organizationId, statuses) => { + calls.push(`listOrgMembers:${organizationId}`); + expect(statuses, "the scan lists every status").toEqual(["active", "pending", "inactive"]); + return Effect.succeed({ + object: "list" as const, + data: [workosMembership(userId, org, { role: { slug: "admin" } })] as never[], + listMetadata: { before: null, after: null }, + }); + }, + getUser: (id) => { + calls.push(`getUser:${id}`); + return Effect.succeed(workosUser(id) as never); + }, + }); + + const first = await authorize(userId, org, workos); + expect(first?.memberRole, "authorized from the scan's result, with the scanned role").toBe( + "admin", + ); + expect(calls, "one scan: the listing and one getUser per member").toEqual([ + `listOrgMembers:${org}`, + `getUser:${userId}`, + ]); + expect( + (await readMembers(org)).map((m) => m.accountId), + "the scan filled the mirror", + ).toEqual([userId]); + + const second = await authorize(userId, org, workos); + expect(second?.id).toBe(org); + expect(calls, "the org is now marked: the second check reads the mirror alone").toEqual([ + `listOrgMembers:${org}`, + `getUser:${userId}`, + ]); + expect( + await authorize(outsider, org, workos), + "a non-member is refused from the mirror", + ).toBeNull(); + expect(calls, "without a scan").toHaveLength(2); + }); + + it("keeps a marked org out of the organization switcher", async () => { + const userId = freshId("user"); + const live = freshId("org"); + const marked = freshId("org"); + const liveSlug = await seedMembership(userId, live, "active"); + await seedMembership(userId, marked, "active"); + await Effect.runPromise( + markOrganizationDeleted(marked).pipe( + Effect.provide(UserStoreService.Live.pipe(Layer.provide(DbService.Live))), + Effect.scoped, + ), + ); + + const response = await sessionHandler(userId)( + new Request("http://test.local/auth/organizations", { + headers: { cookie: "wos-session=sealed" }, + }), + ); + + expect(response.status).toBe(200); + const body = (await response.json()) as { organizations: { id: string; slug: string }[] }; + expect(body.organizations.map((o) => [o.id, o.slug])).toEqual([[live, liveSlug]]); + }); +}); + describe("account service writes through to the mirror", () => { const ADMIN = freshId("user"); const TARGET = freshId("user"); @@ -536,29 +1120,24 @@ describe("account service writes through to the mirror", () => { }); /** - * The provider layer over the LIVE mirror + user store (test db) and a fake - * WorkOS in which ADMIN administers `org` and TARGET is a plain member. - * `deleted` records the WorkOS-side deletes so "WorkOS first" is assertable. - * Provided around the WHOLE test body so the postgres socket outlives the - * provider call under test. + * The provider layer over the LIVE mirror + user store + directory (test db) + * and a fake WorkOS that only serves the WRITES. Membership reads — the org + * check, the admin gate, the ownership check on the target — come from the + * mirror, so `seedTarget` mirrors ADMIN as the org's admin alongside TARGET; + * any membership READ against WorkOS dies. `deleted` records the WorkOS-side + * deletes so "WorkOS first" is assertable. Provided around the WHOLE test + * body so the postgres socket outlives the provider call under test. */ - const providerLayer = (org: string, deleted: string[]) => { - const list = (data: readonly unknown[]) => - Effect.succeed({ - object: "list" as const, - data: data as never[], - listMetadata: { before: null, after: null }, - }); + const providerLayer = ( + org: string, + deleted: string[], + options: { + readonly workos?: Partial; + readonly autumn?: Layer.Layer; + } = {}, + ) => { const workos = stubWorkOS({ - listUserMemberships: (userId) => list([workosMembership(userId, org)]), - getUserOrgMembership: (organizationId, userId) => - Effect.succeed( - workosMembership(userId, organizationId, { - role: { slug: userId === ADMIN ? "admin" : "member" }, - }) as never, - ), - getOrgMembership: (membershipId) => - Effect.succeed(workosMembership(TARGET, org, { id: membershipId }) as never), + ...options.workos, deleteOrgMembership: (membershipId) => Effect.sync(() => { deleted.push(membershipId); @@ -571,7 +1150,6 @@ describe("account service writes through to the mirror", () => { updatedAt: T2, }) as never, ), - listOrgMembers: () => list([]), }); // The test database serves ONE connection at a time, so the seed, the // provider, and the directory read all share this layer's socket. @@ -579,13 +1157,14 @@ describe("account service writes through to the mirror", () => { UserStoreService.Live, WorkOsMirror.Live, cloudMemberDirectoryLayer, + readyMirror, ); return workosAccountProvider.pipe( Layer.provide( Layer.mergeAll( workos, stubApiKeys, - stubAutumn, + options.autumn ?? stubAutumn, Layer.succeed(AccountCaller)({ session: session(ADMIN) }), ), ), @@ -594,8 +1173,15 @@ describe("account service writes through to the mirror", () => { ); }; - // TARGET as an existing member of `org`, seeded through the live mirror. - const seedTarget = (org: string) => + // ADMIN as the org's admin and TARGET as an existing member of `org`, + // seeded through the live mirror — the rows the provider's membership reads + // resolve against. The org is marked backfilled (as the one-off backfill + // leaves every org) unless a test wants the unscanned state, so a seat + // count reads the mirror rather than scanning WorkOS. + const seedTarget = ( + org: string, + options: { readonly backfilled: boolean } = { backfilled: true }, + ) => Effect.gen(function* () { const users = yield* UserStoreService; const mirror = yield* WorkOsMirror; @@ -606,6 +1192,14 @@ describe("account service writes through to the mirror", () => { updatedAt: new Date(T1), }), ); + yield* mirror.upsertMembership({ + id: `om_${ADMIN}_${org}`, + accountId: ADMIN, + organizationId: org, + role: "admin", + status: "active", + updatedAt: new Date(T1), + }); yield* mirror.upsertMembership({ id: `om_${TARGET}_${org}`, accountId: TARGET, @@ -614,11 +1208,179 @@ describe("account service writes through to the mirror", () => { status: "active", updatedAt: new Date(T1), }); + if (options.backfilled) { + // An empty listing at T1 (nothing to tombstone: TARGET's row is + // stamped T1, not before it) marks the org scanned as of T1. + yield* mirror.applyOrganizationScan({ + organizationId: org, + listedAt: new Date(T1), + members: [], + }); + } }); const membersOf = (org: string) => Effect.flatMap(MemberDirectory.asEffect(), (directory) => directory.members(org)); + it.effect("inviteMember mirrors the pending membership WorkOS created for the invitee", () => { + const org = freshId("org"); + // Two people are already invited; the new invitee is a third pending + // membership, and only their user carries the invited address — with + // different casing than the admin typed, as WorkOS may store it. + const earlier = [freshId("user"), freshId("user")]; + const invitee = freshId("user"); + const invitedEmail = `${invitee}@placeholder.test`; + const userCalls: string[] = []; + // The plan gate reads the customer's plan before inviting: an unlimited + // plan so the seat cap never interferes with what is under test. + const teamAutumn = Layer.succeed(AutumnService)({ + use: () => + Effect.succeed({ + subscriptions: [{ planId: "team", status: "active" }], + } as never), + ensureCustomer: () => Effect.void, + checkExecutionBalance: () => Effect.die("invite does not check balances"), + trackExecution: () => Effect.void, + setMemberSeats: () => Effect.void, + }); + const layer = providerLayer(org, [], { + autumn: teamAutumn, + workos: { + listPendingInvitations: () => + Effect.succeed({ + object: "list" as const, + data: [] as never[], + listMetadata: { before: null, after: null }, + }), + sendInvitation: ({ email }) => + Effect.succeed({ + id: `invitation_${invitee}`, + email: email.toUpperCase(), + } as never), + listOrgMembers: (organizationId, statuses) => { + expect(organizationId).toBe(org); + expect(statuses, "only the pending set is listed").toEqual(["pending"]); + return Effect.succeed({ + object: "list" as const, + data: [...earlier, invitee].map((userId) => + workosMembership(userId, org, { status: "pending" }), + ) as never[], + listMetadata: { before: null, after: null }, + }); + }, + getUser: (userId) => + Effect.sync(() => { + userCalls.push(userId); + return workosUser(userId, { + firstName: "Invited", + lastName: "Person", + }) as never; + }), + }, + }); + return Effect.gen(function* () { + yield* seedTarget(org); + const account = yield* AccountProvider; + + const result = yield* account.inviteMember( + { [ORG_SELECTOR_HEADER]: org }, + { email: invitedEmail }, + ); + + expect(result.id).toBe(`invitation_${invitee}`); + const members = yield* membersOf(org); + const pending = members.find((m) => m.status === "pending"); + expect(pending, "the invitee appears as a pending member").toMatchObject({ + accountId: invitee, + membershipId: `om_${invitee}_${org}`, + email: invitedEmail, + name: "Invited Person", + role: "member", + }); + expect( + members.filter((m) => m.status === "pending"), + "only the invitee's pending membership is mirrored, not the other pending ones", + ).toHaveLength(1); + expect( + userCalls.sort(), + "one getUser per pending membership, bounded to the pending set", + ).toEqual([...earlier, invitee].sort()); + }).pipe(Effect.provide(layer)); + }); + + it.effect( + "inviteMember scans an organization the backfill never covered before counting its seats, once", + () => { + const org = freshId("org"); + const listed: string[] = []; + // A free plan (limit 3). The mirror holds TWO members of the org (ADMIN, + // TARGET) and the org is unmarked; WorkOS lists four. Only a count + // taken after the scan refuses the invite. + const freeAutumn = Layer.succeed(AutumnService)({ + use: () => Effect.succeed({ subscriptions: [] } as never), + ensureCustomer: () => Effect.void, + checkExecutionBalance: () => Effect.die("invite does not check balances"), + trackExecution: () => Effect.void, + setMemberSeats: () => Effect.void, + }); + const others = [freshId("user"), freshId("user")]; + const layer = providerLayer(org, [], { + autumn: freeAutumn, + workos: { + listPendingInvitations: () => + Effect.succeed({ + object: "list" as const, + data: [] as never[], + listMetadata: { before: null, after: null }, + }), + listOrgMembers: (organizationId, statuses) => { + listed.push(organizationId); + expect(statuses, "the scan lists every status, inactive included").toEqual([ + "active", + "pending", + "inactive", + ]); + return Effect.succeed({ + object: "list" as const, + data: [ADMIN, TARGET, ...others].map((userId) => + workosMembership(userId, org, { + role: { slug: userId === ADMIN ? "admin" : "member" }, + }), + ) as never[], + listMetadata: { before: null, after: null }, + }); + }, + getUser: (userId) => Effect.succeed(workosUser(userId) as never), + sendInvitation: () => + Effect.die("the plan gate refuses before WorkOS is asked to invite"), + }, + }); + return Effect.gen(function* () { + yield* seedTarget(org, { backfilled: false }); + const account = yield* AccountProvider; + const invite = () => + Effect.flip( + account.inviteMember({ [ORG_SELECTOR_HEADER]: org }, { email: "new@placeholder.test" }), + ); + + const error = yield* invite(); + expect(error).toBeInstanceOf(AccountForbidden); + expect(error).toMatchObject({ + message: expect.stringContaining("Your plan includes 3 members"), + }); + expect(listed, "the org was scanned from WorkOS before it was counted").toEqual([org]); + expect( + (yield* membersOf(org)).map((m) => m.accountId).sort(), + "and the scan filled the mirror", + ).toEqual([ADMIN, TARGET, ...others].sort()); + + const again = yield* invite(); + expect(again).toBeInstanceOf(AccountForbidden); + expect(listed, "a marked org is never scanned again").toEqual([org]); + }).pipe(Effect.provide(layer)); + }, + ); + it.effect("removeMember tombstones the mirror row after the WorkOS delete", () => { const org = freshId("org"); const deleted: string[] = []; @@ -686,6 +1448,173 @@ describe("account service writes through to the mirror", () => { expect(members.find((m) => m.accountId === TARGET)?.role).toBe("admin"); }).pipe(Effect.provide(providerLayer(org, []))); }); + + it.effect("removeMember refuses a membership id the org does not hold, before WorkOS", () => { + const org = freshId("org"); + const other = freshId("org"); + const deleted: string[] = []; + return Effect.gen(function* () { + yield* seedTarget(org); + const account = yield* AccountProvider; + + // A membership id from ANOTHER org (leaked, guessed) is not in this + // org's mirror, so the ownership check refuses it and nothing is + // deleted anywhere. + const error = yield* Effect.flip( + account.removeMember({ [ORG_SELECTOR_HEADER]: org }, `om_${TARGET}_${other}`), + ); + + expect(error).toBeInstanceOf(AccountForbidden); + expect(deleted, "the gate runs BEFORE the WorkOS delete").toEqual([]); + const members = yield* membersOf(org); + expect(members.map((m) => m.accountId).sort()).toEqual([ADMIN, TARGET].sort()); + }).pipe(Effect.provide(providerLayer(org, deleted))); + }); +}); + +describe("seat reporter", () => { + /** + * A `WorkOsMirror` answering the per-org backfill mark and recording the + * scan a reporter applies; every other operation is out of its reach. + */ + const recordingMirror = (backfilledAt: Date | null, writes: string[]) => + Layer.succeed(WorkOsMirror)({ + upsertUser: () => Effect.die("the seat reporter scans, it does not upsert one by one"), + upsertMembership: () => Effect.die("the seat reporter scans, it does not upsert one by one"), + deleteMembership: () => Effect.die("the seat reporter does not delete"), + deleteUser: () => Effect.die("the seat reporter does not delete"), + getCursor: () => Effect.die("the seat reporter does not read the cursor"), + applyPage: () => Effect.die("the seat reporter does not move the cursor"), + applyOrganizationScan: (scan) => + Effect.sync(() => { + writes.push( + `applyOrganizationScan:${scan.organizationId}:${scan.members + .map((member) => member.membership.id) + .join(",")}`, + ); + return Option.some({ + usersWritten: scan.members.length, + membershipsWritten: scan.members.length, + membershipsTombstoned: 0, + }); + }), + replayBoundary: () => Effect.die("the seat reporter does not run the reconciler"), + setReplayBoundary: () => Effect.die("the seat reporter does not record the boundary"), + backfillCompletedAt: () => Effect.die("the seat reporter does not check mirror readiness"), + markBackfillCompleted: () => Effect.die("the seat reporter does not record the completion"), + drainedAt: () => Effect.die("the seat reporter does not check mirror readiness"), + markDrained: () => Effect.die("the seat reporter does not run the reconciler"), + organizationBackfilledAt: () => Effect.succeed(backfilledAt), + } satisfies WorkOsMirrorShape); + + /** A directory holding `active` active members and one pending one. */ + const directoryWith = (org: string, active: number) => + Layer.succeed(MemberDirectory)({ + membership: () => Effect.die("the seat reporter lists, it does not look up"), + membershipById: () => Effect.die("the seat reporter lists, it does not look up"), + membershipsOf: () => Effect.die("the seat reporter lists, it does not look up"), + membersById: () => Effect.die("the seat reporter lists, it does not look up"), + findByEmail: () => Effect.die("the seat reporter lists, it does not look up"), + members: (organizationId, query) => { + expect(organizationId).toBe(org); + expect(query?.statuses, "billed seats are active members only").toEqual(["active"]); + return Effect.succeed( + Array.from({ length: active }, (_, i) => ({ + accountId: `user_${i}`, + membershipId: `om_${i}`, + organizationId, + email: null, + name: null, + avatarUrl: null, + role: "member", + status: "active" as const, + lastActiveAt: null, + })), + ); + }, + }); + + const report = ( + org: string, + backfilledAt: Date | null, + active: number, + workos: Partial = {}, + ) => + Effect.gen(function* () { + const reported: { organizationId: string; seats: number }[] = []; + const writes: string[] = []; + const recording = Layer.succeed(AutumnService)({ + use: () => Effect.die("the seat reporter sets seats, it does not read"), + ensureCustomer: () => Effect.void, + checkExecutionBalance: () => Effect.die("the seat reporter does not check balances"), + trackExecution: () => Effect.void, + setMemberSeats: (organizationId, seats) => + Effect.sync(() => { + reported.push({ organizationId, seats }); + }), + }); + yield* forkReportMemberSeats(org).pipe( + Effect.provide( + Layer.mergeAll( + recordingMirror(backfilledAt, writes), + directoryWith(org, active), + recording, + stubWorkOS(workos), + ), + ), + ); + // The Autumn call is forked; it is synchronous here, so it has landed. + return { reported, writes }; + }); + + it.effect( + "sets the active member count of a scanned organization without touching WorkOS", + () => { + const org = freshId("org"); + return Effect.gen(function* () { + const { reported, writes } = yield* report(org, new Date(T1), 3); + expect(reported).toEqual([{ organizationId: org, seats: 3 }]); + expect(writes, "a marked organization is not scanned").toEqual([]); + }); + }, + ); + + it.effect("scans an organization the backfill never covered before counting it", () => { + const org = freshId("org"); + const member = freshId("user"); + return Effect.gen(function* () { + const { reported, writes } = yield* report(org, null, 2, { + listOrgMembers: (organizationId, statuses) => { + expect(organizationId).toBe(org); + // Inactive memberships included: a scan that skipped them would + // tombstone them under their ids and refuse their reactivation. + expect(statuses).toEqual(["active", "pending", "inactive"]); + return Effect.succeed({ + object: "list" as const, + data: [workosMembership(member, org)] as never[], + listMetadata: { before: null, after: null }, + }); + }, + getUser: (userId) => Effect.succeed(workosUser(userId) as never), + }); + expect( + writes, + "the scan fills the mirror and marks the organization, then the count is read", + ).toEqual([`applyOrganizationScan:${org}:om_${member}_${org}`]); + expect(reported).toEqual([{ organizationId: org, seats: 2 }]); + }); + }); + + it.effect("pushes no count when the scan fails: a partial count is never billed", () => { + const org = freshId("org"); + return Effect.gen(function* () { + const { reported, writes } = yield* report(org, null, 2, { + listOrgMembers: () => Effect.fail(new WorkOSError({ status: 503 })), + }); + expect(reported).toEqual([]); + expect(writes, "nothing is marked").toEqual([]); + }); + }); }); describe("backfill", () => { diff --git a/apps/cloud/src/auth/mirror-feeders.ts b/apps/cloud/src/auth/mirror-feeders.ts index 0dc825e9b3..3b7ab3ec11 100644 --- a/apps/cloud/src/auth/mirror-feeders.ts +++ b/apps/cloud/src/auth/mirror-feeders.ts @@ -5,14 +5,21 @@ // Each feeder takes the WorkOS payload the caller ALREADY holds (the // authenticated user, the membership list the callback fetches to pick a // landing org, the membership a write returned) so feeding the mirror never -// adds a WorkOS read. Mirror failures fail the request: the mirror is the -// membership read path, so a login that could not record its memberships is -// not a login that finished. +// adds a WorkOS read — except the two writes whose WorkOS response is not the +// membership they changed: invitation acceptance (`auth/handlers.ts` reads +// the activated membership back) and sending an invitation +// (`mirrorInvitedMember` below reads the pending one WorkOS created). Both +// are rare, admin-driven paths. Mirror failures fail the request: the mirror +// is the membership read path, so a login that could not record its +// memberships is not a login that finished. // --------------------------------------------------------------------------- import { Effect } from "effect"; +import { normalizeAdminUserEmail } from "@executor-js/api/server"; + import { UserStoreService } from "./context"; +import { WorkOSClient } from "./workos"; import { WorkOsMirror, mirrorMembershipFromWorkOs, @@ -20,6 +27,7 @@ import { type WorkOsMembershipPayload, type WorkOsUserPayload, } from "./workos-mirror"; +import { backfillOrganization } from "./workos-mirror-backfill"; /** * A membership as WorkOS lists it for a user: carries the organization's name, @@ -78,3 +86,101 @@ export const mirrorMembership = (membership: WorkOsMembershipPayload) => Effect.flatMap(WorkOsMirror.asEffect(), (mirror) => mirror.upsertMembership(mirrorMembershipFromWorkOs(membership)), ); + +// Bounded fan-out for the per-invitee `getUser` calls, matching the backfill: +// enough to overlap WorkOS round-trips, low enough to stay clear of its rate +// limit. +const USER_FETCH_CONCURRENCY = 5; + +/** + * Record the PENDING membership WorkOS creates for an invitee the moment an + * organization invites them — the row the member list shows as "Invited" and + * the admin revokes an outstanding invite through. `sendInvitation` returns + * the invitation, not that membership, so this reads it back: it lists the + * organization's pending memberships (WorkOS has no lookup by email that the + * emulator serves) and fetches their users, five at a time, until one carries + * the invited email. Bounded by the pending set, so an organization with + * many active members pays nothing per member. + * + * `false` when no pending membership carried the email — WorkOS created none + * (the address may already hold a membership) or has not yet — which the + * caller treats as a warning, not a failure: the Events reconciler lands + * whatever WorkOS did create. + */ +export const mirrorInvitedMember = Effect.fn("workos_mirror.invitedMember")(function* ( + organizationId: string, + invitedEmail: string, +) { + const workos = yield* WorkOSClient; + const mirror = yield* WorkOsMirror; + const wanted = normalizeAdminUserEmail(invitedEmail); + const pending = yield* workos.listOrgMembers(organizationId, ["pending"]); + for (let start = 0; start < pending.data.length; start += USER_FETCH_CONCURRENCY) { + const batch = pending.data.slice(start, start + USER_FETCH_CONCURRENCY); + const candidates = yield* Effect.forEach( + batch, + (membership) => + Effect.map(workos.getUser(membership.userId), (user) => ({ + membership, + user, + })), + { concurrency: USER_FETCH_CONCURRENCY }, + ); + const match = candidates.find( + (candidate) => normalizeAdminUserEmail(candidate.user.email) === wanted, + ); + if (match === undefined) continue; + yield* mirror.upsertUser(mirrorUserFromWorkOs(match.user)); + yield* mirror.upsertMembership(mirrorMembershipFromWorkOs(match.membership)); + return true; + } + return false; +}); + +/** + * Make sure the organization's membership list has been scanned from WorkOS + * in full before a COUNT read from the mirror is trusted. Login records only + * the caller's own memberships and write-through only the one it changed, + * so an organization the one-off backfill did not cover — mirrored lazily + * by a request, or created after the backfill ran — holds a partial list + * until it is scanned. The per-organization mark + * (`organizations.backfilled_at`) says whether that scan has happened; when + * it is missing, this runs the scan now (`backfillOrganization`: one + * membership listing plus one `getUser` per member, then the mark), so the + * caller's count is complete. Returns `true` when a scan ran. A scan that + * fails marks nothing, so the next count tries again. + */ +export const ensureOrganizationBackfilled = Effect.fn("workos_mirror.ensureOrganizationBackfilled")( + function* (organizationId: string) { + const mirror = yield* WorkOsMirror; + const backfilledAt = yield* mirror.organizationBackfilledAt(organizationId); + if (backfilledAt !== null) return false; + const workos = yield* WorkOSClient; + yield* Effect.logInfo( + "workos_mirror: organization not yet backfilled; scanning it from WorkOS", + { + organizationId, + }, + ); + yield* backfillOrganization( + { + // EVERY status, as the scan source requires: the scan tombstones + // whatever its listing lacks, and a tombstone is keyed to the + // membership id for good — so a listing that skipped the inactive + // ones (the wrapper's active + pending default, the seat-occupying + // set) would tombstone a membership WorkOS merely deactivated and + // refuse its reactivation under the same id forever. + listOrgMembers: (id) => + Effect.map( + workos.listOrgMembers(id, ["active", "pending", "inactive"]), + (list) => list.data, + ), + getUser: (id) => workos.getUser(id), + }, + mirror, + organizationId, + { dryRun: false }, + ); + return true; + }, +); diff --git a/apps/cloud/src/auth/mirror-readiness-store.ts b/apps/cloud/src/auth/mirror-readiness-store.ts new file mode 100644 index 0000000000..c2e65524cd --- /dev/null +++ b/apps/cloud/src/auth/mirror-readiness-store.ts @@ -0,0 +1,116 @@ +// --------------------------------------------------------------------------- +// Mirror READINESS: whether the local membership mirror may be trusted as +// the membership authority for a request, or WorkOS must still be asked. +// +// The mirror is fed by login, write-through, and the Events API reconciler +// (`workos-mirror.ts`), and is complete only once the one-off backfill has +// written every organization and the reconciler has caught up to the +// present. Before that, two things go wrong if it is trusted anyway: +// - a member who has not signed in since the mirror shipped has no row +// yet, and every protected request of theirs is refused — the backfill +// is what writes them; +// - a member revoked in the WorkOS dashboard while the reconciler was not +// running still holds an active row, and keeps their access until the +// stream is replayed — the reconciler is what tombstones them. +// So readiness is BOTH: the backfill's completion mark +// (`workos_sync.backfill_completed_at`, written once by a run that covered +// every live organization) AND a recent drain of the events stream +// (`workos_sync.drained_at`, moved forward by every reconciler run that read +// the stream to its end). The lag budget bounds how far behind the reconciler +// may be: it runs every minute, so a mark older than the budget means it has +// stalled (WorkOS unreachable, the cron not deployed, a backlog draining over +// many runs) and the mirror may be missing revocations. While either half is +// missing the authorization path reads membership from WorkOS instead +// (`organization.ts`), exactly as it did before the cutover; nothing is +// denied or granted on the mirror's word. +// +// The rule and the row read live here, free of `cloudflare:workers`, so the +// deploy gate (`scripts/ensure-workos-mirror-ready.ts`) applies the SAME rule +// over a plain postgres.js connection under bun before the build that trusts +// the mirror goes live. The request-scoped service is `mirror-readiness.ts`. +// --------------------------------------------------------------------------- + +import { eq } from "drizzle-orm"; +import { Data, Duration } from "effect"; + +import type { DrizzleDb } from "../db/db"; +import { workosSync } from "../db/schema"; +import { WORKOS_EVENTS_STREAM_ID } from "./workos-mirror-store"; + +/** + * How far behind the present the reconciler's last drain may be before the + * mirror stops being trusted. The reconciler runs every minute and a healthy + * run drains in one tick; ten minutes absorbs a few missed ticks and a short + * WorkOS blip without falling back, and bounds how long a dashboard-side + * revocation could go unseen if it did. + */ +export const MIRROR_RECONCILER_LAG_BUDGET = Duration.minutes(10); + +/** + * What the readiness check found. `Ready` is the only state in which the + * mirror authorizes; the other two name which half is missing so the fallback + * can be logged with its cause. + */ +export type MirrorReadinessState = Data.TaggedEnum<{ + readonly Ready: {}; + /** No backfill run has covered every organization yet. */ + readonly BackfillPending: {}; + /** The backfill is done but the reconciler has not drained within the budget (`drainedAt` null = never). */ + readonly ReconcilerStale: { readonly drainedAt: Date | null }; +}>; +export const MirrorReadinessState = Data.taggedEnum(); + +/** The two `workos_sync` columns the rule reads, as the events row holds them (or no row at all). */ +export interface MirrorReadinessRow { + readonly backfillCompletedAt: Date | null; + readonly drainedAt: Date | null; +} + +/** + * The readiness rule over the events row as of `now`: ready when the + * backfill has completed AND the last drain is within + * {@link MIRROR_RECONCILER_LAG_BUDGET} of `now`. A missing row is a mirror + * that was never backfilled. Pure, so the deploy gate and the request path + * cannot disagree. + */ +export const mirrorReadinessFrom = ( + row: MirrorReadinessRow | null, + now: Date, +): MirrorReadinessState => { + if (row === null || row.backfillCompletedAt === null) + return MirrorReadinessState.BackfillPending(); + const drainedAt = row.drainedAt; + if ( + drainedAt === null || + now.getTime() - drainedAt.getTime() > Duration.toMillis(MIRROR_RECONCILER_LAG_BUDGET) + ) { + return MirrorReadinessState.ReconcilerStale({ drainedAt }); + } + return MirrorReadinessState.Ready(); +}; + +/** Read the events row's readiness columns and apply {@link mirrorReadinessFrom} as of `now`. */ +export const readMirrorReadiness = async ( + db: DrizzleDb, + now: Date, +): Promise => { + const rows = await db + .select({ + backfillCompletedAt: workosSync.backfillCompletedAt, + drainedAt: workosSync.drainedAt, + }) + .from(workosSync) + .where(eq(workosSync.id, WORKOS_EVENTS_STREAM_ID)); + return mirrorReadinessFrom(rows[0] ?? null, now); +}; + +/** One line naming the state, for logs and the deploy gate; never carries member data. */ +export const describeMirrorReadiness = (state: MirrorReadinessState): string => + MirrorReadinessState.$match(state, { + Ready: () => "ready", + BackfillPending: () => "backfill pending: no backfill run has covered every organization yet", + ReconcilerStale: ({ drainedAt }) => + drainedAt === null + ? "reconciler stale: the events reconciler has never drained the stream" + : `reconciler stale: the events stream was last drained at ${drainedAt.toISOString()}, past the ${Duration.format(MIRROR_RECONCILER_LAG_BUDGET)} budget`, + }); diff --git a/apps/cloud/src/auth/mirror-readiness.ts b/apps/cloud/src/auth/mirror-readiness.ts new file mode 100644 index 0000000000..1bf8ac05de --- /dev/null +++ b/apps/cloud/src/auth/mirror-readiness.ts @@ -0,0 +1,69 @@ +// --------------------------------------------------------------------------- +// MirrorReadiness — the request-scoped service that answers whether the +// membership mirror may authorize this request (see +// `mirror-readiness-store.ts` for the rule and why it exists). +// +// Per-request layer shape, like `UserStoreService` and `WorkOsMirror`: it +// reads the request's postgres socket, so it is rebuilt per request +// (`RequestScopedServicesLive`) and never shared across Workers requests. One +// indexed point read per authorization, on the same socket the membership +// read uses next. +// --------------------------------------------------------------------------- + +import { Clock, Context, Effect, Layer } from "effect"; + +import { DbService, type DrizzleDb } from "../db/db"; +import { + WorkOsMirrorError, + tryPromiseService, + userStoreReasonFromCause, + withServiceLogging, +} from "./errors"; +import { readMirrorReadiness, type MirrorReadinessState } from "./mirror-readiness-store"; + +export { + MIRROR_RECONCILER_LAG_BUDGET, + MirrorReadinessState, + describeMirrorReadiness, + mirrorReadinessFrom, + type MirrorReadinessRow, +} from "./mirror-readiness-store"; + +export interface MirrorReadinessShape { + /** + * The mirror's readiness as of now. Fails with `WorkOsMirrorError` when the + * row cannot be read — the caller must not treat that as either ready or + * not; it is the same infra failure as any other mirror read. + */ + readonly state: () => Effect.Effect; +} + +const makeService = (db: DrizzleDb): MirrorReadinessShape => ({ + state: () => + Effect.flatMap(Clock.currentTimeMillis, (millis) => + withServiceLogging( + "workos_mirror.readiness", + (failure) => + new WorkOsMirrorError({ + operation: "readiness", + reason: userStoreReasonFromCause(failure), + }), + tryPromiseService(() => readMirrorReadiness(db, new Date(millis))), + ), + ), +}); + +export class MirrorReadiness extends Context.Service()( + "@executor-js/cloud/MirrorReadiness", +) { + static Live = Layer.effect(this)(Effect.map(DbService.asEffect(), ({ db }) => makeService(db))); +} + +/** + * A FRESH `MirrorReadiness` layer (new layer value per call), for a service + * built once but invoked across many Workers requests — the MCP + * org-authorization seam and the document gate — for the same reason + * `makeUserStoreLayer` exists. See [[makeDbLayer]]. + */ +export const makeMirrorReadinessLayer = (): Layer.Layer => + Layer.effect(MirrorReadiness)(Effect.map(DbService.asEffect(), ({ db }) => makeService(db))); diff --git a/apps/cloud/src/auth/org-api-key-auth.node.test.ts b/apps/cloud/src/auth/org-api-key-auth.node.test.ts index 14e2006f55..5358e75994 100644 --- a/apps/cloud/src/auth/org-api-key-auth.node.test.ts +++ b/apps/cloud/src/auth/org-api-key-auth.node.test.ts @@ -1,9 +1,13 @@ import { describe, expect, it } from "@effect/vitest"; -import { Effect, Layer } from "effect"; +import { Cause, Effect, Exit, Layer } from "effect"; + +import { MemberDirectory, NoOrganization } from "@executor-js/api/server"; import { ApiKeyService } from "./api-keys"; import { UserStoreService } from "./context"; +import { MirrorReadiness, MirrorReadinessState } from "./mirror-readiness"; import { WorkOSClient, type WorkOSClientService } from "./workos"; +import { WorkOsMirror, type WorkOsMirrorShape } from "./workos-mirror"; import { isPlatformAuth, resolveApiKeyPrincipal, resolveBearerAuth } from "./workos-auth-provider"; // Groundwork for the PRIVILEGED, org-level API key: it resolves to the platform @@ -58,22 +62,45 @@ const stubWorkOS = Layer.succeed( WorkOSClient, new Proxy({} as WorkOSClientService, { get: (_target, prop) => { - if (prop === "listUserMemberships") { - return (userId: string) => - Effect.succeed({ - data: - userId === "user_123" - ? [{ userId, organizationId: "org_123", status: "active" }] - : [], - }); - } - // An org key must NOT trigger a membership check — there is no user to - // check. Any such call dies here, which is the assertion. + // Membership is read from the mirror, never from WorkOS; any WorkOS call + // dies here. return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); }, }), ); +// The mirror as the directory reads it: user_123 holds an active membership in +// org_123 and nothing else. Membership is never read from WorkOS. +// The mirror is READY in these tests (backfill complete, reconciler caught +// up), so membership is read from the stubbed directory, never from WorkOS. +const stubReadiness = Layer.succeed(MirrorReadiness)({ + state: () => Effect.succeed(MirrorReadinessState.Ready()), +}); + +const stubDirectory = Layer.succeed(MemberDirectory)({ + membership: (accountId, organizationId) => + Effect.succeed( + accountId === "user_123" && organizationId === "org_123" + ? { + accountId, + membershipId: `om_${accountId}_${organizationId}`, + organizationId, + email: null, + name: null, + avatarUrl: null, + role: "member", + status: "active" as const, + lastActiveAt: null, + } + : null, + ), + membershipById: () => Effect.die("bearer resolution does not look up by membership id"), + membershipsOf: () => Effect.die("bearer resolution reads one membership, not the list"), + members: () => Effect.die("bearer resolution does not list members"), + membersById: () => Effect.die("bearer resolution does not batch members"), + findByEmail: () => Effect.die("bearer resolution does not resolve emails"), +}); + const stubUsers = Layer.succeed(UserStoreService)({ use: (_op, fn) => Effect.promise(() => @@ -83,7 +110,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ upsertOrganization: async (org: { id: string; name: string }) => ({ ...org, slug: `org-slug-${org.id}`, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, @@ -92,7 +119,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ id, name: `Org ${id}`, slug: `org-slug-${id}`, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, @@ -101,17 +128,35 @@ const stubUsers = Layer.succeed(UserStoreService)({ id: "org_by_slug", name: `Org ${slug}`, slug, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, }), + markOrganizationDeleted: async () => null, deleteOrganizationCascade: async () => {}, }), ), }); -const layers = Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers); +// Authorization scans an organization the backfill never covered before it +// reads the mirror (`auth/organization.ts`); every org row above is marked +// backfilled, so the scan is never reached and the mirror is never written. +const stubMirror = Layer.succeed( + WorkOsMirror, + new Proxy({} as WorkOsMirrorShape, { + get: (_target, prop) => () => Effect.die(`unexpected WorkOsMirror.${String(prop)} call`), + }), +); + +const layers = Layer.mergeAll( + stubApiKeys, + stubWorkOS, + stubUsers, + stubDirectory, + stubMirror, + stubReadiness, +); const bearer = (token: string) => new Request("https://executor.test/api/tools", { @@ -137,6 +182,67 @@ describe("org-level API keys", () => { }), ); + it.effect("are refused once the org is marked deleted", () => + Effect.gen(function* () { + const deletedOrgUsers = Layer.succeed(UserStoreService)({ + use: (_op, fn) => + Effect.promise(() => + fn({ + ensureAccount: async (id: string) => bareAccount(id), + getAccount: async (id: string) => bareAccount(id), + upsertOrganization: async (org: { id: string; name: string }) => ({ + ...org, + slug: `org-slug-${org.id}`, + backfilledAt: createdAt, + deletedAt: createdAt, + workosUpdatedAt: null, + createdAt, + }), + getOrganization: async (id: string) => ({ + id, + name: `Org ${id}`, + slug: `org-slug-${id}`, + backfilledAt: createdAt, + deletedAt: createdAt, + workosUpdatedAt: null, + createdAt, + }), + getOrganizationBySlug: async (slug: string) => ({ + id: "org_by_slug", + name: `Org ${slug}`, + slug, + backfilledAt: createdAt, + deletedAt: createdAt, + workosUpdatedAt: null, + createdAt, + }), + markOrganizationDeleted: async () => null, + deleteOrganizationCascade: async () => {}, + }), + ), + }); + const exit = yield* Effect.exit( + resolveBearerAuth(bearer("valid_org_key")).pipe( + Effect.provide( + Layer.mergeAll( + stubApiKeys, + stubWorkOS, + deletedOrgUsers, + stubDirectory, + stubMirror, + stubReadiness, + ), + ), + ), + ); + expect(Exit.isFailure(exit)).toBe(true); + expect( + Exit.isFailure(exit) ? Cause.squash(exit.cause) : null, + "the key outlives the org until the purge; a marked org refuses it", + ).toBeInstanceOf(NoOrganization); + }), + ); + it.effect("user keys still resolve to a bound member principal", () => Effect.gen(function* () { const auth = yield* resolveBearerAuth(bearer("valid_user_key")).pipe(Effect.provide(layers)); @@ -173,10 +279,29 @@ describe("org-level API keys", () => { it.effect("do not trigger a user membership check", () => Effect.gen(function* () { - // `authorizeOrganization` checks a USER's live membership; there is no - // user here. The WorkOS stub dies on any call other than the user path, - // so a clean resolution proves the org branch never took it. - const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe(Effect.provide(layers)); + // `authorizeOrganization` checks a USER's membership; there is no user + // here. A directory whose `membership` dies proves the org branch never + // asked. + const noMembershipReads = Layer.succeed(MemberDirectory)({ + membership: () => Effect.die("an org key must not trigger a membership check"), + membershipById: () => Effect.die("an org key must not trigger a membership check"), + membershipsOf: () => Effect.die("an org key must not trigger a membership check"), + members: () => Effect.die("an org key must not trigger a membership check"), + membersById: () => Effect.die("an org key must not trigger a membership check"), + findByEmail: () => Effect.die("an org key must not trigger a membership check"), + }); + const auth = yield* resolveBearerAuth(bearer("valid_org_key")).pipe( + Effect.provide( + Layer.mergeAll( + stubApiKeys, + stubWorkOS, + stubUsers, + noMembershipReads, + stubMirror, + stubReadiness, + ), + ), + ); expect(isPlatformAuth(auth)).toBe(true); }), diff --git a/apps/cloud/src/auth/org-selector-auth.node.test.ts b/apps/cloud/src/auth/org-selector-auth.node.test.ts index 050b483a0e..0cdaca2d1c 100644 --- a/apps/cloud/src/auth/org-selector-auth.node.test.ts +++ b/apps/cloud/src/auth/org-selector-auth.node.test.ts @@ -1,18 +1,22 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; +import { MemberDirectory, type DirectoryMember } from "@executor-js/api/server"; + import { ApiKeyService } from "./api-keys"; import { UserStoreService } from "./context"; +import { MirrorReadiness, MirrorReadinessState } from "./mirror-readiness"; import { resolveSessionPrincipal } from "./workos-auth-provider"; import { WorkOSClient, type WorkOSClientService } from "./workos"; +import { WorkOsMirror, type WorkOsMirrorShape } from "./workos-mirror"; // The org a console request resolves to is the URL's org (sent in the // `x-executor-organization` selector header) — NEVER the session's stored org. // The sealed cookie's org is a browser-global pinned to whichever org WorkOS // last touched, so a fallback to it silently scopes a multi-org user's request -// to the wrong org; a header-less request fails closed instead. Live -// membership is re-checked either way. This is what makes two browser tabs on -// different orgs independent. +// to the wrong org; a header-less request fails closed instead. Membership is +// re-checked against the local mirror either way. This is what makes two +// browser tabs on different orgs independent. const createdAt = new Date("2026-01-01T00:00:00.000Z"); @@ -30,10 +34,53 @@ const bareAccount = (id: string) => ({ }); // user_session belongs to BOTH orgs; the URL selects which one a request hits. +// Their membership in PENDING_ORG is only pending — an invite, not access. const MEMBER = "user_session"; const SESSION_ORG = "org_session"; const URL_ORG = "org_url"; +const PENDING_ORG = "org_pending"; const URL_SLUG = "acme"; +const PENDING_SLUG = "pending-acme"; + +const mirrored = ( + organizationId: string, + overrides: Partial = {}, +): DirectoryMember => ({ + accountId: MEMBER, + membershipId: `om_${MEMBER}_${organizationId}`, + organizationId, + email: null, + name: null, + avatarUrl: null, + role: "member", + status: "active", + lastActiveAt: null, + ...overrides, +}); + +// The mirror as the directory reads it: MEMBER is active in both real orgs, +// an admin of URL_ORG, and merely invited to PENDING_ORG. +const memberships = new Map([ + [SESSION_ORG, mirrored(SESSION_ORG)], + [URL_ORG, mirrored(URL_ORG, { role: "admin" })], + [PENDING_ORG, mirrored(PENDING_ORG, { status: "pending" })], +]); + +// The mirror is READY in these tests (backfill complete, reconciler caught +// up), so membership is read from the stubbed directory, never from WorkOS. +const stubReadiness = Layer.succeed(MirrorReadiness)({ + state: () => Effect.succeed(MirrorReadinessState.Ready()), +}); + +const stubDirectory = Layer.succeed(MemberDirectory)({ + membership: (accountId, organizationId) => + Effect.succeed(accountId === MEMBER ? (memberships.get(organizationId) ?? null) : null), + membershipById: () => Effect.die("session resolution does not look up by membership id"), + membershipsOf: () => Effect.die("session resolution reads one membership, not the list"), + members: () => Effect.die("session resolution does not list members"), + membersById: () => Effect.die("session resolution does not batch members"), + findByEmail: () => Effect.die("session resolution does not resolve emails"), +}); const stubApiKeys = Layer.succeed(ApiKeyService)({ // No Authorization header in these tests → the api-key path returns null and @@ -59,18 +106,8 @@ const stubWorkOS = Layer.succeed( organizationId: SESSION_ORG, }); } - if (prop === "listUserMemberships") { - return (userId: string) => - Effect.succeed({ - data: - userId === MEMBER - ? [ - { userId, organizationId: SESSION_ORG, status: "active" }, - { userId, organizationId: URL_ORG, status: "active" }, - ] - : [], - }); - } + // Membership is read from the mirror, never from WorkOS: any WorkOS + // call past session authentication fails the test. return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); }, }), @@ -86,7 +123,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ upsertOrganization: async (org: { id: string; name: string }) => ({ ...org, slug: org.id, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, @@ -95,32 +132,124 @@ const stubUsers = Layer.succeed(UserStoreService)({ id, name: `Org ${id}`, slug: id, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, }), - // The URL slug maps to URL_ORG (the member's other org); any other slug - // maps to an org the caller is NOT a member of, so membership rejects it. + // The URL slug maps to URL_ORG (the member's other org), the pending + // slug to the org they are only invited to; any other slug maps to an + // org the caller is NOT a member of, so membership rejects it. getOrganizationBySlug: async (slug: string) => ({ - id: slug === URL_SLUG ? URL_ORG : "org_outsider", + id: slug === URL_SLUG ? URL_ORG : slug === PENDING_SLUG ? PENDING_ORG : "org_outsider", name: `Org ${slug}`, slug, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, }), + markOrganizationDeleted: async () => null, deleteOrganizationCascade: async () => {}, }), ), }); -const run = (headers: Record) => +// Authorization scans an organization the backfill never covered before it +// reads the mirror (`auth/organization.ts`); every org row above is marked +// backfilled, so the scan is never reached and the mirror is never written. +const stubMirror = Layer.succeed( + WorkOsMirror, + new Proxy({} as WorkOsMirrorShape, { + get: (_target, prop) => () => Effect.die(`unexpected WorkOsMirror.${String(prop)} call`), + }), +); + +const run = ( + headers: Record, + readiness: Layer.Layer = stubReadiness, +) => resolveSessionPrincipal(new Request("https://executor.test/api/tools", { headers })).pipe( - Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers)), + Effect.provide( + Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror, readiness), + ), ); +// The mirror before the cutover has landed: the backfill has not covered +// every organization, or the reconciler has not drained recently. +const unreadyMirror = (state: MirrorReadinessState) => + Layer.succeed(MirrorReadiness)({ state: () => Effect.succeed(state) }); + +/** + * A WorkOS that answers the pre-cutover membership list for MEMBER — active + * in SESSION_ORG only, as a member — and records the calls, so the fallback + * is assertable: with the mirror unready the list is read from WorkOS and + * the directory (which says MEMBER is active in URL_ORG too) is never asked. + */ +const workosMemberships = (calls: string[]) => + Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_t, prop) => { + if (prop === "authenticateRequest") { + return () => + Effect.succeed({ + userId: MEMBER, + email: "u@e2e.test", + organizationId: SESSION_ORG, + }); + } + if (prop === "listUserMemberships") { + return (userId: string) => + Effect.sync(() => { + calls.push(`listUserMemberships:${userId}`); + return { + object: "list" as const, + data: [ + { + id: `om_${MEMBER}_${SESSION_ORG}`, + userId: MEMBER, + organizationId: SESSION_ORG, + status: "active", + role: { slug: "member" }, + }, + ] as never[], + listMetadata: { before: null, after: null }, + }; + }); + } + return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); + }, + }), + ); + +const unreadDirectory = Layer.succeed(MemberDirectory)({ + membership: () => Effect.die("an unready mirror must not be asked for membership"), + membershipById: () => Effect.die("an unready mirror must not be asked for membership"), + membershipsOf: () => Effect.die("an unready mirror must not be asked for membership"), + members: () => Effect.die("an unready mirror must not be asked for membership"), + membersById: () => Effect.die("an unready mirror must not be asked for membership"), + findByEmail: () => Effect.die("an unready mirror must not be asked for membership"), +}); + +const runAgainstWorkOs = (headers: Record, state: MirrorReadinessState) => { + const calls: string[] = []; + return resolveSessionPrincipal(new Request("https://executor.test/api/tools", { headers })) + .pipe( + Effect.provide( + Layer.mergeAll( + stubApiKeys, + workosMemberships(calls), + stubUsers, + unreadDirectory, + stubMirror, + unreadyMirror(state), + ), + ), + ) + .pipe(Effect.map((principal) => ({ principal, calls }))); +}; + describe("resolveSessionPrincipal · URL org selector", () => { it.effect("fails closed when no selector header is sent", () => Effect.gen(function* () { @@ -141,6 +270,23 @@ describe("resolveSessionPrincipal · URL org selector", () => { "x-executor-organization": URL_SLUG, }); expect(principal.organizationId, "the slug header wins over the session org").toBe(URL_ORG); + expect(principal.orgRole, "the mirrored role binds the executor's write authority").toBe( + "admin", + ); + }), + ); + + it.effect("rejects a selector for an org where the membership is only pending", () => + Effect.gen(function* () { + // An invite is mirrored as a pending membership; it grants no access + // until accepted. + const error = yield* Effect.flip( + run({ + cookie: "wos-session=x", + "x-executor-organization": PENDING_SLUG, + }), + ); + expect(error).toMatchObject({ _tag: "NoOrganization" }); }), ); @@ -168,3 +314,55 @@ describe("resolveSessionPrincipal · URL org selector", () => { }), ); }); + +// The mirror authorizes only while it is READY (`mirror-readiness.ts`): +// the backfill has written every organization and the reconciler has drained +// the stream within its lag budget. Until then the membership check reads +// WorkOS, as it did before the cutover — so a member the backfill has not +// written yet is not locked out, and a stale mirror row cannot grant access +// WorkOS has revoked. +describe("resolveSessionPrincipal · mirror readiness", () => { + const unready: readonly [string, MirrorReadinessState][] = [ + ["the backfill has not completed", MirrorReadinessState.BackfillPending()], + ["the reconciler has never drained", MirrorReadinessState.ReconcilerStale({ drainedAt: null })], + [ + "the reconciler's last drain is older than the budget", + MirrorReadinessState.ReconcilerStale({ drainedAt: createdAt }), + ], + ]; + + for (const [why, state] of unready) { + it.effect(`reads membership from WorkOS, not the mirror, while ${why}`, () => + Effect.gen(function* () { + // WorkOS says MEMBER is active in SESSION_ORG: authorized there... + const granted = yield* runAgainstWorkOs( + { cookie: "wos-session=x", "x-executor-organization": SESSION_ORG }, + state, + ); + expect(granted.principal.organizationId).toBe(SESSION_ORG); + expect(granted.principal.orgRole, "the role comes from WorkOS's list too").toBe("member"); + expect(granted.calls).toEqual([`listUserMemberships:${MEMBER}`]); + + // ...and NOT in URL_ORG, even though the (unready) mirror holds an + // active admin membership there: the mirror's word is not taken. + const refused = yield* Effect.flip( + runAgainstWorkOs({ cookie: "wos-session=x", "x-executor-organization": URL_SLUG }, state), + ); + expect(refused).toMatchObject({ _tag: "NoOrganization" }); + }), + ); + } + + it.effect("reads the mirror once it is ready, without any WorkOS membership call", () => + Effect.gen(function* () { + // `stubWorkOS` dies on any call past session authentication, so a + // resolved principal here proves the list was never requested. + const principal = yield* run( + { cookie: "wos-session=x", "x-executor-organization": URL_SLUG }, + stubReadiness, + ); + expect(principal.organizationId).toBe(URL_ORG); + expect(principal.orgRole).toBe("admin"); + }), + ); +}); diff --git a/apps/cloud/src/auth/organization.ts b/apps/cloud/src/auth/organization.ts index 68d70fad28..2349c59669 100644 --- a/apps/cloud/src/auth/organization.ts +++ b/apps/cloud/src/auth/organization.ts @@ -3,7 +3,8 @@ // // One module for the cloud org auth-resolution path: // - `resolveOrganization` — local mirror with lazy WorkOS fallback. -// - `authorizeOrganization` — live membership check, returns the resolved org. +// - `authorizeOrganization` — membership check against the local membership +// mirror, returns the resolved org. // // Deliberately billing-FREE: this module is reached by the MCP session DO bundle // (via `mcp/auth.ts`), which must not transitively import any billing config @@ -11,10 +12,14 @@ // which DO depend on the Autumn plan config — live in `extensions/billing/plans.ts`. // --------------------------------------------------------------------------- -import { Effect } from "effect"; +import { Clock, Effect } from "effect"; +import { MemberDirectory } from "@executor-js/api/server"; import { EXECUTOR_ORG_SELECTOR_HEADER } from "@executor-js/sdk/shared"; import { UserStoreService } from "./context"; +import { ensureOrganizationBackfilled } from "./mirror-feeders"; +import { MirrorReadiness, MirrorReadinessState, describeMirrorReadiness } from "./mirror-readiness"; +import type { Organization } from "./user-store"; import { WorkOSClient } from "./workos"; // --------------------------------------------------------------------------- @@ -53,53 +58,225 @@ export const resolveOrganization = (organizationId: string) => }); // --------------------------------------------------------------------------- -// Authorization — live membership check against WorkOS. +// Deletion mark — the local step that revokes an organization. +// --------------------------------------------------------------------------- +// +// Membership is authorized from the local mirror (below), so deleting the +// WorkOS organization revokes nothing here by itself: the local membership +// rows keep authorizing sessions until the local purge removes them, and a +// purge that fails leaves them live. This mark is what revokes access, and it +// is the FIRST step of cloud's deletion flow (`auth/handlers.ts` +// deleteOrganization) — before the WorkOS delete and the purge, both of which +// can fail — and what the `organization.deleted` event applies +// (`workos-events-sync.ts`) when the org was deleted in the WorkOS dashboard +// instead. Membership rows are left as they are; the mark alone refuses them. +// Idempotent: a retry keeps the first mark. An org the mirror does not hold +// is not marked (nothing to revoke), and `false` says so. + +export const markOrganizationDeleted = (organizationId: string) => + Effect.gen(function* () { + const users = yield* UserStoreService; + const at = new Date(yield* Clock.currentTimeMillis); + const marked = yield* users.use("markOrganizationDeleted", (s) => + s.markOrganizationDeleted(organizationId, at), + ); + return marked !== null; + }); + +// --------------------------------------------------------------------------- +// Authorization — membership check against the local membership mirror. // --------------------------------------------------------------------------- // // The sealed session cookie carries an organizationId that WorkOS signed at // login / refresh time. WorkOS does NOT invalidate existing sessions when a // membership is revoked, and `session.authenticate()` validates the JWT -// locally without hitting the API — so a removed user keeps full access -// until their access token naturally expires (~10 min). +// locally without hitting the API — so a removed user would keep full access +// until their access token naturally expired (~10 min) if the session were +// trusted on its own. +// +// To close that gap, membership is verified on every protected request — but +// against the LOCAL mirror of WorkOS memberships (`memberships` join +// `accounts`, read through the shared `MemberDirectory`), never against WorkOS +// itself. This used to be one `listUserMemberships` call per request (2026-07: +// deliberately NOT cached, because a positive TTL cache is exactly what would +// re-open the revocation gap). The mirror is not a cache with a TTL; it is a +// replica whose freshness is defined by its feeders: +// - login (`auth/handlers.ts` callback): the user and every membership WorkOS +// lists for them, from the list the callback already fetches; +// - write-through: every membership change Executor makes (create org, +// invite, accept, remove, change role) lands in the mirror in the same +// request, so a revocation through Executor is denied on the NEXT request; +// - the WorkOS Events API reconciler (`workos-events-sync.ts`, every minute +// by cron plus a signed webhook poke): changes made in the WorkOS +// dashboard land within seconds. +// The membership row must be `active`: a pending invitee is not a member, and a +// deactivated member keeps their row but not their access. And the +// organization must not be marked deleted (`organizations.deleted_at`): cloud's +// deletion flow (`auth/handlers.ts` deleteOrganization) sets that mark FIRST, +// before the WorkOS delete and the local purge, so an org whose deletion did +// not finish refuses every session at once — its membership rows are still +// there, live, until the purge removes them, and must not authorize anyone. // -// To close that gap we verify membership live on every protected request. -// `listUserMemberships` is one WorkOS call per request. +// The mirror is trusted only while it is READY (`mirror-readiness.ts`): the +// one-off backfill has written every organization, and the events reconciler +// has drained the stream within its lag budget. Until both hold, membership is +// read from WorkOS (`listUserMemberships`, one call per request) exactly as +// before the cutover — a member the backfill has not written yet must not be +// locked out, and a member revoked in the dashboard while the reconciler was +// down must not be let in on a stale row. The readiness row is one indexed +// point read on the same socket; the deploy gate +// (`scripts/ensure-workos-mirror-ready.ts`) applies the same rule before this +// build goes live, so in steady state the fallback is never taken. A +// readiness or mirror read failure fails the request (500), never a silent +// fallback in either direction. The one org the fallback never asks WorkOS +// about is one the mirror holds as DELETED: WorkOS no longer has it (or is +// about to not), so its answer is "no member" for everyone — including the +// admin whose deletion failed part-way and must retry it (below). That +// membership is read from the mirror, whose rows are exactly what the purge +// has not removed yet, ready or not; a refused caller gets null either way. // -// Caching decision (2026-07): we deliberately do NOT add a positive TTL cache -// here. A positive cache is exactly what would re-open the revocation gap this -// live check exists to close — a revoked member would keep access for the cache -// TTL. Negative caching is worse still (a transient WorkOS blip would get -// pinned as "no access"), so it is out too. The rate-limit amplification a -// shared-API-key org can cause under a WorkOS slowdown is mitigated instead by -// the classification fix at the MCP call site (a blip now yields a retryable -// 503, so it no longer condemns sessions or triggers reconnect storms). If per- -// request WorkOS load later proves to be the bottleneck, the right structural -// fix is a local memberships table fed by the WorkOS Events API (authoritative, -// no staleness window), not a TTL cache over this call — tracked as follow-up. +// Readiness is database-wide; completeness is PER ORGANIZATION. An +// organization whose row was minted after the backfill ran — lazily by a +// request (`resolveOrganization`), or by a first login — carries no +// `backfilled_at`, and the mirror holds only the memberships login and +// write-through happened to record for it: a member who has not signed in +// since would be refused on a row that was never written. So the org row is +// read FIRST, and an unmarked live organization is scanned from WorkOS +// (`ensureOrganizationBackfilled`: one membership listing plus one `getUser` +// per member, then the mark) BEFORE its mirror is read — the same on-demand +// scan the seat gates run. One-time per organization: the scan marks the +// row, and this branch is never taken for it again. An organization the +// mirror does not hold at all — one that predates the mirror and that nobody +// has signed in to since (a CLI or MCP token names it, and the JWT path has +// no login feeder), or one created in the WorkOS dashboard — is reachable by +// neither the backfill (which lists the mirror's organizations) nor the +// reconciler (which starts at the replay boundary), so it is resolved on +// demand HERE: WorkOS is asked for the caller's own membership in it first +// (`getUserOrgMembership`, a read scoped to this caller — never a listing +// of the org), and only a member's answer mints the row +// (`resolveOrganization`) and scans it as above. A non-member mints nothing: +// a signed-in caller cannot create the row of an arbitrary WorkOS +// organization by naming its id. An organization marked deleted is never +// scanned: WorkOS no longer has it, and its rows are the purge's to remove, +// not a listing's to refresh. // -// Returns the resolved organization (via resolveOrganization) if the user -// currently holds an *active* membership in it, otherwise null. Callers -// should treat null as "no access" and route accordingly (onboarding page / -// 403). +// Returns the resolved organization if the user currently holds an *active* +// membership in it, otherwise null. Callers should treat null as "no access" +// and route accordingly (onboarding page / 403). +// +// The ONE caller that may see a marked org is the deletion flow itself +// (`deleted: "allow"`): an admin whose deletion failed after the mark must be +// able to send it again to finish the purge, and their membership row is +// still there to authorize exactly that. + +export interface AuthorizeOrganizationOptions { + /** Whether an organization marked deleted resolves (`"allow"`) or is refused (default). */ + readonly deleted?: "refuse" | "allow"; +} + +/** The caller's active membership in the org, however it was read: only the role matters past this point. */ +interface ActiveMembership { + readonly role: string; +} + +// The mirror read: the caller's row, active or nothing. +const activeMembershipFromMirror = (userId: string, organizationId: string) => + Effect.gen(function* () { + const directory = yield* MemberDirectory; + const membership = yield* directory.membership(userId, organizationId); + if (!membership || membership.status !== "active") return null; + const active: ActiveMembership = { role: membership.role }; + return active; + }); -export const authorizeOrganization = (userId: string, organizationId: string) => +// The pre-cutover read, kept for the window in which the mirror is not yet +// ready: WorkOS's own membership list for the user, one call per request. +const activeMembershipFromWorkOs = (userId: string, organizationId: string) => Effect.gen(function* () { const workos = yield* WorkOSClient; const memberships = yield* workos.listUserMemberships(userId); - const active = memberships.data.find( - (m: { readonly organizationId: string; readonly status: string }) => - m.organizationId === organizationId && m.status === "active", + const membership = memberships.data.find( + (m) => m.organizationId === organizationId && m.status === "active", + ); + if (!membership) return null; + const active: ActiveMembership = { role: membership.role.slug }; + return active; + }); + +// The authorized organization, or null for one marked deleted (unless the +// caller is the deletion flow). The membership already names the caller's +// role — surfaced normalized so identity resolution can bind the executor's +// workspace write permission without a second read. WorkOS issues `admin` / +// `member`; anything unrecognized stays a plain member. +const authorized = ( + org: Organization, + membership: ActiveMembership, + options: AuthorizeOrganizationOptions, +) => { + if (org.deletedAt !== null && options.deleted !== "allow") return null; + const memberRole: "admin" | "member" = membership.role === "admin" ? "admin" : "member"; + return { ...org, memberRole }; +}; + +// The organization row for a caller, minted from WorkOS when the mirror +// does not hold it — only for a caller WorkOS confirms as its member (see +// above). `null` when the mirror has no row and WorkOS lists no membership. +const heldOrResolvedForMember = (userId: string, organizationId: string) => + Effect.gen(function* () { + const users = yield* UserStoreService; + const held = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId)); + if (held) return held; + const workos = yield* WorkOSClient; + const membership = yield* workos.getUserOrgMembership(organizationId, userId); + if (!membership) return null; + yield* Effect.logInfo( + "authorizeOrganization: organization not mirrored; resolving it from WorkOS for its member", + { organizationId }, ); - if (!active) return null; - - const org = yield* resolveOrganization(organizationId); - // The membership row already names the caller's role — surface it - // normalized so identity resolution can bind the executor's workspace - // write permission without a second WorkOS call. WorkOS issues - // `admin` / `member`; anything unrecognized stays a plain member. - const roleSlug = (active as { readonly role?: { readonly slug?: string } }).role?.slug; - const memberRole: "admin" | "member" = roleSlug === "admin" ? "admin" : "member"; - return { ...org, memberRole }; + return yield* resolveOrganization(organizationId); + }); + +export const authorizeOrganization = ( + userId: string, + organizationId: string, + options: AuthorizeOrganizationOptions = {}, +) => + Effect.gen(function* () { + const readiness = yield* MirrorReadiness; + const state = yield* readiness.state(); + if (!MirrorReadinessState.$is("Ready")(state)) { + yield* Effect.logWarning( + "authorizeOrganization: membership mirror not ready; membership read from WorkOS", + { readiness: describeMirrorReadiness(state) }, + ); + // A marked organization is the mirror's to answer for (see above): + // WorkOS lists no member of it, and the deletion retry must still + // get in. + const users = yield* UserStoreService; + const held = yield* users.use("getOrganization", (s) => s.getOrganization(organizationId)); + if (held?.deletedAt != null) { + if (options.deleted !== "allow") return null; + const membership = yield* activeMembershipFromMirror(userId, organizationId); + if (!membership) return null; + return authorized(held, membership, options); + } + const membership = yield* activeMembershipFromWorkOs(userId, organizationId); + if (!membership) return null; + const org = yield* resolveOrganization(organizationId); + return authorized(org, membership, options); + } + + const org = yield* heldOrResolvedForMember(userId, organizationId); + if (!org) return null; + // An unmarked live organization is scanned before its mirror is read + // (see above). The row returned below still shows the mark as it was + // read; nothing past this point reads it. + if (org.deletedAt === null && org.backfilledAt === null) { + yield* ensureOrganizationBackfilled(organizationId); + } + const membership = yield* activeMembershipFromMirror(userId, organizationId); + if (!membership) return null; + return authorized(org, membership, options); }); // --------------------------------------------------------------------------- @@ -111,8 +288,8 @@ export const authorizeOrganization = (userId: string, organizationId: string) => // its own `x-executor-mcp-organization`). The selector is a slug (`acme`, the // readable URL form) or a WorkOS id (`org_…`, the legacy/token form). It is a // SELECTOR, not a trust boundary: `authorizeOrganizationSelector` re-checks -// live membership, so the worst a forged header does is name an org the caller -// already belongs to. +// membership against the mirror, so the worst a forged header does is name an +// org the caller already belongs to. // // Why a header and not the session's `org_id`: a browser shares ONE cookie jar // across tabs, so a single session-pinned org makes "active org" a @@ -130,15 +307,19 @@ export const orgSelectorFromRequest = (request: Request): string | null => * Resolve an org SELECTOR (URL slug or `org_…` id) to the organization the * caller actively belongs to, or `null`. A slug resolves through the local * mirror to its id first; ids pass straight through. Either way membership is - * verified live via {@link authorizeOrganization}. + * verified against the mirror via {@link authorizeOrganization}. */ -export const authorizeOrganizationSelector = (userId: string, selector: string) => +export const authorizeOrganizationSelector = ( + userId: string, + selector: string, + options: AuthorizeOrganizationOptions = {}, +) => Effect.gen(function* () { if (selector.startsWith("org_")) { - return yield* authorizeOrganization(userId, selector); + return yield* authorizeOrganization(userId, selector, options); } const users = yield* UserStoreService; const org = yield* users.use("getOrganizationBySlug", (s) => s.getOrganizationBySlug(selector)); if (!org) return null; - return yield* authorizeOrganization(userId, org.id); + return yield* authorizeOrganization(userId, org.id, options); }); diff --git a/apps/cloud/src/auth/user-store.ts b/apps/cloud/src/auth/user-store.ts index 1dfdb7d022..ec4fd862b8 100644 --- a/apps/cloud/src/auth/user-store.ts +++ b/apps/cloud/src/auth/user-store.ts @@ -7,7 +7,7 @@ // so domain tables can foreign-key against them and so we can resolve org // metadata without an API call on every request. -import { and, eq, isNull, lte, or } from "drizzle-orm"; +import { and, eq, isNull, lte, or, sql } from "drizzle-orm"; import { generateOrgSlug } from "@executor-js/api"; @@ -148,6 +148,24 @@ export const makeUserStore = (db: DrizzleDb) => { return rows[0] ?? null; }, + // Mark an org deleted, refusing every membership authorization against + // it from this moment. The FIRST step of cloud's deletion flow, taken + // before the WorkOS delete and the local purge, so a failure in either + // later step leaves the org unreachable rather than still authorizing + // sessions from its live membership rows. Idempotent: a retry after the + // WorkOS org is already gone keeps the original mark. `null` when the + // org is not mirrored. + markOrganizationDeleted: async (id: string, at: Date): Promise => { + const [marked] = await db + .update(organizations) + .set({ + deletedAt: sql`coalesce(${organizations.deletedAt}, ${at.toISOString()}::timestamptz)`, + }) + .where(eq(organizations.id, id)) + .returning(); + return marked ?? null; + }, + // Permanently delete everything an org owns (tenant data, secrets, its // memberships) in a single transaction, leaving the organization row as // a tombstone marked `deletedAt` (see `purgeOrganizationData` for why). diff --git a/apps/cloud/src/auth/workos-auth-provider.ts b/apps/cloud/src/auth/workos-auth-provider.ts index 6abbffb7cb..25ce48a54c 100644 --- a/apps/cloud/src/auth/workos-auth-provider.ts +++ b/apps/cloud/src/auth/workos-auth-provider.ts @@ -19,13 +19,17 @@ // - session without org header -> NoOrganization 403 no_organization (fail closed) // - session org not authorized -> NoOrganization 403 no_organization // - no auth header -> falls through to the sealed-session path -// The org-resolution infra errors (`UserStoreError` / `WorkOSError`) are -// `Effect.die`d so they surface as 500 defects — the same status the old inline -// resolver produced when those bubbled up. +// The org-resolution infra errors (`UserStoreError` / `WorkOSError` / +// `MemberDirectoryError` / `WorkOsMirrorError`, the last from the mirror +// readiness read) are `Effect.die`d so they surface as 500 defects — the +// same status the old inline resolver produced when those bubbled up. // -// The per-request `UserStoreService` (read by the org-resolution path) stays a -// REQUIREMENT OF THE LAYER, satisfied by the facade's per-request DB combine — -// NOT a function-level requirement (that is what forced a forked tag before). +// The per-request `UserStoreService` + `MemberDirectory` + `MirrorReadiness` +// + `WorkOsMirror` (read by the org-resolution path: the org row, whether +// the mirror may be trusted, the caller's mirrored membership, and the +// on-demand scan of an organization the backfill never covered) stay +// REQUIREMENTS OF THE LAYER, satisfied by the facade's per-request DB combine — +// NOT function-level requirements (that is what forced a forked tag before). // --------------------------------------------------------------------------- import { Effect, Layer } from "effect"; @@ -34,6 +38,7 @@ import type { JWTVerifyGetKey } from "jose"; import { IdentityProvider, + MemberDirectory, NoOrganization, Unauthorized, Unavailable, @@ -41,6 +46,7 @@ import { import type { FailureRenderingStrategy, IdentityFailure, + MemberDirectoryError, PlatformPrincipal, Principal, ResolvedPrincipal, @@ -48,6 +54,8 @@ import type { import { ApiKeyService } from "./api-keys"; import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; +import { MirrorReadiness } from "./mirror-readiness"; +import { WorkOsMirror } from "./workos-mirror"; import { BEARER_PREFIX } from "./bearer"; import { authorizeOrganization, @@ -57,7 +65,7 @@ import { } from "./organization"; import { UserStoreService } from "./context"; import { sealedSessionDisplayName } from "./middleware"; -import type { UserStoreError, WorkOSError } from "./errors"; +import type { UserStoreError, WorkOSError, WorkOsMirrorError } from "./errors"; import { WorkOSClient } from "./workos"; import { verifyWorkosUserManagementToken } from "../mcp/jwt"; @@ -66,7 +74,7 @@ import { verifyWorkosUserManagementToken } from "../mcp/jwt"; * (user_management) access token: the client-scoped SSO JWKS resolver. Issuer * and audience are NOT pinned (the client-scoped JWKS binds the token to this * app; user_management tokens carry no audience and an app-specific issuer) and - * org membership is re-checked live downstream. Passed in as a plain value so + * org membership is re-checked against the mirror downstream. Passed in as a plain value so * this module stays `cloudflare:workers`-free and the node-pool resolver tests * can inject a local JWKS. Production supplies {@link workosApiJwtBearerConfig}. */ @@ -118,7 +126,7 @@ const looksLikeJwt = (token: string): boolean => token.split(".").length === 3; /** * Resolve a WorkOS device-login (user_management) access token into a protected * `Principal`. Verifies the token's signature + expiry against the client-scoped - * SSO JWKS, then live-checks org membership, exactly like the api-key path. The + * SSO JWKS, then checks org membership in the mirror, exactly like the api-key path. The * `org_id` claim must be present (a token with no org context is rejected as * `NoOrganization`). NOTE: this is a different WorkOS token domain than the MCP * `/oauth2` tokens (different keyset, no audience), so it does NOT reuse the MCP @@ -191,7 +199,7 @@ export const isPlatformAuth = (value: BearerAuth): value is PlatformAuth => * path. * * The org branch does NOT call `authorizeOrganization`: that checks a USER's - * live membership, and there is no user here. The key itself is the authority — + * membership, and there is no user here. The key itself is the authority — * WorkOS validated it and reported which org owns it — so the org row is merely * resolved (mirrored on first read) for its name and slug. */ @@ -200,8 +208,14 @@ export const resolveBearerAuth = ( jwt: JwtBearerConfig | null = null, ): Effect.Effect< BearerAuth, - Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, - WorkOSClient | ApiKeyService | UserStoreService + | Unauthorized + | NoOrganization + | Unavailable + | UserStoreError + | WorkOSError + | WorkOsMirrorError + | MemberDirectoryError, + WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror > => Effect.gen(function* () { const authHeader = request.headers.get("authorization"); @@ -229,6 +243,9 @@ export const resolveBearerAuth = ( if (owner.scope === "org") { const org = yield* resolveOrganization(owner.organizationId); + // The key outlives the org until the purge removes it; an org marked + // deleted refuses it as it refuses every member's session. + if (org.deletedAt !== null) return yield* new NoOrganization(NO_ORGANIZATION_IN_API_KEY); return { kind: "platform", organizationId: org.id, @@ -277,8 +294,14 @@ export const resolveApiKeyPrincipal = ( jwt: JwtBearerConfig | null = null, ): Effect.Effect< ResolvedPrincipal | null, - Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, - WorkOSClient | ApiKeyService | UserStoreService + | Unauthorized + | NoOrganization + | Unavailable + | UserStoreError + | WorkOSError + | WorkOsMirrorError + | MemberDirectoryError, + WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror > => Effect.gen(function* () { const auth = yield* resolveBearerAuth(request, jwt); @@ -306,8 +329,9 @@ export const resolveSessionPrincipal = (request: Request) => // browser-global and pinned to whichever org WorkOS last touched, so // falling back to it silently serves ANOTHER org's data to a multi-org // user (the wrong-tenant connection-list bug, 2026-07). A header-less - // session call gets a clear 403 instead. Membership is re-checked live — - // the header is a selector, not a trust boundary (see organization.ts). + // session call gets a clear 403 instead. Membership is re-checked against + // the mirror — the header is a selector, not a trust boundary (see + // organization.ts). // A bare-URL first paint (no org in the path yet) may 403 here; that's // the safe outcome — OrgSlugGate immediately canonicalizes the URL onto // an org slug, the org-keyed atom registry remounts, and everything @@ -340,9 +364,10 @@ export const resolveSessionPrincipal = (request: Request) => * no roles to resolve, so each leaf already carries `roles: []`. Raises the * SHARED identity errors directly (`Unauthorized | NoOrganization | Unavailable`, * each carrying its machine `code` + `message`); the org-resolution infra errors - * (`UserStoreError` / `WorkOSError`) bubble for `workosIdentityLayer` to `die`. - * Keeps `WorkOSClient` / `ApiKeyService` / `UserStoreService` as requirements (the - * org-resolution path reads them) so it stays request-scoped. Re-exported for + * (`UserStoreError` / `WorkOSError` / `MemberDirectoryError`) bubble for + * `workosIdentityLayer` to `die`. Keeps `WorkOSClient` / `ApiKeyService` / + * `UserStoreService` / `MemberDirectory` as requirements (the org-resolution + * path reads them) so it stays request-scoped. Re-exported for * `protected-api-key-auth.node.test.ts`, which asserts the per-path principal + * shared error codes this folded resolver emits. */ @@ -351,8 +376,14 @@ export const resolveProtectedPrincipal = ( jwt: JwtBearerConfig | null = null, ): Effect.Effect< ResolvedPrincipal, - Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError, - WorkOSClient | ApiKeyService | UserStoreService + | Unauthorized + | NoOrganization + | Unavailable + | UserStoreError + | WorkOSError + | WorkOsMirrorError + | MemberDirectoryError, + WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror > => Effect.gen(function* () { const bearerPrincipal = yield* resolveApiKeyPrincipal(request, jwt); @@ -362,22 +393,29 @@ export const resolveProtectedPrincipal = ( /** * Cloud's NEUTRAL `IdentityProvider` Layer. Closes over the long-lived - * `WorkOSClient` + `ApiKeyService`; the request-scoped `UserStoreService` stays a - * REQUIREMENT OF THE LAYER, satisfied per request by the facade's DB combine. - * `authenticate` matches the neutral shape exactly (`Effect`): rejected credentials already - * carry the shared errors; the org-resolution infra errors (`UserStoreError` / - * `WorkOSError`) are `Effect.die`d so they surface as 500 defects, never on the - * error channel. + * `WorkOSClient` + `ApiKeyService`; the request-scoped `UserStoreService` + + * `MemberDirectory` stay REQUIREMENTS OF THE LAYER, satisfied per request by the + * facade's DB combine. `authenticate` matches the neutral shape exactly + * (`Effect`): rejected + * credentials already carry the shared errors; the org-resolution infra errors + * (`UserStoreError` / `WorkOSError` / `MemberDirectoryError`) are `Effect.die`d + * so they surface as 500 defects, never on the error channel. */ export const workosIdentityLayer: Layer.Layer< IdentityProvider, never, - WorkOSClient | ApiKeyService | UserStoreService + WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror > = Layer.effect( IdentityProvider, Effect.gen(function* () { - const context = yield* Effect.context(); + const context = yield* Effect.context< + | WorkOSClient + | ApiKeyService + | UserStoreService + | MemberDirectory + | MirrorReadiness + | WorkOsMirror + >(); return IdentityProvider.of({ authenticate: (request) => resolveProtectedPrincipal(request, workosApiJwtBearerConfig).pipe( @@ -390,6 +428,10 @@ export const workosIdentityLayer: Layer.Layer< UserStoreError: (error) => Effect.die(error), // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: org-resolution infra failure -> 500 defect, matches prior inline-resolver behavior WorkOSError: (error) => Effect.die(error), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: membership-mirror read failure -> 500 defect, same class as the store failure above + MemberDirectoryError: (error) => Effect.die(error), + // oxlint-disable-next-line executor/no-effect-escape-hatch -- boundary: mirror-readiness read failure -> 500 defect, same class as the store failure above + WorkOsMirrorError: (error) => Effect.die(error), }), Effect.provide(context), ), diff --git a/apps/cloud/src/auth/workos-callback-state.node.test.ts b/apps/cloud/src/auth/workos-callback-state.node.test.ts index a62f840b67..d7e56cd672 100644 --- a/apps/cloud/src/auth/workos-callback-state.node.test.ts +++ b/apps/cloud/src/auth/workos-callback-state.node.test.ts @@ -16,6 +16,8 @@ import { HttpRouter, HttpServer } from "effect/unstable/http"; import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpApi } from "effect/unstable/httpapi"; +import { MemberDirectory } from "@executor-js/api/server"; + import { CloudAuthPublicHandlers } from "./handlers"; import { CloudAuthPublicApi } from "./api"; import { UserStoreService } from "./context"; @@ -50,9 +52,6 @@ const stubWorkOS = Layer.succeed( if (prop === "listUserMemberships") { return () => Effect.succeed({ data: [] }); } - if (prop === "listOrgMembers") { - return () => Effect.succeed({ data: [{ status: "active" }] }); - } return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); }, }), @@ -103,13 +102,16 @@ const stubUsers = Layer.succeed(UserStoreService)({ workosUpdatedAt: null, createdAt: new Date(), }), + markOrganizationDeleted: async () => null, deleteOrganizationCascade: async () => {}, }), ), }); // The callback records the sign-in (user + memberships) in the membership -// mirror; every other mirror operation is out of this route's reach. +// mirror, and its forked seat recount reads the backfill marker and the +// landed org's active members from it; every other operation is out of this +// route's reach. const stubMirror = Layer.succeed(WorkOsMirror)({ upsertUser: () => Effect.succeed(true), upsertMembership: () => Effect.succeed(true), @@ -124,7 +126,29 @@ const stubMirror = Layer.succeed(WorkOsMirror)({ markBackfillCompleted: () => Effect.die("the callback does not run the backfill"), drainedAt: () => Effect.die("the callback does not check mirror readiness"), markDrained: () => Effect.die("the callback does not run the reconciler"), - organizationBackfilledAt: () => Effect.die("the callback does not report seats"), + organizationBackfilledAt: () => Effect.succeed(new Date()), +}); + +const stubDirectory = Layer.succeed(MemberDirectory)({ + membership: () => Effect.die("the callback does not look up one membership"), + membershipById: () => Effect.die("the callback does not look up by membership id"), + membershipsOf: () => Effect.die("the callback reads the WorkOS list, not the mirror's"), + membersById: () => Effect.die("the callback does not batch members"), + findByEmail: () => Effect.die("the callback does not resolve emails"), + members: (organizationId) => + Effect.succeed([ + { + accountId: STUB_USER_ID, + membershipId: `om_${STUB_USER_ID}`, + organizationId, + email: null, + name: null, + avatarUrl: null, + role: "member", + status: "active" as const, + lastActiveAt: null, + }, + ]), }); // Only the public group is under test; the session group (and its SessionAuth @@ -137,6 +161,7 @@ const App = HttpApiBuilder.layer(PublicApi).pipe( Layer.provide(stubWorkOS), Layer.provide(stubUsers), Layer.provide(stubMirror), + Layer.provide(stubDirectory), Layer.provide(AutumnService.Default), Layer.provide(HttpServer.layerServices), ); diff --git a/apps/cloud/src/auth/workos-events-sync.node.test.ts b/apps/cloud/src/auth/workos-events-sync.node.test.ts index d0a465a09d..6e53189638 100644 --- a/apps/cloud/src/auth/workos-events-sync.node.test.ts +++ b/apps/cloud/src/auth/workos-events-sync.node.test.ts @@ -12,7 +12,8 @@ // deleted, membership created/updated/deleted, organization renamed // - an older event never regresses a newer row (`stale`) — an older // organization rename included — a replayed delete is `absent`, and -// `organization.deleted` MARKS the org deleted without purging anything; +// `organization.deleted` MARKS the org deleted (refusing every membership +// authorization) without purging anything; // replayed, or after cloud's own flow marked it first, it is `absent` // - `organization.deleted` for an org the mirror has never seen MINTS a // tombstone row, so a login that fetched a membership of it before the @@ -61,6 +62,8 @@ import { UserStoreService } from "./context"; import { WorkOSError } from "./errors"; import { cloudMemberDirectoryLayer } from "./member-directory"; import { mirrorSignIn } from "./mirror-feeders"; +import { MirrorReadiness, MirrorReadinessState } from "./mirror-readiness"; +import { authorizeOrganization } from "./organization"; import { WorkOSClient, type WorkOSClientService, type WorkOSListEventsOptions } from "./workos"; import { planEvent, @@ -199,13 +202,26 @@ const profiles = (reads: string[] = []): Partial => ({ }); const DbLive = DbService.Live; +// The mirror is READY here (the authorization checks below read the mirror, +// not WorkOS); the readiness rule is pinned in workos-mirror.node.test.ts. +const readyMirror = Layer.succeed(MirrorReadiness)({ + state: () => Effect.succeed(MirrorReadinessState.Ready()), +}); + const MirrorServices = Layer.mergeAll( WorkOsMirror.Live, UserStoreService.Live, cloudMemberDirectoryLayer, + readyMirror, ).pipe(Layer.provideMerge(DbLive)); -type Services = WorkOsMirror | UserStoreService | MemberDirectory | DbService | WorkOSClient; +type Services = + | WorkOsMirror + | UserStoreService + | MemberDirectory + | MirrorReadiness + | DbService + | WorkOSClient; const run = ( body: Effect.Effect, @@ -754,6 +770,15 @@ describe("applyEvent", () => { const result = await run( Effect.gen(function* () { const seeded = yield* seedOrganization(org); + // Marked as scanned (an empty listing at T1), as the one-off backfill + // leaves every org: authorization scans an unmarked org from WorkOS + // first, and no WorkOS read is served here. + const mirror = yield* WorkOsMirror; + yield* mirror.applyOrganizationScan({ + organizationId: org, + listedAt: new Date(T1), + members: [], + }); yield* applyEvent( membershipEvent("organization_membership.created", workosMembership(userId, org)), ); @@ -767,6 +792,7 @@ describe("applyEvent", () => { organizationEvent("organization.updated", workosOrganization(org, "Older Name", T1)), ); const afterOlderRename = yield* readOrganization(org); + const authorizedBefore = yield* authorizeOrganization(userId, org); const deleted = yield* applyEvent( organizationEvent( "organization.deleted", @@ -777,6 +803,7 @@ describe("applyEvent", () => { ); const orgAfterDelete = yield* readOrganization(org); const membershipAfterDelete = yield* readMembership(userId, org); + const authorizedAfter = yield* authorizeOrganization(userId, org); const deletedAgain = yield* applyEvent( organizationEvent( "organization.deleted", @@ -795,9 +822,11 @@ describe("applyEvent", () => { afterRename, olderRename, afterOlderRename, + authorizedBefore, deleted, orgAfterDelete, membershipAfterDelete, + authorizedAfter, deletedAgain, renamedAfterDelete, orgAfterReplay, @@ -810,10 +839,12 @@ describe("applyEvent", () => { expect(result.afterRename?.slug, "the slug is stable across renames").toBe(result.seeded.slug); expect(result.olderRename, "an older rename is refused").toBe("stale"); expect(result.afterOlderRename?.name).toBe("Renamed Org"); + expect(result.authorizedBefore).not.toBeNull(); expect(result.deleted, "organization.deleted marks the org").toBe("applied"); expect(result.orgAfterDelete?.deletedAt, "as of the event").toEqual(new Date(T2)); expect(result.orgAfterDelete?.name, "the row is kept, not purged").toBe("Renamed Org"); expect(result.membershipAfterDelete, "and so is the membership row").not.toBeNull(); + expect(result.authorizedAfter, "but it authorizes nobody any more").toBeNull(); expect(result.deletedAgain, "a replayed deletion changes nothing").toBe("absent"); expect(result.renamedAfterDelete, "a deleted org is never renamed").toBe("absent"); expect(result.orgAfterReplay?.deletedAt, "the first mark stands").toEqual(new Date(T2)); diff --git a/apps/cloud/src/auth/workos-mirror.node.test.ts b/apps/cloud/src/auth/workos-mirror.node.test.ts index f70c208169..8a07eab012 100644 --- a/apps/cloud/src/auth/workos-mirror.node.test.ts +++ b/apps/cloud/src/auth/workos-mirror.node.test.ts @@ -40,12 +40,13 @@ // - the events replay boundary is recorded once and never advanced // - `members` searches email AND name case-insensitively, pages stably // - `findByEmail` ignores the casing WorkOS stored +// - `membershipById` is org-scoped: another org's id resolves to null // - a membership arriving before its user still holds (FK via ensureAccount) // --------------------------------------------------------------------------- import { describe, expect, it } from "@effect/vitest"; import { eq, sql } from "drizzle-orm"; -import { Context, Deferred, Effect, Fiber, Layer, Option } from "effect"; +import { Context, Deferred, Duration, Effect, Fiber, Layer, Option } from "effect"; import { MemberDirectory } from "@executor-js/api/server"; @@ -60,6 +61,13 @@ import { type WorkOsMirrorUser, } from "./workos-mirror"; import { makeWorkOsMirrorStore } from "./workos-mirror-store"; +import { + MIRROR_RECONCILER_LAG_BUDGET, + MirrorReadiness, + MirrorReadinessState, + makeMirrorReadinessLayer, + mirrorReadinessFrom, +} from "./mirror-readiness"; const DbLive = DbService.Live; const Services = Layer.mergeAll( @@ -841,6 +849,62 @@ describe("WorkOsMirror cursor", () => { }); }); +describe("mirror readiness", () => { + const now = T4; + const budget = Duration.toMillis(MIRROR_RECONCILER_LAG_BUDGET); + const within = new Date(now.getTime() - budget); + const tooOld = new Date(now.getTime() - budget - 1); + + it("is ready only when the backfill has completed AND the reconciler drained within the budget", () => { + expect(mirrorReadinessFrom(null, now), "no events row: never backfilled").toEqual( + MirrorReadinessState.BackfillPending(), + ); + expect(mirrorReadinessFrom({ backfillCompletedAt: null, drainedAt: within }, now)).toEqual( + MirrorReadinessState.BackfillPending(), + ); + expect( + mirrorReadinessFrom({ backfillCompletedAt: T1, drainedAt: null }, now), + "backfilled but the reconciler has never drained", + ).toEqual(MirrorReadinessState.ReconcilerStale({ drainedAt: null })); + expect( + mirrorReadinessFrom({ backfillCompletedAt: T1, drainedAt: tooOld }, now), + "a drain older than the budget is stale", + ).toEqual(MirrorReadinessState.ReconcilerStale({ drainedAt: tooOld })); + expect( + mirrorReadinessFrom({ backfillCompletedAt: T1, drainedAt: within }, now), + "a drain exactly at the budget is still ready", + ).toEqual(MirrorReadinessState.Ready()); + expect(mirrorReadinessFrom({ backfillCompletedAt: T1, drainedAt: now }, now)).toEqual( + MirrorReadinessState.Ready(), + ); + }); + + it("reads the live row the backfill and the reconciler write", async () => { + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const readiness = yield* MirrorReadiness; + yield* clearEventsRow; + const noRow = yield* readiness.state(); + yield* mirror.setReplayBoundary(T1); + yield* mirror.markBackfillCompleted(T1); + const backfilledOnly = yield* readiness.state(); + // A drain as of now: what a reconciler run that just read the stream + // to its end records. + const drainedAt = new Date(); + yield* mirror.markDrained(drainedAt); + const ready = yield* readiness.state(); + return { noRow, backfilledOnly, ready }; + }).pipe(Effect.provide(makeMirrorReadinessLayer().pipe(Layer.provide(DbLive)))), + ); + expect(result.noRow).toEqual(MirrorReadinessState.BackfillPending()); + expect(result.backfilledOnly).toEqual( + MirrorReadinessState.ReconcilerStale({ drainedAt: null }), + ); + expect(result.ready).toEqual(MirrorReadinessState.Ready()); + }); +}); + describe("WorkOsMirror backfill sync state", () => { it("records the replay boundary and the backfill completion once each, and the drained mark forward only, without touching the cursor", async () => { const result = await run( @@ -1244,6 +1308,33 @@ describe("cloud MemberDirectory", () => { expect(result.wildcard, "a literal % matches nothing rather than everything").toEqual([]); }); + it("lists one account's memberships across orgs, active + pending by default", async () => { + const result = await run( + Effect.gen(function* () { + const directory = yield* MemberDirectory; + const mirror = yield* WorkOsMirror; + const active = yield* freshOrg(); + const pending = yield* freshOrg(); + const inactive = yield* freshOrg(); + const id = `user_${crypto.randomUUID()}`; + yield* mirror.upsertUser(user(id)); + yield* mirror.upsertMembership(membership(active, id, { role: "admin" })); + yield* mirror.upsertMembership(membership(pending, id, { status: "pending" })); + yield* mirror.upsertMembership(membership(inactive, id, { status: "inactive" })); + const defaults = yield* directory.membershipsOf(id); + const activeOnly = yield* directory.membershipsOf(id, ["active"]); + const nobody = yield* directory.membershipsOf(`user_${crypto.randomUUID()}`); + return { active, pending, inactive, defaults, activeOnly, nobody }; + }), + ); + expect(result.defaults.map((m) => m.organizationId)).toEqual( + [result.active, result.pending].sort(), + ); + expect(result.defaults.find((m) => m.organizationId === result.active)?.role).toBe("admin"); + expect(result.activeOnly.map((m) => m.organizationId)).toEqual([result.active]); + expect(result.nobody).toEqual([]); + }); + it("resolves a normalized email regardless of stored casing, and batches by id", async () => { const result = await run( Effect.gen(function* () { @@ -1264,6 +1355,9 @@ describe("cloud MemberDirectory", () => { ["active", "pending", "inactive"], ); const empty = yield* directory.membersById(org, []); + const byId = yield* directory.membershipById(org, `om_${ids.gone}_${org}`); + const byIdForeign = yield* directory.membershipById(other, `om_${ids.gone}_${org}`); + const byIdUnknown = yield* directory.membershipById(org, "om_unknown"); return { ids, found, @@ -1273,6 +1367,9 @@ describe("cloud MemberDirectory", () => { batch, batchAll, empty, + byId, + byIdForeign, + byIdUnknown, }; }), ); @@ -1285,5 +1382,11 @@ describe("cloud MemberDirectory", () => { ]); expect([...result.batchAll.keys()].sort()).toEqual([result.ids.ada, result.ids.gone].sort()); expect(result.empty.size).toBe(0); + expect(result.byId, "membershipById reports any status").toMatchObject({ + accountId: result.ids.gone, + status: "inactive", + }); + expect(result.byIdForeign, "an id from another org is not this org's").toBeNull(); + expect(result.byIdUnknown).toBeNull(); }); }); diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index dcaaa723da..918a7e8556 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -9,6 +9,7 @@ import { WorkOS, type Event as WorkOSEvent, type EventName as WorkOSEventName, + type OrganizationMembershipStatus, } from "@workos-inc/node/worker"; import { defaults as ironDefaults, unseal as unsealIron } from "iron-webcrypto"; import { decodeJwt, jwtVerify } from "jose"; @@ -665,18 +666,30 @@ const make = Effect.gen(function* () { deleteApiKey: (id: string) => use("apiKeys.deleteApiKey", (wos) => wos.apiKeys.deleteApiKey(id)), - /** List organization memberships with user details. */ - listOrgMembers: (organizationId: string) => + /** + * An organization's memberships, all pages. Defaults to active + pending + * (the seat-occupying set); pass `statuses` to narrow — the invite + * write-through lists only `pending` to find the membership WorkOS + * created for the invitee. + */ + listOrgMembers: ( + organizationId: string, + statuses: readonly OrganizationMembershipStatus[] = ["active", "pending"], + ) => use("userManagement.listOrganizationMemberships", async (wos) => collectWorkOSList( await wos.userManagement.listOrganizationMemberships({ organizationId, - statuses: ["active", "pending"], + statuses: [...statuses], }), ), ), - /** Get a user's membership in an organization. */ + /** + * A user's membership in an organization (active or pending), or `null` + * when WorkOS lists none: the user is not a member, or the organization + * is gone. + */ getUserOrgMembership: (organizationId: string, userId: string) => use("userManagement.listOrganizationMemberships", async (wos) => { const response = await wos.userManagement.listOrganizationMemberships({ @@ -684,24 +697,14 @@ const make = Effect.gen(function* () { userId, statuses: ["active", "pending"], }); - return response.data[0] ?? null; + const [membership] = response.data; + return membership === undefined ? null : membership; }), /** Get a user by ID. */ getUser: (userId: string) => use("userManagement.getUser", (wos) => wos.userManagement.getUser(userId)), - /** List users matching an email within one organization. */ - listUsers: (params: { email: string; organizationId: string }) => - use("userManagement.listUsers", async (wos) => - collectWorkOSList( - await wos.userManagement.listUsers({ - email: params.email, - organizationId: params.organizationId, - }), - ), - ), - /** Send an organization invitation. */ sendInvitation: (params: { email: string; organizationId: string; roleSlug?: string }) => use("userManagement.sendInvitation", (wos) => @@ -753,12 +756,6 @@ const make = Effect.gen(function* () { wos.userManagement.deleteOrganizationMembership(membershipId), ), - /** Get the role for a membership. */ - getOrgMembership: (membershipId: string) => - use("userManagement.getOrganizationMembership", (wos) => - wos.userManagement.getOrganizationMembership(membershipId), - ), - /** Update a membership's role. */ updateOrgMembershipRole: (membershipId: string, roleSlug: string) => use("userManagement.updateOrganizationMembership", (wos) => diff --git a/apps/cloud/src/db/schema.ts b/apps/cloud/src/db/schema.ts index 89e5c3cdb9..e0ecd2d664 100644 --- a/apps/cloud/src/db/schema.ts +++ b/apps/cloud/src/db/schema.ts @@ -82,15 +82,20 @@ export const organizations = pgTable( */ backfilledAt: timestamp("backfilled_at", { withTimezone: true }), /** - * When this organization was deleted, or null while it is live. Set by - * cloud's own deletion flow and by the `organization.deleted` event — - * which MINTS the row as a tombstone when the mirror has never seen the - * organization — and KEPT by the local purge (`db/org-deletion.ts`), - * which removes the organization's memberships and tenant data but - * leaves this row as a tombstone: a feeder that fetched a membership - * before the deletion and writes it after (a login that stalled across - * the deletion) finds the tombstone and does not mint the organization - * live. A marked organization is never renamed and authorizes nobody. + * When this organization was deleted, or null while it is live. Set FIRST + * by cloud's own deletion flow (`auth/handlers.ts` deleteOrganization), + * before the WorkOS delete and the local purge, and by the + * `organization.deleted` event for an org deleted in the WorkOS dashboard + * — which MINTS the row as a tombstone when the mirror has never seen the + * organization: membership is authorized from the local mirror, so a + * marked organization refuses every session at once, whether or not the + * later steps land. Membership rows are left as they are until the purge + * (so the admin who started the deletion can retry it after a step + * failed), and the purge (`db/org-deletion.ts`) removes them but KEEPS + * this row as a tombstone: a feeder that fetched a membership before the + * deletion and writes it after (a login that stalled across the deletion) + * finds the tombstone and does not mint the organization live. A marked + * organization is never renamed. */ deletedAt: timestamp("deleted_at", { withTimezone: true }), /** diff --git a/apps/cloud/src/extensions/billing/member-seats.ts b/apps/cloud/src/extensions/billing/member-seats.ts index 75c1d7e00d..7ed5f2ab19 100644 --- a/apps/cloud/src/extensions/billing/member-seats.ts +++ b/apps/cloud/src/extensions/billing/member-seats.ts @@ -1,11 +1,16 @@ // --------------------------------------------------------------------------- -// Seat-count reporting — the WorkOS → Autumn reconciliation for seat billing +// Seat-count reporting — the membership mirror → Autumn reconciliation for +// seat billing // --------------------------------------------------------------------------- import { Effect } from "effect"; import { waitUntil } from "cloudflare:workers"; -import { WorkOSClient } from "../../auth/workos"; +import { MemberDirectory } from "@executor-js/api/server"; + +import { ensureOrganizationBackfilled } from "../../auth/mirror-feeders"; +import type { WorkOSClient } from "../../auth/workos"; +import type { WorkOsMirror } from "../../auth/workos-mirror"; import { AutumnService } from "./service"; /** @@ -16,39 +21,54 @@ import { AutumnService } from "./service"; * Seats change through paths the app never sees a mutation for (invitation * acceptance in AuthKit, SSO JIT provisioning, join by domain, WorkOS * dashboard edits), so this reconciles from a full recount rather than - * tracking deltas. It runs after in-app membership mutations AND on every - * login callback, so drift from out-of-band changes heals on the next - * sign-in. Fire-and-forget-safe: errors are logged, never surfaced. + * tracking deltas. The count comes from the local membership mirror through + * the shared `MemberDirectory`: every in-app membership mutation writes + * through to the mirror BEFORE calling this, and out-of-band changes land via + * login and the Events reconciler, so the recount reads the change on the + * next sign-in exactly as it did against WorkOS — without a WorkOS read. + * + * The Autumn call runs off the calling request's critical path: Cloudflare + * owns its promise through `waitUntil`, so the recount can finish after the + * response, and billing never stalls or fails a user-facing request. Errors + * are logged, never surfaced. + * + * The count is a PARTIAL one until THIS organization's membership list has + * been scanned from WorkOS in full (the one-off backfill, or the on-demand + * scan below): before that, the mirror holds only the members who signed in + * or were changed since the mirror shipped. Because the Autumn write is an + * authoritative SET, pushing a partial count would under-bill the + * organization, so the recount first makes sure the organization is + * backfilled (`ensureOrganizationBackfilled`: a scan runs now when its + * per-organization mark is missing) and only then counts. The plan gate + * (`reserveMemberSlot`) goes through the same step, so it never admits an + * invite past the plan limit on a partial mirror. + * + * The COUNT is read inline, not in the fork: `MemberDirectory` is per-request + * (it holds the request's postgres socket, which Cloudflare Workers' I/O + * isolation ties to the request), so a forked fiber reading it could outlive + * the socket. One indexed local query is cheap enough to pay inline; only the + * Autumn call — over the boot-scoped `AutumnService` — is forked, so the + * forked fiber captures nothing request-scoped. */ -export const reportMemberSeats = ( +export const forkReportMemberSeats = ( organizationId: string, -): Effect.Effect => +): Effect.Effect => Effect.gen(function* () { - const workos = yield* WorkOSClient; + const directory = yield* MemberDirectory; const autumn = yield* AutumnService; - const memberships = yield* workos.listOrgMembers(organizationId); - const seats = memberships.data.filter((m) => m.status === "active").length; - yield* autumn.setMemberSeats(organizationId, seats); + yield* ensureOrganizationBackfilled(organizationId); + const seats = yield* directory + .members(organizationId, { statuses: ["active"] }) + .pipe(Effect.map((members) => members.length)); + yield* Effect.sync(() => { + waitUntil(Effect.runPromise(autumn.setMemberSeats(organizationId, seats))); + }); }).pipe( Effect.catch((error) => - Effect.logWarning("reportMemberSeats: seat recount failed", { organizationId, error }), + Effect.logWarning("reportMemberSeats: seat recount failed", { + organizationId, + error, + }), ), Effect.withSpan("billing.reportMemberSeats"), ); - -/** - * Fork `reportMemberSeats` off the calling request, mirroring how execution - * tracking is forked: billing must never stall or fail a user-facing - * request. Cloudflare owns the promise through waitUntil, so the recount can - * finish after the response. Only boot-scoped WorkOS and Autumn services are - * captured. - */ -export const forkReportMemberSeats = ( - organizationId: string, -): Effect.Effect => - Effect.gen(function* () { - const ctx = yield* Effect.context(); - yield* Effect.sync(() => { - waitUntil(Effect.runPromiseWith(ctx)(reportMemberSeats(organizationId))); - }); - }); diff --git a/apps/cloud/src/extensions/billing/route.node.test.ts b/apps/cloud/src/extensions/billing/route.node.test.ts index 51a182b180..1f10d3abc5 100644 --- a/apps/cloud/src/extensions/billing/route.node.test.ts +++ b/apps/cloud/src/extensions/billing/route.node.test.ts @@ -1,8 +1,12 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; +import { MemberDirectory } from "@executor-js/api/server"; + import { UserStoreService } from "../../auth/context"; +import { MirrorReadiness, MirrorReadinessState } from "../../auth/mirror-readiness"; import { WorkOSClient, type WorkOSClientService } from "../../auth/workos"; +import { WorkOsMirror, type WorkOsMirrorShape } from "../../auth/workos-mirror"; import { resolveBillingOrganization } from "./route"; const createdAt = new Date("2026-01-01T00:00:00.000Z"); @@ -29,23 +33,43 @@ const stubWorkOS = Layer.succeed( WorkOSClient, new Proxy({} as WorkOSClientService, { get: (_target, prop) => { - if (prop === "listUserMemberships") { - return (userId: string) => - Effect.succeed({ - data: - userId === MEMBER - ? [ - { userId, organizationId: SESSION_ORG, status: "active" }, - { userId, organizationId: URL_ORG, status: "active" }, - ] - : [], - }); - } + // Membership is read from the mirror, never from WorkOS. return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); }, }), ); +// MEMBER is active in both orgs, as the mirror reports it. +// The mirror is READY in these tests (backfill complete, reconciler caught +// up), so membership is read from the stubbed directory, never from WorkOS. +const stubReadiness = Layer.succeed(MirrorReadiness)({ + state: () => Effect.succeed(MirrorReadinessState.Ready()), +}); + +const stubDirectory = Layer.succeed(MemberDirectory)({ + membership: (accountId, organizationId) => + Effect.succeed( + accountId === MEMBER && (organizationId === SESSION_ORG || organizationId === URL_ORG) + ? { + accountId, + membershipId: `om_${accountId}_${organizationId}`, + organizationId, + email: null, + name: null, + avatarUrl: null, + role: "member", + status: "active" as const, + lastActiveAt: null, + } + : null, + ), + membershipById: () => Effect.die("billing auth does not look up by membership id"), + membershipsOf: () => Effect.die("billing auth reads one membership, not the list"), + members: () => Effect.die("billing auth does not list members"), + membersById: () => Effect.die("billing auth does not batch members"), + findByEmail: () => Effect.die("billing auth does not resolve emails"), +}); + const stubUsers = Layer.succeed(UserStoreService)({ use: (_op, fn) => Effect.promise(() => @@ -55,7 +79,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ upsertOrganization: async (org: { id: string; name: string }) => ({ ...org, slug: org.id, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, @@ -64,7 +88,7 @@ const stubUsers = Layer.succeed(UserStoreService)({ id, name: `Org ${id}`, slug: id, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, @@ -73,21 +97,34 @@ const stubUsers = Layer.succeed(UserStoreService)({ id: slug === URL_SLUG ? URL_ORG : "org_outsider", name: `Org ${slug}`, slug, - backfilledAt: null, + backfilledAt: createdAt, deletedAt: null, workosUpdatedAt: null, createdAt, }), + markOrganizationDeleted: async () => null, deleteOrganizationCascade: async () => {}, }), ), }); +// Authorization scans an organization the backfill never covered before it +// reads the mirror (`auth/organization.ts`); every org row above is marked +// backfilled, so the scan is never reached and the mirror is never written. +const stubMirror = Layer.succeed( + WorkOsMirror, + new Proxy({} as WorkOsMirrorShape, { + get: (_target, prop) => () => Effect.die(`unexpected WorkOsMirror.${String(prop)} call`), + }), +); + const run = (headers: Record) => resolveBillingOrganization( new Request("https://executor.test/api/billing/customer", { headers }), { userId: MEMBER }, - ).pipe(Effect.provide(Layer.mergeAll(stubWorkOS, stubUsers))); + ).pipe( + Effect.provide(Layer.mergeAll(stubWorkOS, stubUsers, stubDirectory, stubMirror, stubReadiness)), + ); describe("billing route org selector", () => { it.effect("fails closed when no selector header is sent", () => diff --git a/apps/cloud/src/extensions/routes.ts b/apps/cloud/src/extensions/routes.ts index bd4b1d4c12..8d2b541d4b 100644 --- a/apps/cloud/src/extensions/routes.ts +++ b/apps/cloud/src/extensions/routes.ts @@ -28,9 +28,10 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpApiSwagger, OpenApi } from "effect/unstable/httpapi"; import { AccountApi, AdminUsersApi } from "@executor-js/api"; -import { requestScopedMiddleware } from "@executor-js/api/server"; +import { requestScopedMiddleware, type MemberDirectory } from "@executor-js/api/server"; import { UserStoreService } from "../auth/context"; +import { MirrorReadiness } from "../auth/mirror-readiness"; import { WorkOsMirror } from "../auth/workos-mirror"; import { CloudAuthPublicHandlers, @@ -79,7 +80,9 @@ const spec = OpenApi.fromApi(CloudOpenApi); * core. */ export const makeCloudExtensionRoutes = ( - rsLive: Layer.Layer, + rsLive: Layer.Layer< + DbService | UserStoreService | WorkOsMirror | MemberDirectory | MirrorReadiness + >, ) => { // Session routes (login / callback / me / switch-org / …). Handlers yield // `UserStoreService` directly; the per-request DB combine keeps the postgres diff --git a/apps/cloud/src/mcp/agent-handler.ts b/apps/cloud/src/mcp/agent-handler.ts index 3d94d3e9cb..d10233953a 100644 --- a/apps/cloud/src/mcp/agent-handler.ts +++ b/apps/cloud/src/mcp/agent-handler.ts @@ -178,7 +178,7 @@ const propsForPrincipal = ( return { session: { organizationId: principal.organizationId, - // The org record the live membership check resolved microseconds ago, + // The org record the membership check resolved microseconds ago, // handed to the session DO so it never opens a connection of its own to // re-read it. An unnamed org (no auth plane could resolve one) is // omitted rather than sent empty, so the DO can tell "not carried" from diff --git a/apps/cloud/src/mcp/auth-provider.ts b/apps/cloud/src/mcp/auth-provider.ts index 054054a940..ab538e4ddb 100644 --- a/apps/cloud/src/mcp/auth-provider.ts +++ b/apps/cloud/src/mcp/auth-provider.ts @@ -91,7 +91,7 @@ const ORGANIZATION_AUTHORIZE_UNAVAILABLE = * Enrich a cloud {@link VerifiedToken} (which carries only accountId + * organizationId) into the full {@link Principal} the seam validates. * - * The org name and slug come from the record the live membership check just + * The org name and slug come from the record the membership check just * resolved — this is the whole point of `authorize` returning the record rather * than an id. They used to be dropped here (`organizationName: ""`), which left * the session Durable Object to re-read the same row over a fresh database @@ -182,8 +182,9 @@ export const cloudMcpAuthProviderLayer: Layer.Layer< // slug (`/acme/mcp`, what the install card prints) or a legacy org id // (`/org_xxx/mcp`), carried in the header by `prepareMcpOrgScope`; the // bare `/mcp` falls back to the token's `org_id`. Either way - // `orgAuth.authorize` resolves the selector and re-checks live WorkOS - // membership below, so the URL is a selector, not a trust boundary. + // `orgAuth.authorize` resolves the selector and re-checks membership + // against the local mirror below, so the URL is a selector, not a + // trust boundary. const organizationSelector = mcpOrganizationFromRequest(request) ?? token.organizationId; if (!organizationSelector) { yield* annotateMcpRequest(request, { token, parseBody }); diff --git a/apps/cloud/src/mcp/auth.ts b/apps/cloud/src/mcp/auth.ts index d14e6f4997..81ac8bc73f 100644 --- a/apps/cloud/src/mcp/auth.ts +++ b/apps/cloud/src/mcp/auth.ts @@ -17,6 +17,9 @@ import { ApiKeyService } from "../auth/api-keys"; import { BEARER_PREFIX } from "../auth/bearer"; import { authorizeOrganization } from "../auth/organization"; import { UserStoreService, makeUserStoreLayer } from "../auth/context"; +import { makeMemberDirectoryLayer } from "../auth/member-directory"; +import { makeMirrorReadinessLayer } from "../auth/mirror-readiness"; +import { makeWorkOsMirrorLayer } from "../auth/workos-mirror"; import { CoreSharedServices } from "../auth/workos"; import { makeDbLayer } from "../db/db"; import { bearerChallenge } from "./responses"; @@ -60,7 +63,7 @@ const TOOLKIT_SEGMENT = "/toolkits/"; // the token's `org_id` claim. start.ts / the test worker rewrite `/org_xxx/mcp` // (and the org-scoped discovery doc) to the bare path the shared envelope routes // and stash the URL-pinned org in this INTERNAL header; the provider reads it -// back. The org is re-checked against live WorkOS membership per request +// back. The org is re-checked against the local membership mirror per request // (`McpOrganizationAuth.authorize`), so the header — like the URL it came from — // is a SELECTOR, not a trust boundary. export const MCP_ORGANIZATION_HEADER = "x-executor-mcp-organization"; @@ -201,18 +204,28 @@ const verifyJwt = (token: string) => // `DbService.Live` would open its postgres socket on the first request and // illegally reuse it on later ones ("Cannot perform I/O on behalf of a // different request"), failing the org lookup on every follow-up — the -// "connected · tools fetch failed" symptom. A fresh DB + UserStore layer per -// call gives each request its own request-scoped socket. `CoreSharedServices` -// (WorkOS, no per-request socket) stays shared. +// "connected · tools fetch failed" symptom. A fresh DB + UserStore + +// MemberDirectory + WorkOsMirror layer per call gives each request its own +// request-scoped socket. `CoreSharedServices` (WorkOS, no per-request socket) stays shared. const makeMcpOrganizationAuthServices = () => { const dbLive = makeDbLayer(); const userStoreLive = makeUserStoreLayer().pipe(Layer.provide(dbLive)); - return Layer.mergeAll(dbLive, userStoreLive, CoreSharedServices); + const memberDirectoryLive = makeMemberDirectoryLayer().pipe(Layer.provide(dbLive)); + const mirrorReadinessLive = makeMirrorReadinessLayer().pipe(Layer.provide(dbLive)); + const workOsMirrorLive = makeWorkOsMirrorLayer().pipe(Layer.provide(dbLive)); + return Layer.mergeAll( + dbLive, + userStoreLive, + memberDirectoryLive, + mirrorReadinessLive, + workOsMirrorLive, + CoreSharedServices, + ); }; // A URL slug resolves through the mirror to its org id before the membership // check; an unknown slug authorizes nothing. Ids pass straight through — -// `authorizeOrganization` verifies live WorkOS membership either way. +// `authorizeOrganization` verifies membership against the mirror either way. const resolveOrgSelector = (selector: string) => selector.startsWith("org_") ? Effect.succeed(selector) diff --git a/apps/cloud/src/org/auth-middleware.ts b/apps/cloud/src/org/auth-middleware.ts index 477de037bc..d9f562494f 100644 --- a/apps/cloud/src/org/auth-middleware.ts +++ b/apps/cloud/src/org/auth-middleware.ts @@ -1,10 +1,16 @@ -import { Effect, Layer } from "effect"; +import { Context, Effect, Layer } from "effect"; import { HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { AuthContext, requestScopedMiddleware } from "@executor-js/api/server"; +import { + AuthContext, + requestScopedMiddleware, + type MemberDirectory, +} from "@executor-js/api/server"; import { UserStoreService } from "../auth/context"; import { sessionFromSealed } from "../auth/middleware"; +import { MirrorReadiness } from "../auth/mirror-readiness"; +import { WorkOsMirror } from "../auth/workos-mirror"; import { ORG_SELECTOR_HEADER, authorizeOrganizationSelector } from "../auth/organization"; import { WorkOSClient } from "../auth/workos"; import { DbService } from "../db/db"; @@ -27,7 +33,21 @@ const noOrganization = () => { status: 403 }, ); -const OrgAuthMiddleware = HttpRouter.middleware<{ provides: AuthContext }>()( +/** + * The caller's role in the session org, as `authorizeOrganizationSelector` + * read it for THIS request: from the mirror while the mirror is ready, from + * WorkOS otherwise (`auth/organization.ts`). Provided beside `AuthContext` — + * the shared seam, which carries no role — so the domain handlers' admin gate + * is this one value, never a second read of the mirror that would skip the + * readiness rule and admit a demoted admin on a stale row while the + * reconciler is behind. + */ +export class OrgMemberRole extends Context.Service< + OrgMemberRole, + { readonly memberRole: "admin" | "member" } +>()("@executor-js/cloud/OrgMemberRole") {} + +const OrgAuthMiddleware = HttpRouter.middleware<{ provides: AuthContext | OrgMemberRole }>()( Effect.gen(function* () { const captured = yield* Effect.context(); const workos = yield* WorkOSClient; @@ -62,10 +82,18 @@ const OrgAuthMiddleware = HttpRouter.middleware<{ provides: AuthContext }>()( roles: [], }); - return yield* Effect.provideService(httpEffect, AuthContext, auth); + return yield* Effect.provideContext( + httpEffect, + Context.make(AuthContext, auth).pipe( + Context.add(OrgMemberRole, { memberRole: org.memberRole }), + ), + ); }).pipe(Effect.provideContext(captured)); }), ); -export const orgAuthMiddleware = (rsLive: Layer.Layer) => - OrgAuthMiddleware.combine(requestScopedMiddleware(rsLive)).layer; +export const orgAuthMiddleware = ( + rsLive: Layer.Layer< + DbService | UserStoreService | MemberDirectory | MirrorReadiness | WorkOsMirror + >, +) => OrgAuthMiddleware.combine(requestScopedMiddleware(rsLive)).layer; diff --git a/apps/cloud/src/org/handlers.test.ts b/apps/cloud/src/org/handlers.test.ts index 05445a3e28..e1f4459cb2 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -1,24 +1,38 @@ -import { describe, it, expect } from "@effect/vitest"; +import { afterAll, describe, expect, it } from "@effect/vitest"; import { Data, Effect, Layer } from "effect"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; +import { HttpApiBuilder } from "effect/unstable/httpapi"; -import { AuthContext } from "@executor-js/api/server"; +import { AuthContext, MemberDirectory, type DirectoryMember } from "@executor-js/api/server"; +import { UserStoreService } from "../auth/context"; +import { MirrorReadiness, MirrorReadinessState } from "../auth/mirror-readiness"; +import { ORG_SELECTOR_HEADER } from "../auth/organization"; import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; -import { Forbidden } from "./api"; +import { WorkOsMirror, type WorkOsMirrorShape } from "../auth/workos-mirror"; +import { DbService } from "../db/db"; +import { AutumnService } from "../extensions/billing/service"; +import { OrgHttpApi, Forbidden } from "./api"; +import { OrgMemberRole, orgAuthMiddleware } from "./auth-middleware"; +import { OrgHandlers, assertDomainInSessionOrg, requireAdmin } from "./handlers"; // --------------------------------------------------------------------------- // Domain-handler guards. The member / role / invite / org-name endpoints moved // to the shared WorkOS `AccountProvider` (covered by // `workos-account-service.test.ts`); this group now serves only the WorkOS // domain-verification endpoints. These tests pin the two guards those handlers -// share — `requireAdmin` and `assertDomainInSessionOrg` — which mirror -// `org/handlers.ts`. +// share — the REAL `requireAdmin` and `assertDomainInSessionOrg` exported from +// `org/handlers.ts`, so a change to the gate cannot pass on a stale copy — and +// the admin gate's SOURCE: the role `orgAuthMiddleware` resolved for the +// request, so a stale mirror row cannot admit a demoted admin while the mirror +// is not trusted (the readiness rule in `auth/organization.ts`). // --------------------------------------------------------------------------- // eslint-disable-next-line @typescript-eslint/no-explicit-any -- test stub needs wide function types type StubFn = (...args: never[]) => Effect.Effect; type StubOverrides = { - getUserOrgMembership?: StubFn; + authenticateSealedSession?: StubFn; + listUserMemberships?: StubFn; getOrganizationDomain?: StubFn; getOrganization?: StubFn; deleteOrganizationDomain?: StubFn; @@ -55,62 +69,27 @@ const adminAuth = { roles: [], }; -const memberAuth = { - accountId: "user_member", - organizationId: "org_1", - email: "member@test.com", - name: "Member", - avatarUrl: null, - roles: [], -}; - -const provide = (auth: typeof adminAuth, workosOverrides: StubOverrides = {}) => - Layer.mergeAll(Layer.succeed(AuthContext)(auth), stubWorkOS(workosOverrides)); - -// Mirrors `org/handlers.ts` `requireAdmin`. -const requireAdmin = Effect.gen(function* () { - const auth = yield* AuthContext; - if (auth.accountId === null) return yield* new Forbidden(); - const workos = yield* WorkOSClient; - const current = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); - if (!current || current.role?.slug !== "admin") { - return yield* new Forbidden(); - } -}); - -const withCurrentMembership: StubOverrides = { - getUserOrgMembership: (_organizationId: string, userId: string) => - Effect.succeed( - userId === "user_admin" - ? { id: "mem_admin", userId, status: "active", role: { slug: "admin" } } - : { id: "mem_member", userId, status: "active", role: { slug: "member" } }, - ), -}; - -// Mirrors `org/handlers.ts` `assertDomainInSessionOrg`. -const assertDomainInSessionOrg = (domainId: string) => - Effect.gen(function* () { - const auth = yield* AuthContext; - const workos = yield* WorkOSClient; - const domain = yield* workos - .getOrganizationDomain(domainId) - .pipe(Effect.catchCause(() => Effect.succeed(null))); - if (!domain || domain.organizationId !== auth.organizationId) { - return yield* new Forbidden(); - } - }); +const provide = ( + memberRole: "admin" | "member", + workosOverrides: StubOverrides = {}, +): Layer.Layer => + Layer.mergeAll( + Layer.succeed(AuthContext)(adminAuth), + Layer.succeed(OrgMemberRole)({ memberRole }), + stubWorkOS(workosOverrides), + ); describe("Org domain handlers", () => { describe("requireAdmin", () => { it.effect("passes for an admin caller", () => - requireAdmin.pipe(Effect.provide(provide(adminAuth, withCurrentMembership))), + requireAdmin.pipe(Effect.provide(provide("admin"))), ); it.effect("rejects a non-admin caller with Forbidden", () => Effect.gen(function* () { const error = yield* Effect.flip(requireAdmin); expect(error).toBeInstanceOf(Forbidden); - }).pipe(Effect.provide(provide(memberAuth, withCurrentMembership))), + }).pipe(Effect.provide(provide("member"))), ); }); @@ -118,7 +97,7 @@ describe("Org domain handlers", () => { it.effect("passes when the domain belongs to the session org", () => assertDomainInSessionOrg("dom_1").pipe( Effect.provide( - provide(adminAuth, { + provide("admin", { getOrganizationDomain: () => Effect.succeed({ id: "dom_1", organizationId: "org_1", domain: "acme.test" }), }), @@ -132,7 +111,7 @@ describe("Org domain handlers", () => { expect(error).toBeInstanceOf(Forbidden); }).pipe( Effect.provide( - provide(adminAuth, { + provide("admin", { getOrganizationDomain: () => Effect.succeed({ id: "dom_other", organizationId: "org_2", domain: "evil.test" }), }), @@ -146,7 +125,7 @@ describe("Org domain handlers", () => { expect(error).toBeInstanceOf(Forbidden); }).pipe( Effect.provide( - provide(adminAuth, { + provide("admin", { getOrganizationDomain: () => Effect.fail(new UnstubbedWorkOSMethod({ method: "boom" })), }), ), @@ -154,3 +133,182 @@ describe("Org domain handlers", () => { ); }); }); + +// --------------------------------------------------------------------------- +// The admin gate over HTTP, through `orgAuthMiddleware`: the role the gate +// sees is the one the middleware resolved through `authorizeOrganizationSelector` +// — the mirror while it is ready, WorkOS otherwise. The mirror row below is +// STALE: it still says `admin` for a caller WorkOS has demoted to `member`. +// While the mirror is not trusted (the reconciler is behind), WorkOS's answer +// must decide, and the delete must be refused. +// --------------------------------------------------------------------------- + +const ORG = "org_1"; +const CALLER = "user_caller"; +const DOMAIN = "dom_1"; +const createdAt = new Date("2026-01-01T00:00:00.000Z"); + +// The mirror's row for the caller: an active admin — stale once WorkOS has +// demoted them and the reconciler has not landed the change yet. +const staleAdminRow: DirectoryMember = { + accountId: CALLER, + membershipId: `om_${CALLER}_${ORG}`, + organizationId: ORG, + email: null, + name: null, + avatarUrl: null, + role: "admin", + status: "active", + lastActiveAt: null, +}; + +const unread = (why: string) => () => Effect.die(why); +const stubDirectory = Layer.succeed(MemberDirectory)({ + membership: (accountId, organizationId) => + Effect.succeed(accountId === CALLER && organizationId === ORG ? staleAdminRow : null), + membershipById: unread("the org plane does not look up by membership id"), + membershipsOf: unread("the org plane reads one membership, not the list"), + members: unread("the org plane does not list members"), + membersById: unread("the org plane does not batch members"), + findByEmail: unread("the org plane does not resolve emails"), +}); + +const readiness = (state: MirrorReadinessState) => + Layer.succeed(MirrorReadiness)({ state: () => Effect.succeed(state) }); + +const organizationRow = (id: string) => ({ + id, + name: `Org ${id}`, + slug: id, + backfilledAt: createdAt, + deletedAt: null, + workosUpdatedAt: null, + createdAt, +}); + +// The store's operations are plain promises: an unexpected one defects +// through `Effect.promise`, the same way `unread` does for the services. +const unreadStore = (why: string) => () => Effect.runPromise(Effect.die(why)); +const stubUsers = Layer.succeed(UserStoreService)({ + use: (_op, fn) => + Effect.promise(() => + fn({ + ensureAccount: unreadStore("the org plane does not mint accounts"), + getAccount: unreadStore("the org plane does not read accounts"), + upsertOrganization: unreadStore("the org plane does not mirror organizations"), + getOrganization: async (id: string) => organizationRow(id), + getOrganizationBySlug: unreadStore("the selector below is an org id, not a slug"), + markOrganizationDeleted: unreadStore("the org plane does not delete organizations"), + deleteOrganizationCascade: unreadStore("the org plane does not delete organizations"), + }), + ), +}); + +// Authorization scans an organization the backfill never covered before it +// reads the mirror (`auth/organization.ts`); every org row above is marked +// backfilled, so the scan is never reached and the mirror is never written. +const stubMirror = Layer.succeed( + WorkOsMirror, + new Proxy({} as WorkOsMirrorShape, { + get: (_target, prop) => () => Effect.die(`unexpected WorkOsMirror.${String(prop)} call`), + }), +); + +// The handlers never reach the database here: the directory and the store are +// stubbed above, so the request-scoped `DbService` is a placeholder. +const stubDb = Layer.succeed(DbService)({ db: {} as never }); + +const stubAutumn = Layer.succeed(AutumnService)({ + use: unread("the delete does not consult billing"), + ensureCustomer: unread("the delete does not provision billing"), + checkExecutionBalance: unread("the delete does not check balances"), + trackExecution: unread("the delete does not track usage"), + setMemberSeats: unread("the delete does not count seats"), +}); + +// WorkOS as the org plane sees it: the caller's session, their CURRENT +// membership list (demoted to member), and the domain to delete. +const workosWithCallerAs = (role: "admin" | "member", deleted: string[]) => + stubWorkOS({ + authenticateSealedSession: () => + Effect.succeed({ userId: CALLER, email: "caller@placeholder.test", organizationId: ORG }), + listUserMemberships: () => + Effect.succeed({ + data: [ + { + id: staleAdminRow.membershipId, + organizationId: ORG, + status: "active", + role: { slug: role }, + }, + ], + }), + getOrganizationDomain: () => + Effect.succeed({ id: DOMAIN, organizationId: ORG, domain: "acme.test" }), + deleteOrganizationDomain: (domainId: string) => + Effect.sync(() => { + deleted.push(domainId); + }), + }); + +const orgApp = (state: MirrorReadinessState, workos: Layer.Layer) => { + const rsLive = Layer.mergeAll(stubDb, stubUsers, stubDirectory, stubMirror, readiness(state)); + const App = HttpApiBuilder.layer(OrgHttpApi).pipe( + Layer.provide(OrgHandlers), + Layer.provide(orgAuthMiddleware(rsLive)), + Layer.provide(workos), + Layer.provide(stubAutumn), + Layer.provide(HttpServer.layerServices), + ); + return HttpRouter.toWebHandler(App, { disableLogger: true }); +}; + +const apps: { dispose: () => Promise }[] = []; +afterAll(async () => { + await Promise.all(apps.map((app) => app.dispose())); +}); + +const deleteDomain = async (state: MirrorReadinessState, role: "admin" | "member") => { + const deleted: string[] = []; + const app = orgApp(state, workosWithCallerAs(role, deleted)); + apps.push(app); + const response = await app.handler( + new Request(`https://executor.test/org/domains/${DOMAIN}`, { + method: "DELETE", + headers: { cookie: "wos-session=sealed", [ORG_SELECTOR_HEADER]: ORG }, + }), + // beta.59: the handler type expects a context argument; this layer stack + // needs none at runtime — pass undefined like the api.request-scope tests. + undefined as never, + ); + return { status: response.status, deleted }; +}; + +describe("Org domain handlers over HTTP: the admin gate is the authorized role", () => { + it("lets a mirrored admin delete a domain while the mirror is ready", async () => { + const { status, deleted } = await deleteDomain(MirrorReadinessState.Ready(), "member"); + // WorkOS is not consulted for membership while the mirror is ready: the + // mirror row (admin) decides, and the demotion lands through the + // reconciler within its lag budget. + expect(status).toBe(200); + expect(deleted).toEqual([DOMAIN]); + }); + + it("refuses a demoted admin while the mirror is not ready, however stale the mirror row is", async () => { + const { status, deleted } = await deleteDomain( + MirrorReadinessState.ReconcilerStale({ drainedAt: null }), + "member", + ); + expect(status, "WorkOS says member; the stale admin row does not grant the delete").toBe(403); + expect(deleted).toEqual([]); + }); + + it("lets an admin WorkOS confirms delete a domain while the mirror is not ready", async () => { + const { status, deleted } = await deleteDomain( + MirrorReadinessState.ReconcilerStale({ drainedAt: null }), + "admin", + ); + expect(status).toBe(200); + expect(deleted).toEqual([DOMAIN]); + }); +}); diff --git a/apps/cloud/src/org/handlers.ts b/apps/cloud/src/org/handlers.ts index b338eee3f7..64c9f329b2 100644 --- a/apps/cloud/src/org/handlers.ts +++ b/apps/cloud/src/org/handlers.ts @@ -7,6 +7,7 @@ import { WorkOSClient } from "../auth/workos"; import { AutumnService } from "../extensions/billing/service"; import { resolveOrganization } from "../auth/organization"; import { Forbidden, OrgHttpApi } from "./api"; +import { OrgMemberRole } from "./auth-middleware"; // --------------------------------------------------------------------------- // Cloud-local org handlers — WorkOS domain-verification only. Members / roles / @@ -15,18 +16,21 @@ import { Forbidden, OrgHttpApi } from "./api"; // `OrgAuth` (org-scoped cookie session). // --------------------------------------------------------------------------- -const requireAdmin = Effect.gen(function* () { - const auth = yield* AuthContext; - // This plane is mounted behind the session-only `orgAuthMiddleware`, so the - // caller is always a member — but `AuthContext.accountId` is nullable for the - // platform credential, and membership of "no member" is not a question worth - // asking WorkOS. Refuse rather than assert. - if (auth.accountId === null) return yield* new Forbidden(); - const workos = yield* WorkOSClient; - const currentMembership = yield* workos.getUserOrgMembership(auth.organizationId, auth.accountId); - if (!currentMembership || currentMembership.role?.slug !== "admin") { - return yield* new Forbidden(); - } +/** + * The admin gate for the domain endpoints: the caller must be an `admin` of + * the session org. The role is the one `orgAuthMiddleware` resolved for this + * request through `authorizeOrganizationSelector` — an ACTIVE membership, + * read from the mirror only while the mirror is ready and from WorkOS + * otherwise — and is provided as `OrgMemberRole`. The gate is that one value, + * as on the sibling gates (`workos-account-service.ts` `requireAdmin`, + * `admin-users-api.ts` `authorizeTenant`): a second read of the mirror here + * would skip the readiness rule, and while the reconciler is behind a stale + * row would keep admitting an admin demoted in the WorkOS dashboard. Fails + * with `Forbidden` for a member. Exported for its test only. + */ +export const requireAdmin = Effect.gen(function* () { + const { memberRole } = yield* OrgMemberRole; + if (memberRole !== "admin") return yield* new Forbidden(); }); // Target-ownership check — independent of caller privilege. `requireAdmin` @@ -37,7 +41,8 @@ const requireAdmin = Effect.gen(function* () { // workspace API key is workspace-wide and WorkOS does not enforce per-org // ownership on delete by id. Failures (not found OR org mismatch) both surface // as Forbidden so we don't leak existence of ids outside the caller's org. -const assertDomainInSessionOrg = (domainId: string) => +// Exported for its test only. +export const assertDomainInSessionOrg = (domainId: string) => Effect.gen(function* () { const auth = yield* AuthContext; const workos = yield* WorkOSClient; diff --git a/apps/host-selfhost/src/account/better-auth-account-provider.ts b/apps/host-selfhost/src/account/better-auth-account-provider.ts index c7ed75a30e..885ed74eee 100644 --- a/apps/host-selfhost/src/account/better-auth-account-provider.ts +++ b/apps/host-selfhost/src/account/better-auth-account-provider.ts @@ -145,7 +145,7 @@ export const betterAuthAccountProvider: Layer.Layer ({ id: member.id, userId: member.userId, - email: member.user?.email ?? "", + email: member.user?.email ?? null, name: member.user?.name ?? null, avatarUrl: member.user?.image ?? null, role: member.role, diff --git a/apps/host-selfhost/src/admin/admin-users-api.ts b/apps/host-selfhost/src/admin/admin-users-api.ts index 38f767a168..8a4d458c52 100644 --- a/apps/host-selfhost/src/admin/admin-users-api.ts +++ b/apps/host-selfhost/src/admin/admin-users-api.ts @@ -16,7 +16,11 @@ // // The READ half is identical to cloud's: a subject-less, tenant-reach executor // from `makePlatformExecutor`, projected by the shared `admin/reads`. Self-host -// is single-tenant, so the tenant is always the boot-seeded org. +// is single-tenant, so the tenant is always the boot-seeded org. Identity +// (email/name per row), the `?email=` resolver and the `?search=` match all +// come from the shared `MemberDirectory` — here Better Auth's `member` + `user` +// tables through its own adapter (`auth/member-directory.ts`), the SAME read +// the MCP plane makes, so no plane keeps its own join. // --------------------------------------------------------------------------- import { HttpRouter } from "effect/unstable/http"; @@ -26,18 +30,17 @@ import { AdminUsersProvider, DbProvider, HostConfig, + MemberDirectory, PluginsProvider, + adminUserDirectoryFromMembers, getAdminUser, listAdminUserConnections, listAdminUsers, listAdminUsersWithConnections, makeAdminUsersApiLayer, makePlatformExecutor, - normalizeAdminUserEmail, platformViewOf, requestScopedMiddleware, - type AdminUserDirectory, - type AdminUserIdentity, type AdminUsersHeaders, } from "@executor-js/api/server"; import { @@ -67,70 +70,6 @@ const requireAdmin = (headers: AdminUsersHeaders) => ), ); -/** - * Self-host's member directory: `externalId` → email/name. - * - * THE JOIN KEY is `member.userId`, the Better Auth `user.id` — precisely what - * `auth/identity.ts` binds as `accountId` and therefore what the subject table - * records in `external_id`. `member.id` is the organization `member` ROW id and - * joins to nothing; the two look alike, so the choice is pinned here and in the - * node test rather than left to a reader. - * - * One `listMembers` call per request: Better Auth's organization plugin already - * attaches the `user` row to each member, so email and name arrive with the - * membership and no per-user lookup is needed. The requested ids are not passed - * to the call — the plugin offers no id filter, and a single-instance member - * list is small — but the caller only reads the ids it asked for. - * - * Runs as the CALLER, using their own admin headers, so this reads exactly the - * directory that session is already entitled to on `/account/members`. - */ -const listMembers = (auth: BetterAuthHandle["auth"], headers: AdminUsersHeaders) => - Effect.tryPromise(() => auth.api.listMembers({ headers: new Headers(headers) })); - -/** - * Both directions of self-host's directory, over the SAME single `listMembers` - * read. - * - * The reverse (email → `user.id`) needs no extra call and no new permission: - * the organization plugin already attaches the `user` row to each member, so - * the email is sitting beside the id the forward join uses. Better Auth - * lower-cases every email it writes, but the directory value is normalized - * anyway so this host cannot answer differently from cloud if that ever - * changes. - * - * A member with no `user.email` cannot match — `null` is not an address, and - * coercing it to "" would let an empty `?email=` select an arbitrary row. - */ -const userDirectory = ( - auth: BetterAuthHandle["auth"], - headers: AdminUsersHeaders, -): AdminUserDirectory => ({ - identities: () => - listMembers(auth, headers).pipe( - Effect.map((result) => { - const identities = new Map(); - for (const member of result.members) { - identities.set(member.userId, { - email: member.user?.email ?? null, - displayName: member.user?.name ?? null, - }); - } - return identities; - }), - ), - resolveEmail: (email) => - listMembers(auth, headers).pipe( - Effect.map( - (result) => - result.members.find((member) => { - const stored = member.user?.email; - return stored != null && normalizeAdminUserEmail(stored) === email; - })?.userId ?? null, - ), - ), -}); - const withPlatformView = ( headers: AdminUsersHeaders, organizationId: string, @@ -153,24 +92,25 @@ const withPlatformView = = Layer.effect(AdminUsersProvider)( Effect.gen(function* () { const context = yield* Effect.context(); - const { auth, organizationId } = yield* BetterAuth; + const { organizationId } = yield* BetterAuth; + // Scoped to the INSTANCE's org — the same one the platform view is opened + // for, never the caller's `activeOrganizationId` (see require-admin.ts). + const directory = adminUserDirectoryFromMembers(yield* MemberDirectory, organizationId); return AdminUsersProvider.of({ listUsers: (headers, options) => withPlatformView(headers, organizationId, (executor) => platformViewOf(executor).pipe( - Effect.flatMap((admin) => listAdminUsers(admin, options, userDirectory(auth, headers))), + Effect.flatMap((admin) => listAdminUsers(admin, options, directory)), ), ).pipe(Effect.provideContext(context)), listUsersWithConnections: (headers, options) => withPlatformView(headers, organizationId, (executor) => platformViewOf(executor).pipe( - Effect.flatMap((admin) => - listAdminUsersWithConnections(admin, options, userDirectory(auth, headers)), - ), + Effect.flatMap((admin) => listAdminUsersWithConnections(admin, options, directory)), ), ).pipe(Effect.provideContext(context)), listUserConnections: (headers, externalId) => @@ -182,9 +122,7 @@ export const betterAuthAdminUsersProvider: Layer.Layer< getUser: (headers, identifier) => withPlatformView(headers, organizationId, (executor) => platformViewOf(executor).pipe( - Effect.flatMap((admin) => - getAdminUser(admin, identifier, userDirectory(auth, headers)), - ), + Effect.flatMap((admin) => getAdminUser(admin, identifier, directory)), ), ).pipe(Effect.provideContext(context)), }); @@ -193,6 +131,9 @@ export const betterAuthAdminUsersProvider: Layer.Layer< export interface SelfHostAdminUsersApiDeps { readonly betterAuth: BetterAuthHandle; + /** The boot-built `MemberDirectory` (see `resolveAuthProviders`), so this + * plane reads the same directory instance every other plane does. */ + readonly memberDirectory: Layer.Layer; readonly db: SelfHostDbHandle; readonly mountPrefix: `/${string}`; } @@ -206,6 +147,7 @@ export interface SelfHostAdminUsersApiDeps { */ export const makeSelfHostAdminUsersApiLayer = ({ betterAuth, + memberDirectory, db, mountPrefix, }: SelfHostAdminUsersApiDeps) => { @@ -214,6 +156,7 @@ export const makeSelfHostAdminUsersApiLayer = ({ ); const provider = betterAuthAdminUsersProvider.pipe( Layer.provide(Layer.succeed(BetterAuth)(betterAuth)), + Layer.provide(memberDirectory), Layer.provide(SelfHostDbProvider), Layer.provide(SelfHostPluginsProvider), Layer.provide(SelfHostHostConfig), diff --git a/apps/host-selfhost/src/app.ts b/apps/host-selfhost/src/app.ts index a2341702a8..18bcdf9fc2 100644 --- a/apps/host-selfhost/src/app.ts +++ b/apps/host-selfhost/src/app.ts @@ -73,7 +73,8 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // ---- auth providers --------------------------------------------------- // Better Auth: cookie/bearer/api-key identity + /api/auth handler + account // API + MCP OAuth seam, all over the shared libSQL handle. - const { identityLayer, authHandler, betterAuth } = await resolveAuthProviders(dbHandle); + const { identityLayer, memberDirectoryLayer, authHandler, betterAuth } = + await resolveAuthProviders(dbHandle); // ---- the in-process MCP serving seams (+ shutdown hook) ---------------- const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config); @@ -130,7 +131,12 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // Tenant-wide admin users API (/api/admin/users*): the owner's view of // who uses this instance and what they've connected. Owner/admin-gated, // same as the invite routes above. - makeSelfHostAdminUsersApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), + makeSelfHostAdminUsersApiLayer({ + betterAuth, + memberDirectory: memberDirectoryLayer, + db: dbHandle, + mountPrefix: "/api", + }), // Public system API: /api/health + /api/setup-status (unauthenticated). makeSelfHostSystemApiLayer({ betterAuth, db: dbHandle, mountPrefix: "/api" }), // Swagger UI at /docs, over the /api-prefixed spec (matches the served paths). @@ -141,11 +147,14 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { // The boot-scoped context provideMerge'd under everything: the long-lived DB // handle (read by the DbProvider seam, Better Auth, and the MCP store) + the // resolved identity (captured once by the execution middleware + MCP auth) + // + the member directory (the shared membership read seam, boot-scoped + // beside identity because Better Auth's handle is an app singleton) // + the artifact-usage observer (this HTTP plane is the console UI's data // layer, so operations it serves file as `via: "ui"`). boot: Layer.mergeAll( Layer.succeed(SelfHostDb)(dbHandle), identityLayer, + memberDirectoryLayer, Layer.succeed(ArtifactUsageObserver)((action) => selfHostAnalytics.record(`artifact_${action}`, { via: "ui" }), ), diff --git a/apps/host-selfhost/src/auth/index.ts b/apps/host-selfhost/src/auth/index.ts index bf9a4b5839..1005970c21 100644 --- a/apps/host-selfhost/src/auth/index.ts +++ b/apps/host-selfhost/src/auth/index.ts @@ -1,24 +1,28 @@ import { Layer } from "effect"; -import { IdentityProvider } from "@executor-js/api/server"; +import { IdentityProvider, MemberDirectory } from "@executor-js/api/server"; import { loadConfig } from "../config"; import type { SelfHostDbHandle } from "../db/self-host-db"; import { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; import { betterAuthIdentityLayer } from "./identity"; +import { betterAuthMemberDirectoryLayer } from "./member-directory"; import { consentRedirectClientId, withClientName, withForcedMcpConsent } from "./force-mcp-consent"; import { rewriteInvalidOrigin } from "./invalid-origin-help"; export { BetterAuth, buildBetterAuth, type BetterAuthHandle } from "./better-auth"; export { betterAuthIdentityLayer } from "./identity"; +export { betterAuthMemberDirectoryLayer } from "./member-directory"; // --------------------------------------------------------------------------- // Resolve the self-host auth providers. // // Build the Better Auth instance over the shared libSQL file, expose its -// `IdentityProvider` (cookie/bearer/api-key) and its web handler (mounted at -// /api/auth/*). Returns the live `BetterAuthHandle` so the composition root can -// build the account API and the Better Auth MCP OAuth seam. +// `IdentityProvider` (cookie/bearer/api-key), its `MemberDirectory` (the +// shared membership read seam over the org plugin's tables) and its web +// handler (mounted at /api/auth/*). Returns the live `BetterAuthHandle` so the +// composition root can build the account API and the Better Auth MCP OAuth +// seam. // // This is the one and only production auth path. Tests that need a fake identity // (single-admin / header-driven) compose `ExecutorApp.make` directly through @@ -29,6 +33,8 @@ export { betterAuthIdentityLayer } from "./identity"; export interface ResolvedAuthProviders { /** The resolved Better Auth `IdentityProvider` seam (cookie/bearer/api-key). */ readonly identityLayer: Layer.Layer; + /** The resolved Better Auth `MemberDirectory` seam (org members + users). */ + readonly memberDirectoryLayer: Layer.Layer; /** Better Auth's web handler (`/api/auth/*`). */ readonly authHandler: (request: Request) => Promise; /** The live Better Auth handle (account API + Better Auth MCP OAuth seam). */ @@ -78,6 +84,7 @@ export const resolveAuthProviders = async ( return { identityLayer: betterAuthIdentityLayer.pipe(Layer.provide(betterAuthLayer)), + memberDirectoryLayer: betterAuthMemberDirectoryLayer.pipe(Layer.provide(betterAuthLayer)), authHandler, betterAuth, }; diff --git a/apps/host-selfhost/src/auth/member-directory.test.ts b/apps/host-selfhost/src/auth/member-directory.test.ts index 1671adb8da..bd25e8d13b 100644 --- a/apps/host-selfhost/src/auth/member-directory.test.ts +++ b/apps/host-selfhost/src/auth/member-directory.test.ts @@ -114,10 +114,18 @@ describe("self-host MemberDirectory", () => { const result = await run( Effect.gen(function* () { const d = yield* MemberDirectory; + const one = yield* d.membership(grace, organizationId); + const graceRow = one?.membershipId ?? "member_missing"; return { - one: yield* d.membership(grace, organizationId), + one, oneInactive: yield* d.membership(grace, organizationId, ["inactive"]), none: yield* d.membership(outsider.user.id, organizationId), + byId: yield* d.membershipById(organizationId, graceRow), + byIdForeign: yield* d.membershipById("org_other", graceRow), + byIdUnknown: yield* d.membershipById(organizationId, "member_unknown"), + ofGrace: yield* d.membershipsOf(grace), + ofOutsider: yield* d.membershipsOf(outsider.user.id), + ofGraceInactive: yield* d.membershipsOf(grace, ["inactive"]), batch: yield* d.membersById(organizationId, [ada, linus, outsider.user.id, "nobody"]), byEmail: yield* d.findByEmail(organizationId, "ada.lovelace@placeholder.test"), unknown: yield* d.findByEmail(organizationId, "outsider@placeholder.test"), @@ -127,6 +135,15 @@ describe("self-host MemberDirectory", () => { expect(result.one?.role).toBe("member"); expect(result.oneInactive, "Better Auth members are always active").toBeNull(); expect(result.none).toBeNull(); + expect(result.byId?.accountId).toBe(grace); + expect(result.byIdForeign, "the member row id is scoped to its org").toBeNull(); + expect(result.byIdUnknown).toBeNull(); + expect( + result.ofGrace.map((m) => m.organizationId), + "the single org", + ).toEqual([organizationId]); + expect(result.ofOutsider).toEqual([]); + expect(result.ofGraceInactive, "Better Auth members are always active").toEqual([]); expect([...result.batch.keys()].sort()).toEqual([ada, linus].sort()); expect(result.byEmail?.accountId).toBe(ada); expect(result.unknown, "a user with no membership is not a member").toBeNull(); diff --git a/apps/host-selfhost/src/auth/member-directory.ts b/apps/host-selfhost/src/auth/member-directory.ts index 4c84841174..f6787ac3e6 100644 --- a/apps/host-selfhost/src/auth/member-directory.ts +++ b/apps/host-selfhost/src/auth/member-directory.ts @@ -170,6 +170,29 @@ const makeService = (adapter: BetterAuthAdapter): MemberDirectoryShape => { { field: "organizationId", value: organizationId }, ]).pipe(Effect.map((members) => members[0] ?? null)), + membershipById: (organizationId, membershipId) => + load("membershipById", [ + { field: "id", value: membershipId }, + { field: "organizationId", value: organizationId }, + ]).pipe(Effect.map((members) => members[0] ?? null)), + + membershipsOf: (accountId, statuses = DEFAULT_MEMBER_STATUSES) => + // Every Better Auth member is active; a query for other statuses only + // has nothing to report. + !statuses.includes("active") + ? Effect.succeed([]) + : load("membershipsOf", [{ field: "userId", value: accountId }]).pipe( + Effect.map((members) => + [...members].sort((a, b) => + a.organizationId < b.organizationId + ? -1 + : a.organizationId > b.organizationId + ? 1 + : 0, + ), + ), + ), + members: (organizationId, query: MemberQuery = {}) => Effect.gen(function* () { if (!reportsActive(query.statuses ?? DEFAULT_MEMBER_STATUSES)) return []; diff --git a/packages/core/api/src/account/api.ts b/packages/core/api/src/account/api.ts index 01581b783a..87ad648b9e 100644 --- a/packages/core/api/src/account/api.ts +++ b/packages/core/api/src/account/api.ts @@ -109,10 +109,17 @@ export const OrgApiKeysResponse = Schema.Struct({ apiKeys: Schema.Array(ApiKeySummary), }); +/** + * One member of the caller's organization, as the host's member directory + * reports them. `email` is nullable: a host can hold a membership whose + * profile it has not yet learned (cloud mirrors the membership before the + * user record lands), and reporting `""` for that would let the UI render an + * empty address as if it were one. + */ export const OrgMember = Schema.Struct({ id: Schema.String, userId: Schema.String, - email: Schema.String, + email: Schema.NullOr(Schema.String), name: Schema.NullOr(Schema.String), avatarUrl: Schema.NullOr(Schema.String), role: Schema.String, diff --git a/packages/core/api/src/admin/admin-users.test.ts b/packages/core/api/src/admin/admin-users.test.ts index aec566c295..b2c73c8e7f 100644 --- a/packages/core/api/src/admin/admin-users.test.ts +++ b/packages/core/api/src/admin/admin-users.test.ts @@ -395,6 +395,7 @@ const A1_EMAIL = "a1@users.test"; const stubUserDirectory = (options: { readonly seen?: string[][]; readonly resolved?: string[]; + readonly searched?: string[]; }): AdminUserDirectory => ({ identities: (externalIds) => { options.seen?.push([...externalIds]); @@ -405,6 +406,13 @@ const stubUserDirectory = (options: { // Compares a NORMALIZED stored value, the rule both real hosts follow. return Effect.succeed(A1_EMAIL_STORED.toLowerCase() === email ? USER_A1 : null); }, + search: (term) => { + options.searched?.push(term); + // The one member the directory knows, matched on the normalized email or + // the display name — the substring rule both real hosts apply. + const haystack = [A1_EMAIL_STORED.toLowerCase(), "user a1"]; + return Effect.succeed(haystack.some((value) => value.includes(term)) ? [USER_A1] : []); + }, }); /** The failure a host's directory raises — WorkOS or Better Auth being @@ -1022,6 +1030,102 @@ describe("admin users API", () => { ), ); + // ── ?search= ────────────────────────────────────────────────────────────── + + it.effect("filters the bulk lists by a name or email substring, case-insensitively", () => + withDb((db) => + Effect.gen(function* () { + yield* seed(db); + const searched: string[] = []; + const web = yield* webHandlerFor( + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize, { + ...stubUserDirectory({ searched }), + }), + ); + + // Part of the address, typed in the wrong case and with stray spaces: + // the handler normalizes it before the directory sees it. + const byEmail = yield* jsonOf( + yield* get(web, `/admin/users?search=${encodeURIComponent(" A1@USERS ")}`, ORG_A), + ); + expect(byEmail.users.map((user) => user.externalId)).toEqual([USER_A1]); + expect(byEmail.users[0]?.email, "the page still carries identity").toBe(A1_EMAIL_STORED); + + // Part of the name, on the joined view. + const byName = yield* jsonOf( + yield* get(web, "/admin/users/with-connections?search=User%20a1", ORG_A), + ); + expect(byName.users.map((user) => user.externalId)).toEqual([USER_A1]); + expect(byName.users[0]?.connections.map((c) => c.integration)).toEqual(["github"]); + + // No match is an empty page, never the unfiltered tenant. + const nobody = yield* jsonOf( + yield* get(web, "/admin/users?search=nobody", ORG_A), + ); + expect(nobody.users).toEqual([]); + + expect(searched, "one directory search per request, normalized").toEqual([ + "a1@users", + "user a1", + "nobody", + ]); + }), + ), + ); + + it.effect("a blank search is no filter at all", () => + withDb((db) => + Effect.gen(function* () { + yield* seed(db); + const searched: string[] = []; + const web = yield* webHandlerFor( + stubProvider( + (tenant) => platformExecutorFor(db, tenant), + headerAuthorize, + stubUserDirectory({ searched }), + ), + ); + + const body = yield* jsonOf(yield* get(web, "/admin/users?search=%20%20", ORG_A)); + expect(body.users.map((user) => user.externalId)).toEqual([USER_A1, USER_A2]); + expect(searched, "the directory is never asked to match whitespace").toEqual([]); + }), + ), + ); + + it.effect("returns an empty page for a search no host directory can answer", () => + withDb((db) => + Effect.gen(function* () { + yield* seed(db); + // A directory with identities only: it cannot search, so a search + // filter must select nothing rather than hand back the whole tenant. + const web = yield* webHandlerFor( + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize, { + identities: stubDirectory([]), + }), + ); + + const body = yield* jsonOf(yield* get(web, "/admin/users?search=a1", ORG_A)); + expect(body.users).toEqual([]); + }), + ), + ); + + it.effect("500s when the directory search fails, rather than reporting no match", () => + withDb((db) => + Effect.gen(function* () { + yield* seed(db); + const web = yield* webHandlerFor( + stubProvider((tenant) => platformExecutorFor(db, tenant), headerAuthorize, { + search: () => Effect.fail(new DirectoryUnavailable({ message: "down" })), + }), + ); + + expect((yield* get(web, "/admin/users?search=a1", ORG_A)).status).toBe(500); + }), + ), + ); + // A resolver OUTAGE must not read as "no such user": that is a wrong answer an // operator would act on. Contrast with the identity join, which degrades to // unnamed rows precisely because it is decoration. @@ -1096,8 +1200,8 @@ const A_SUBJECT: AdminSubject = { /** An `ExecutorAdmin` that answers everything and records the reads it was * asked for, so a test can assert the call the filter chose. */ const recordingAdmin = (calls: string[]): ExecutorAdmin => ({ - listSubjects: () => { - calls.push("listSubjects"); + listSubjects: (options) => { + calls.push(`listSubjects:${options?.externalIds?.join(",") ?? "*"}`); return Effect.succeed([A_SUBJECT]); }, getSubject: () => { @@ -1108,8 +1212,8 @@ const recordingAdmin = (calls: string[]): ExecutorAdmin => ({ calls.push("listSubjectConnections"); return Effect.succeed([]); }, - listSubjectsWithConnections: () => { - calls.push("listSubjectsWithConnections"); + listSubjectsWithConnections: (options) => { + calls.push(`listSubjectsWithConnections:${options?.externalIds?.join(",") ?? "*"}`); return Effect.succeed([{ ...A_SUBJECT, connections: [] }]); }, getSubjectWithConnections: () => { @@ -1187,7 +1291,56 @@ describe("admin users reads — the ?email= filter is applied before the read", const calls: string[] = []; yield* listUsersWithConnections(recordingAdmin(calls), { limit: 50 }, stubUserDirectory({})); - expect(calls).toEqual(["listSubjectsWithConnections"]); + expect(calls).toEqual(["listSubjectsWithConnections:*"]); + }), + ); +}); + +// --------------------------------------------------------------------------- +// `?search=` is FILTER-THEN-PAGE through storage: the directory names the +// matching principals, and the paged read carries exactly that set as its +// `externalIds` filter — never a page scan that is filtered afterwards. +// --------------------------------------------------------------------------- + +describe("admin users reads — the ?search= filter pages the directory's matches", () => { + it.effect("hands the matched ids to the paged read, on both views", () => + Effect.gen(function* () { + const calls: string[] = []; + const admin = recordingAdmin(calls); + + yield* listUsers(admin, { search: "a1" }, stubUserDirectory({})); + yield* listUsersWithConnections(admin, { search: "user", limit: 10 }, stubUserDirectory({})); + + expect(calls).toEqual([`listSubjects:${USER_A1}`, `listSubjectsWithConnections:${USER_A1}`]); + }), + ); + + it.effect("issues NO storage read when the directory matches nobody", () => + Effect.gen(function* () { + const calls: string[] = []; + const body = yield* listUsersWithConnections( + recordingAdmin(calls), + { search: "nobody" }, + stubUserDirectory({}), + ); + + expect(calls).toEqual([]); + expect(body.users).toEqual([]); + }), + ); + + it.effect("lets an exact email win over a search term", () => + Effect.gen(function* () { + const calls: string[] = []; + const searched: string[] = []; + yield* listUsers( + recordingAdmin(calls), + { email: A1_EMAIL, search: "anything" }, + stubUserDirectory({ searched }), + ); + + expect(calls, "the keyed read, not a search").toEqual(["getSubject"]); + expect(searched).toEqual([]); }), ); }); diff --git a/packages/core/api/src/admin/api.ts b/packages/core/api/src/admin/api.ts index 69e76db94d..acda949904 100644 --- a/packages/core/api/src/admin/api.ts +++ b/packages/core/api/src/admin/api.ts @@ -261,6 +261,15 @@ const AdminUserIdentifierParams = { identifier: Schema.String }; // handler seam (`normalizeEmail`), which is also where the single-user path // parameter is normalized, so both entry points share ONE rule rather than a // schema transform on one and hand-rolled code on the other. +// +// `search` is the SUBSTRING counterpart: a case-insensitive match over each +// member's email and name in the host's directory, for the operator who knows +// a person's name or part of an address rather than the exact one. Like +// `email` it narrows the fixed list shape and is applied BEFORE paging (the +// directory names the matching principals; storage pages that set), so a +// window on a searched list is a window on the matches. A blank term is no +// filter. When both filters are present `email` wins: it names one principal, +// and there is nothing left for a search to narrow. const AdminListQuery = Schema.Struct({ limit: Schema.optional( Schema.FiniteFromString.check(Schema.isInt(), Schema.isBetween({ minimum: 1, maximum: 500 })), @@ -272,6 +281,7 @@ const AdminListQuery = Schema.Struct({ ), ), email: Schema.optional(Schema.String), + search: Schema.optional(Schema.String), }); // --------------------------------------------------------------------------- diff --git a/packages/core/api/src/admin/handlers.ts b/packages/core/api/src/admin/handlers.ts index f5d6ccc8b9..88625b7551 100644 --- a/packages/core/api/src/admin/handlers.ts +++ b/packages/core/api/src/admin/handlers.ts @@ -2,6 +2,7 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServerRequest } from "effect/unstable/http"; import { Effect } from "effect"; +import { normalizeMemberSearch } from "../server/member-directory"; import { AdminUsersHttpApi } from "./api"; import { normalizeEmail } from "./reads"; import { AdminUsersProvider, type AdminUsersHeaders, type AdminUsersListOptions } from "./service"; @@ -24,15 +25,23 @@ const requestHeaders = Effect.map( // than an explicit `undefined` overriding them. // `email` is normalized here rather than in the contract schema, so the filter // and the single-user path parameter share ONE rule (`normalizeEmail`). +// `search` gets the directory's own rule (`normalizeMemberSearch`: the same +// trim + lower-case, and a blank term is no filter at all — dropped here so a +// provider never sees `search: ""`). const listOptions = (query: { readonly limit?: number | undefined; readonly offset?: number | undefined; readonly email?: string | undefined; -}): AdminUsersListOptions => ({ - ...(query.limit === undefined ? {} : { limit: query.limit }), - ...(query.offset === undefined ? {} : { offset: query.offset }), - ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }), -}); + readonly search?: string | undefined; +}): AdminUsersListOptions => { + const search = normalizeMemberSearch(query.search); + return { + ...(query.limit === undefined ? {} : { limit: query.limit }), + ...(query.offset === undefined ? {} : { offset: query.offset }), + ...(query.email === undefined ? {} : { email: normalizeEmail(query.email) }), + ...(search === undefined ? {} : { search }), + }; +}; export const AdminUsersHandlers = HttpApiBuilder.group( AdminUsersHttpApi, diff --git a/packages/core/api/src/admin/member-directory.ts b/packages/core/api/src/admin/member-directory.ts index e90ad89346..df7c9beb37 100644 --- a/packages/core/api/src/admin/member-directory.ts +++ b/packages/core/api/src/admin/member-directory.ts @@ -10,21 +10,26 @@ import { MemberStatus, type MemberDirectoryShape } from "../server/member-direct import type { AdminUserDirectory, AdminUserIdentity } from "./reads"; /** - * Both directions of the admin plane's directory over one org's + * Every direction of the admin plane's directory over one org's * {@link MemberDirectoryShape}. * * `identities` is one batched `membersById` read for the page of ids (never a * lookup per user); a member the org does not hold reports absent identity. * `resolveEmail` receives the already-normalized email the contract promises * and answers with the host principal id, or `null` when no member has it. + * `search` is one `members` read for the term, answering with the matching + * principal ids in directory order. * - * Both read ANY membership status, not the directory's active + pending - * default: this plane reports footprint, not current access. A member who was - * removed while their connections remain must still be named on the users - * page and findable by the address an operator has for them. + * Every direction reads ANY membership status — the same reach `membersById` + * and `findByEmail` have by contract, and `search` asks for explicitly rather + * than taking `members`' active + pending default. This plane reports + * footprint, not current access: a member who was deactivated while their + * connections remain must still be findable by the address or name an + * operator has for them, exactly as `?email=` already finds them. * - * Both fail with `MemberDirectoryError`, which the shared reads treat as a - * decorative-join outage (identities) or surface as a failed read (resolve). + * All fail with `MemberDirectoryError`, which the shared reads treat as a + * decorative-join outage (identities) or surface as a failed read (resolve, + * search). */ export const adminUserDirectoryFromMembers = ( directory: MemberDirectoryShape, @@ -47,4 +52,8 @@ export const adminUserDirectoryFromMembers = ( directory .findByEmail(organizationId, email, MemberStatus.literals) .pipe(Effect.map((member) => (member === null ? null : member.accountId))), + search: (term) => + directory + .members(organizationId, { search: term, statuses: MemberStatus.literals }) + .pipe(Effect.map((members) => members.map((member) => member.accountId))), }); diff --git a/packages/core/api/src/admin/reads.ts b/packages/core/api/src/admin/reads.ts index 96b6f2bbdc..6110686268 100644 --- a/packages/core/api/src/admin/reads.ts +++ b/packages/core/api/src/admin/reads.ts @@ -16,6 +16,7 @@ import { Effect } from "effect"; import type { AdminConnection, + AdminListSubjectsOptions, AdminSubject, AdminSubjectWithConnections, Executor, @@ -114,12 +115,27 @@ export type AdminIdentityDirectory = ( */ export type AdminEmailResolver = (email: string) => Effect.Effect; -/** Both directions of a host's member directory. Optional as a whole (a host - * with no directory reports unnamed rows and cannot resolve emails), and - * optional per direction. */ +/** + * The directory's SEARCH: a normalized term (trimmed + lower-cased, the same + * rule `normalizeEmail` applies) → the host-auth principal ids of every member + * whose email or name contains it, in the directory's own order. + * + * Unlike `resolveEmail` this names a SET, and the reads page that set through + * storage rather than in memory: the ids go into the SDK's `externalIds` + * filter and the caller's `limit`/`offset` apply there. An empty result means + * no member matches, and costs no storage read. Failures are the caller's to + * interpret on the same terms as `resolveEmail` — a search that cannot run + * must not quietly become "nobody matches". + */ +export type AdminMemberSearch = (term: string) => Effect.Effect; + +/** Every direction of a host's member directory. Optional as a whole (a host + * with no directory reports unnamed rows and cannot resolve emails or search), + * and optional per direction. */ export interface AdminUserDirectory { readonly identities?: AdminIdentityDirectory; readonly resolveEmail?: AdminEmailResolver; + readonly search?: AdminMemberSearch; } /** Identity is decoration on an operator view, not part of the answer: a @@ -292,6 +308,72 @@ const selectByEmail = ( return row === null ? [] : pageOf([row], options); }); +/** + * The `?search=` read: FILTER by the directory, then PAGE through storage. + * + * The term names a SET of principals rather than one, so unlike `?email=` it + * cannot become a keyed read — but it still must not become a page-then-filter + * scan, which on a large tenant would page past every unmatched subject before + * finding the first match. So the directory answers with the matching ids and + * storage pages exactly that set (`externalIds` + the caller's window), which + * keeps "filter, then page" as the one paging rule every filtered list here + * follows. + * + * A host with no search direction answers nothing, for the same reason an + * unanswerable `?email=` does: a filter no host can apply must return an empty + * page, never an unfiltered one. A search FAILURE is a 500 on the same terms as + * a resolver failure. + */ +const selectBySearch = ( + directory: AdminUserDirectory, + term: string, + read: (externalIds: readonly string[]) => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const search = directory.search; + if (!search) return []; + const wanted = yield* search(term).pipe( + Effect.mapError(() => new AdminUsersError({ message: "Failed to search the directory" })), + ); + // Nobody matches: an empty page, and no storage read for an `in ()` that + // could not match anyway. + if (wanted.length === 0) return []; + return yield* read(wanted); + }); + +/** + * Which filtered read a list request takes. `email` names ONE principal and + * wins when both are present: a keyed read is the more specific answer, and + * a search term beside an exact address has nothing left to narrow. + */ +const selectSubjects = ( + directory: AdminUserDirectory, + options: AdminUsersListOptions, + reads: { + readonly page: ( + paging: AdminListSubjectsOptions, + ) => Effect.Effect; + readonly one: (externalId: string) => Effect.Effect; + }, +): Effect.Effect => { + if (options.email !== undefined) { + return selectByEmail(directory, options.email, options, reads.one); + } + if (options.search !== undefined) { + return selectBySearch(directory, options.search, (externalIds) => + reads.page({ ...pagingOf(options), externalIds }), + ); + } + return reads.page(pagingOf(options)); +}; + +/** Only the paging window — never the filters — reaches the SDK: the filters + * are resolved here, and the SDK's own `externalIds` is set by this file. */ +const pagingOf = (options: AdminUsersListOptions): AdminListSubjectsOptions => ({ + ...(options.limit === undefined ? {} : { limit: options.limit }), + ...(options.offset === undefined ? {} : { offset: options.offset }), +}); + export const listUsers = ( admin: ExecutorAdmin, options: AdminUsersListOptions, @@ -299,12 +381,10 @@ export const listUsers = ( ): Effect.Effect => Effect.gen(function* () { const dir = asDirectory(directory); - const subjects = - options.email === undefined - ? yield* admin.listSubjects(options).pipe(Effect.mapError(readFailed("users"))) - : yield* selectByEmail(dir, options.email, options, (externalId) => - admin.getSubject(externalId).pipe(Effect.mapError(readFailed("users"))), - ); + const subjects = yield* selectSubjects(dir, options, { + page: (paging) => admin.listSubjects(paging).pipe(Effect.mapError(readFailed("users"))), + one: (externalId) => admin.getSubject(externalId).pipe(Effect.mapError(readFailed("users"))), + }); // One directory read for the page that was actually returned, joined in // memory — never a lookup per user. const identities = yield* resolveIdentities( @@ -321,14 +401,12 @@ export const listUsersWithConnections = ( ): Effect.Effect => Effect.gen(function* () { const dir = asDirectory(directory); - const subjects = - options.email === undefined - ? yield* admin - .listSubjectsWithConnections(options) - .pipe(Effect.mapError(readFailed("users"))) - : yield* selectByEmail(dir, options.email, options, (externalId) => - admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("users"))), - ); + const subjects = yield* selectSubjects(dir, options, { + page: (paging) => + admin.listSubjectsWithConnections(paging).pipe(Effect.mapError(readFailed("users"))), + one: (externalId) => + admin.getSubjectWithConnections(externalId).pipe(Effect.mapError(readFailed("users"))), + }); const identities = yield* resolveIdentities( dir.identities, subjects.map((subject) => subject.externalId), diff --git a/packages/core/api/src/admin/service.ts b/packages/core/api/src/admin/service.ts index d314e12d3b..97bfac3854 100644 --- a/packages/core/api/src/admin/service.ts +++ b/packages/core/api/src/admin/service.ts @@ -31,12 +31,15 @@ import { export type AdminUsersHeaders = Record; /** Paging and filtering, mirroring the SDK's `AdminListSubjectsOptions` plus - * the contract's `?email=`. The email arrives already trimmed and lower-cased - * by the contract schema, so a provider never re-normalizes it. */ + * the contract's `?email=` and `?search=`. Both filters arrive already + * trimmed and lower-cased by the handler seam (a blank search is omitted + * entirely), so a provider never re-normalizes them. `email` names ONE + * principal and wins when both are present. */ export interface AdminUsersListOptions { readonly limit?: number; readonly offset?: number; readonly email?: string; + readonly search?: string; } type User = typeof AdminUserResponse.Type; diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index b5974de98d..bba228e10f 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -40,6 +40,7 @@ export { normalizeEmail as normalizeAdminUserEmail, type AdminEmailResolver, type AdminIdentityDirectory, + type AdminMemberSearch, type AdminUserDirectory, type AdminUserIdentity, } from "./admin/reads"; diff --git a/packages/core/api/src/server/member-directory.ts b/packages/core/api/src/server/member-directory.ts index 50bef4a342..a80e1adf68 100644 --- a/packages/core/api/src/server/member-directory.ts +++ b/packages/core/api/src/server/member-directory.ts @@ -80,6 +80,26 @@ export interface MemberDirectoryShape { organizationId: string, statuses?: readonly MemberStatus[], ) => Effect.Effect; + /** + * One membership by its host membership ROW id, any status; `null` when + * THIS org holds no such row. The ownership gate for host-specific writes + * (remove, change role): an id leaked from another org resolves to `null` + * here, so a point read answers "is this ours" without listing the org. + */ + readonly membershipById: ( + organizationId: string, + membershipId: string, + ) => Effect.Effect; + /** + * Every organization membership one account holds, across organizations — + * the org switcher's list and the per-user organization limit. `statuses` + * defaults to active + pending; ordered by `organizationId` so the answer + * is stable. One read for the whole set, never a lookup per org. + */ + readonly membershipsOf: ( + accountId: string, + statuses?: readonly MemberStatus[], + ) => Effect.Effect; /** The org's members matching `query` (see {@link MemberQuery} for defaults). */ readonly members: ( organizationId: string, diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index b01c62dbc7..cd89ab9075 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -593,6 +593,14 @@ export interface AdminSubjectWithConnections extends AdminSubject { export interface AdminListSubjectsOptions { readonly limit?: number; readonly offset?: number; + /** + * Keep only subjects whose `external_id` is in this set — the host's answer + * to a directory search (name or email), paged through storage rather than + * in memory. An EMPTY set matches nothing; `undefined` is no filter. Paging + * applies to the filtered set: "filter, then page", the same order the + * `?email=` read follows. + */ + readonly externalIds?: readonly string[]; } /** @@ -7006,8 +7014,18 @@ export const createExecutor = b("external_id", "in", [...externalIds]) }), // Oldest first, ties broken on the unique key so the order is // total and paging can't repeat or skip a row. orderBy: [ diff --git a/packages/core/sdk/src/platform-view.test.ts b/packages/core/sdk/src/platform-view.test.ts index 12cee13c8b..9c5ef821f3 100644 --- a/packages/core/sdk/src/platform-view.test.ts +++ b/packages/core/sdk/src/platform-view.test.ts @@ -424,6 +424,61 @@ const expectWriteRefused = ( Effect.orDie, ); +describe("platform view — admin.listSubjects externalIds filter", () => { + it.effect("keeps only the named ids, still ordered and paged through storage", () => + withDb((db) => + Effect.gen(function* () { + yield* seed(db); + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); + + const only = yield* admin.listSubjects({ externalIds: [SUBJECT_B] }); + expect(only.map((entry) => entry.externalId)).toEqual([SUBJECT_B]); + + // Ids the tenant does not hold are simply absent — including another + // tenant's subject, which the policy keeps out regardless of the filter. + const mixed = yield* admin.listSubjects({ + externalIds: [SUBJECT_B, "user_nobody", "user_elsewhere", SUBJECT_A], + }); + expect(mixed.map((entry) => entry.externalId).sort()).toEqual([SUBJECT_A, SUBJECT_B]); + + // "Filter, then page": the window applies to the filtered set. + const all = yield* admin.listSubjects(); + const second = yield* admin.listSubjects({ + externalIds: [SUBJECT_A, SUBJECT_B], + limit: 1, + offset: 1, + }); + expect(second.map((entry) => entry.externalId)).toEqual([all[1]?.externalId]); + }), + ), + ); + + it.effect("an empty id set matches nothing, on both list reads", () => + withDb((db) => + Effect.gen(function* () { + yield* seed(db); + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); + + expect(yield* admin.listSubjects({ externalIds: [] })).toEqual([]); + expect(yield* admin.listSubjectsWithConnections({ externalIds: [] })).toEqual([]); + }), + ), + ); + + it.effect("the joined read filters the same way and still joins connections", () => + withDb((db) => + Effect.gen(function* () { + yield* seed(db); + const admin = yield* requireAdmin(yield* makePlatformExecutor(db)); + + const rows = yield* admin.listSubjectsWithConnections({ externalIds: [SUBJECT_A] }); + expect(rows.map((entry) => entry.externalId)).toEqual([SUBJECT_A]); + expect(rows[0]?.connections.length).toBeGreaterThan(0); + }), + ), + ); +}); + describe("platform view — read-only across every surface", () => { it.effect("refuses org-row writes through policies and oauth", () => withDb((db) => diff --git a/packages/react/src/api/admin-atoms.tsx b/packages/react/src/api/admin-atoms.tsx index e72bac7812..3f97740edf 100644 --- a/packages/react/src/api/admin-atoms.tsx +++ b/packages/react/src/api/admin-atoms.tsx @@ -10,10 +10,11 @@ import { ReactivityKey } from "./reactivity-keys"; // rejects writes at tenant reach), so there are no mutations here and every // atom carries the same reactivity key. // -// Paging is part of the atom identity, so each page is its own cache entry and -// stepping back to a visited page is instant. `Atom.family` (not a bare arrow) -// because the page component re-derives the key object on every render — a -// fresh atom per render would refetch in a loop. +// Paging and the search term are part of the atom identity, so each page of +// each search is its own cache entry and stepping back to a visited page is +// instant. `Atom.family` (not a bare arrow) because the page component +// re-derives the key object on every render — a fresh atom per render would +// refetch in a loop. // --------------------------------------------------------------------------- /** How many users one page of the list shows. Well inside the contract's @@ -24,6 +25,10 @@ export const ADMIN_USERS_PAGE_SIZE = 25; export interface AdminUsersPage { readonly limit: number; readonly offset: number; + /** The `?search=` term (name or email substring), already debounced by the + * page. `""` is no filter and is sent as no param at all, so the unfiltered + * list keeps one cache identity regardless of how the term was cleared. */ + readonly search: string; } /** @@ -35,7 +40,11 @@ export interface AdminUsersPage { */ export const adminUsersWithConnectionsAtom = Atom.family((page: AdminUsersPage) => AdminApiClient.query("adminUsers", "listUsersWithConnections", { - query: { limit: page.limit + 1, offset: page.offset }, + query: { + limit: page.limit + 1, + offset: page.offset, + ...(page.search === "" ? {} : { search: page.search }), + }, timeToLive: "30 seconds", reactivityKeys: [ReactivityKey.adminUsers], }), diff --git a/packages/react/src/pages/admin-users.tsx b/packages/react/src/pages/admin-users.tsx index 49fae31d68..d081606325 100644 --- a/packages/react/src/pages/admin-users.tsx +++ b/packages/react/src/pages/admin-users.tsx @@ -1,6 +1,7 @@ -import { useState } from "react"; +import { useEffect, useState } from "react"; import { useAtomRefresh, useAtomValue } from "@effect/atom-react"; import { useParams } from "@tanstack/react-router"; +import { SearchIcon, XIcon } from "lucide-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import * as Cause from "effect/Cause"; import * as Option from "effect/Option"; @@ -18,6 +19,7 @@ import { ownerLabel } from "../api/owner-display"; import { Button } from "../components/button"; import { CopyButton } from "../components/copy-button"; import { ErrorState } from "../components/error-state"; +import { Input } from "../components/input"; import { IntegrationFavicon, integrationInferredUrl, @@ -521,14 +523,94 @@ function UserDetail(props: { }); } +// ── Search ────────────────────────────────────────────────────────────────── + +/** How long the typed term settles before it becomes a request. Long enough + * that a typed name is one query rather than one per keystroke, short enough + * to read as immediate. */ +const SEARCH_DEBOUNCE_MS = 250; + +/** + * The search box: what is typed, and the settled term the list actually asks + * for. Two values because the request is debounced, and the input must keep + * echoing keystrokes while the term catches up. Clearing bypasses the debounce + * — an emptied box should show everyone at once, not after a pause. + */ +const useDebouncedSearch = (): { + readonly typed: string; + readonly term: string; + readonly setTyped: (value: string) => void; + readonly clear: () => void; +} => { + const [typed, setTypedState] = useState(""); + const [term, setTerm] = useState(""); + + useEffect(() => { + if (typed === term) return; + const handle = setTimeout(() => setTerm(typed), SEARCH_DEBOUNCE_MS); + return () => clearTimeout(handle); + }, [typed, term]); + + return { + typed, + term, + setTyped: setTypedState, + clear: () => { + setTypedState(""); + setTerm(""); + }, + }; +}; + +function UserSearch(props: { + readonly value: string; + readonly onChange: (value: string) => void; + readonly onClear: () => void; +}) { + return ( +
+ + props.onChange((event.target as HTMLInputElement).value)} + onKeyDown={(event) => { + if (event.key === "Escape" && props.value !== "") props.onClear(); + }} + placeholder="Search by name or email" + aria-label="Search users by name or email" + className="h-9 pl-9 pr-9 text-sm [&::-webkit-search-cancel-button]:hidden" + /> + {props.value !== "" && ( + + )} +
+ ); +} + // ── Page ──────────────────────────────────────────────────────────────────── export function AdminUsersPage() { useExecutorDocumentTitle("Users"); const [offset, setOffset] = useState(0); const [selected, setSelected] = useState(null); + const search = useDebouncedSearch(); - const page = { limit: ADMIN_USERS_PAGE_SIZE, offset }; + // A new term is a new list, so it starts on its first page: an offset kept + // from a broader list would land past the end of a narrower one. + const page = { limit: ADMIN_USERS_PAGE_SIZE, offset, search: search.term }; const result = useAtomValue(adminUsersWithConnectionsAtom(page)); const refresh = useAtomRefresh(adminUsersWithConnectionsAtom(page)); const catalog = useCatalogRows(); @@ -555,10 +637,24 @@ export function AdminUsersPage() { ); + const searching = search.term !== ""; + return ( {header} + { + search.setTyped(value); + setOffset(0); + }} + onClear={() => { + search.clear(); + setOffset(0); + }} + /> + {isAsyncResultLoading(result) ? loading : AsyncResult.match(result, { @@ -572,6 +668,25 @@ export function AdminUsersPage() { onSuccess: ({ value }) => { const { rows, hasNext } = splitPage(value.users, ADMIN_USERS_PAGE_SIZE); + if (rows.length === 0 && searching && offset === 0) { + return ( +
+

No users match

+

+ Nobody in this workspace has a name or email containing “ + {search.term}”. Only people who have reached the workspace or connected + an account are listed. +

+ +
+ ); + } + if (rows.length === 0) { return (
diff --git a/packages/react/src/pages/org.tsx b/packages/react/src/pages/org.tsx index 69e7a5aad0..b23ec30cf0 100644 --- a/packages/react/src/pages/org.tsx +++ b/packages/react/src/pages/org.tsx @@ -69,7 +69,7 @@ import { isAsyncResultLoading } from "../lib/async-result"; type MemberData = { id: string; - email: string; + email: string | null; name: string | null; avatarUrl: string | null; role: string; @@ -80,6 +80,23 @@ type MemberData = { type RoleData = { slug: string; name: string }; +/** What a member row is called: name, else email, else the one thing every + * member has — a membership id — so a profile the host has not learned yet + * still renders as a row an admin can act on. */ +const memberLabel = (member: MemberData): string => member.name ?? member.email ?? member.id; + +const memberInitials = (member: MemberData): string => { + if (member.name) { + return member.name + .split(" ") + .map((n: string) => n[0]) + .join("") + .slice(0, 2) + .toUpperCase(); + } + return (member.email?.[0] ?? "?").toUpperCase(); +}; + type InviteState = { email: string; roleSlug: string; @@ -314,7 +331,7 @@ export function OrgPage(props: { const filtered = search ? members.filter( (m: MemberData) => - m.email.toLowerCase().includes(search.toLowerCase()) || + (m.email?.toLowerCase().includes(search.toLowerCase()) ?? false) || (m.name?.toLowerCase().includes(search.toLowerCase()) ?? false), ) : members; @@ -338,21 +355,14 @@ export function OrgPage(props: { ) : (
- {member.name - ? member.name - .split(" ") - .map((n: string) => n[0]) - .join("") - .slice(0, 2) - .toUpperCase() - : member.email[0]!.toUpperCase()} + {memberInitials(member)}
)}

- {member.name ?? member.email} + {memberLabel(member)}

{member.isCurrentUser && ( You @@ -361,7 +371,7 @@ export function OrgPage(props: { Invited )}
- {member.name && ( + {member.name && member.email && (

{member.email}

@@ -421,7 +431,7 @@ export function OrgPage(props: { onClick={() => setRemovingMember({ id: member.id, - name: member.name ?? member.email, + name: memberLabel(member), }) } >