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/.changeset/member-directory-reconciler.md b/.changeset/member-directory-reconciler.md new file mode 100644 index 0000000000..b73bbf8786 --- /dev/null +++ b/.changeset/member-directory-reconciler.md @@ -0,0 +1,7 @@ +--- +"@executor-js/cloud": patch +--- + +The cloud membership mirror is now reconciled from the WorkOS Events API: an every-minute cron replays user, organization-membership, and organization events from a persisted cursor, so changes made in the WorkOS dashboard (a removed member, a role edit, a profile update) reach the mirror without anyone signing in. A signed webhook at `/api/webhooks/workos` pokes the same reconciler so those changes land in seconds, and `bun run --cwd apps/cloud db:drain-workos-events:prod` runs the same replay out-of-band until the stream is drained. + +**Ops steps (cloud):** set the webhook signing secret with `wrangler secret put WORKOS_WEBHOOK_SECRET`, then register `https://executor.sh/api/webhooks/workos` as a webhook endpoint in the WorkOS dashboard for the `user.*`, `organization_membership.*`, `organization.updated`, and `organization.deleted` events. Until the secret is set the route answers 503 and the cron alone keeps the mirror current. diff --git a/.changeset/mirror-readiness-removal.md b/.changeset/mirror-readiness-removal.md new file mode 100644 index 0000000000..e6ad82e6a5 --- /dev/null +++ b/.changeset/mirror-readiness-removal.md @@ -0,0 +1,5 @@ +--- +"@executor-js/cloud": patch +--- + +`authorizeOrganization` now reads the local membership mirror unconditionally: the per-request readiness check (`MirrorReadiness`) and its live WorkOS `listUserMemberships` fallback are gone from the request path entirely. The backfill is complete and permanent, and an organization that predates the mirror is still covered by the existing on-demand scan (`ensureOrganizationBackfilled`). A stalled reconciler is now an operational alert instead of a per-request fallback: after each run, the cron checks the mirror's `drained_at` heartbeat and, if it has fallen behind the lag budget, logs a structured error and reports it to Sentry. The deploy gate (`scripts/ensure-workos-mirror-ready.ts`) is unchanged — it still refuses to ship while the mirror is unready — and `drained_at` keeps being written by every reconciler run. diff --git a/.changeset/oauth-background-resource-lifetime.md b/.changeset/oauth-background-resource-lifetime.md new file mode 100644 index 0000000000..2680db0ed6 --- /dev/null +++ b/.changeset/oauth-background-resource-lifetime.md @@ -0,0 +1,5 @@ +--- +"@executor-js/api": patch +--- + +Keep request resources alive until background OAuth tool discovery finishes, so slow cloud connections can publish their tools after the callback returns. diff --git a/.changeset/restart-hosted-invitation-login.md b/.changeset/restart-hosted-invitation-login.md new file mode 100644 index 0000000000..c76b90fa2b --- /dev/null +++ b/.changeset/restart-hosted-invitation-login.md @@ -0,0 +1,5 @@ +--- +"@executor-js/cloud": patch +--- + +Restart hosted invitation logins that return without state, while keeping authorization codes bound to the browser that started the login. diff --git a/.claude/skills/prod-telemetry/SKILL.md b/.claude/skills/prod-telemetry/SKILL.md index 0128c9ecd0..cfd07d4527 100644 --- a/.claude/skills/prod-telemetry/SKILL.md +++ b/.claude/skills/prod-telemetry/SKILL.md @@ -57,6 +57,34 @@ join the same traces via traceparent). `execute`/`execute-action` calls `mcp.execute.code` (the script itself, capped at 10k chars — cloud-only content capture; local/self-host telemetry never records content). +- `auth.authorize_organization` — every membership authorization. Reads the + local membership mirror unconditionally; there is no per-request readiness + check and no WorkOS fallback, so this span carries no readiness attribute. + The mirror's write spans are `workos_mirror.`; the reconciler run is + `workos_events.sync`. `workos_sync.drained_at` in the prod DB is the + reconciler heartbeat, and a stalled reconciler now raises its own error + from the cron (see below) rather than showing up as a fallback here. + +**Recipe — reconciler heartbeat (ticks should land roughly every minute; a +gap wider than the 10-minute lag budget means the cron alert should already +have fired — see `workos_events: reconciler stale` below):** + +```apl +['executor-cloud'] +| where _time > ago(1h) and name == "workos_events.sync" +| summarize n = count() by bin(_time, 1m) +| sort by _time desc +``` + +**Recipe — stale-reconciler alerts (should be empty; each row is one paging +event):** + +```apl +['executor-cloud'] +| where _time > ago(1d) and ['status.message'] contains "workos_events: reconciler stale" +| project _time, trace_id, msg = tostring(['status.message']) +| sort by _time desc +``` **Recipe — error signatures by class (the daily-digest query):** 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/drizzle/0018_member_directory_mirror.sql b/apps/cloud/drizzle/0018_member_directory_mirror.sql new file mode 100644 index 0000000000..21a08b7147 --- /dev/null +++ b/apps/cloud/drizzle/0018_member_directory_mirror.sql @@ -0,0 +1,30 @@ +CREATE TABLE "membership_tombstones" ( + "membership_id" text PRIMARY KEY NOT NULL, + "account_id" text NOT NULL, + "organization_id" text NOT NULL, + "deleted_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +CREATE TABLE "workos_sync" ( + "id" text PRIMARY KEY NOT NULL, + "cursor" text, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN "email" text;--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN "first_name" text;--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN "last_name" text;--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN "avatar_url" text;--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN "workos_updated_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN "last_sign_in_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "memberships" ADD COLUMN "membership_id" text;--> statement-breakpoint +ALTER TABLE "memberships" ADD COLUMN "role" text DEFAULT 'member' NOT NULL;--> statement-breakpoint +ALTER TABLE "memberships" ADD COLUMN "status" text DEFAULT 'active' NOT NULL;--> statement-breakpoint +ALTER TABLE "memberships" ADD COLUMN "workos_updated_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "memberships" ADD COLUMN "deleted_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "membership_tombstones" ADD CONSTRAINT "membership_tombstones_account_id_accounts_id_fk" FOREIGN KEY ("account_id") REFERENCES "public"."accounts"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "membership_tombstones" ADD CONSTRAINT "membership_tombstones_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "public"."organizations"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "membership_tombstones_organization_id_idx" ON "membership_tombstones" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "accounts_email_lower_idx" ON "accounts" USING btree (lower("email"));--> statement-breakpoint +CREATE UNIQUE INDEX "memberships_membership_id_unique" ON "memberships" USING btree ("membership_id");--> statement-breakpoint +CREATE INDEX "memberships_organization_id_idx" ON "memberships" USING btree ("organization_id"); \ No newline at end of file diff --git a/apps/cloud/drizzle/0019_workos_mirror_sync_state.sql b/apps/cloud/drizzle/0019_workos_mirror_sync_state.sql new file mode 100644 index 0000000000..419595746a --- /dev/null +++ b/apps/cloud/drizzle/0019_workos_mirror_sync_state.sql @@ -0,0 +1,21 @@ +-- Backfill completeness per organization (`organizations.backfilled_at`: when +-- its membership list was last fully scanned from WorkOS), the organization +-- tombstone (`organizations.deleted_at`: kept by the local purge so a delayed +-- login cannot re-mint a deleted organization), the organization name stamp +-- (`organizations.workos_updated_at`: a name write stamped earlier is refused, +-- so a delayed login cannot revert a rename), and on the "events" row of +-- `workos_sync` the Events API replay boundary (`range_start`) the +-- reconciler's first run reads from plus the backfill completion mark +-- (`backfill_completed_at`) the authorization path checks before it trusts +-- the mirror over WorkOS. A database with no organizations has nothing to +-- backfill, so seed both there (fresh dev, test, and e2e databases); a +-- database that already holds organizations gets them from the backfill +-- script (scripts/backfill-workos-mirror.ts). +ALTER TABLE "organizations" ADD COLUMN "backfilled_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "organizations" ADD COLUMN "deleted_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "organizations" ADD COLUMN "workos_updated_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "workos_sync" ADD COLUMN "range_start" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "workos_sync" ADD COLUMN "backfill_completed_at" timestamp with time zone;--> statement-breakpoint +INSERT INTO "workos_sync" ("id", "cursor", "range_start", "backfill_completed_at", "updated_at") +SELECT 'events', NULL, now(), now(), now() +WHERE NOT EXISTS (SELECT 1 FROM "organizations"); diff --git a/apps/cloud/drizzle/0020_workos_sync_drained_at.sql b/apps/cloud/drizzle/0020_workos_sync_drained_at.sql new file mode 100644 index 0000000000..f1e1b34c42 --- /dev/null +++ b/apps/cloud/drizzle/0020_workos_sync_drained_at.sql @@ -0,0 +1 @@ +ALTER TABLE "workos_sync" ADD COLUMN "drained_at" timestamp with time zone; \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/0018_snapshot.json b/apps/cloud/drizzle/meta/0018_snapshot.json new file mode 100644 index 0000000000..5f29623ce9 --- /dev/null +++ b/apps/cloud/drizzle/meta/0018_snapshot.json @@ -0,0 +1,1724 @@ +{ + "id": "fe5ddc71-31c5-4144-a879-11ea71a63735", + "prevId": "42251aa3-ae24-4010-ac65-9f41e26cdc20", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_email_lower_idx": { + "name": "accounts_email_lower_idx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_tombstones": { + "name": "membership_tombstones", + "schema": "", + "columns": { + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "membership_tombstones_organization_id_idx": { + "name": "membership_tombstones_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "membership_tombstones_account_id_accounts_id_fk": { + "name": "membership_tombstones_account_id_accounts_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "membership_tombstones_organization_id_organizations_id_fk": { + "name": "membership_tombstones_organization_id_organizations_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_membership_id_unique": { + "name": "memberships_membership_id_unique", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_organization_id_idx": { + "name": "memberships_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workos_sync": { + "name": "workos_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/0019_snapshot.json b/apps/cloud/drizzle/meta/0019_snapshot.json new file mode 100644 index 0000000000..eed3fb2107 --- /dev/null +++ b/apps/cloud/drizzle/meta/0019_snapshot.json @@ -0,0 +1,1754 @@ +{ + "id": "26e5a445-9146-40bb-afaf-0f26bfb00818", + "prevId": "fe5ddc71-31c5-4144-a879-11ea71a63735", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_email_lower_idx": { + "name": "accounts_email_lower_idx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_tombstones": { + "name": "membership_tombstones", + "schema": "", + "columns": { + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "membership_tombstones_organization_id_idx": { + "name": "membership_tombstones_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "membership_tombstones_account_id_accounts_id_fk": { + "name": "membership_tombstones_account_id_accounts_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "membership_tombstones_organization_id_organizations_id_fk": { + "name": "membership_tombstones_organization_id_organizations_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_membership_id_unique": { + "name": "memberships_membership_id_unique", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_organization_id_idx": { + "name": "memberships_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backfilled_at": { + "name": "backfilled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workos_sync": { + "name": "workos_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "range_start": { + "name": "range_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/0020_snapshot.json b/apps/cloud/drizzle/meta/0020_snapshot.json new file mode 100644 index 0000000000..886f0a7849 --- /dev/null +++ b/apps/cloud/drizzle/meta/0020_snapshot.json @@ -0,0 +1,1760 @@ +{ + "id": "88f2845b-be28-4ad3-92b2-2cac819478e5", + "prevId": "26e5a445-9146-40bb-afaf-0f26bfb00818", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.accounts": { + "name": "accounts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "first_name": { + "name": "first_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_name": { + "name": "last_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_sign_in_at": { + "name": "last_sign_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "accounts_email_lower_idx": { + "name": "accounts_email_lower_idx", + "columns": [ + { + "expression": "lower(\"email\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.membership_tombstones": { + "name": "membership_tombstones", + "schema": "", + "columns": { + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "membership_tombstones_organization_id_idx": { + "name": "membership_tombstones_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "membership_tombstones_account_id_accounts_id_fk": { + "name": "membership_tombstones_account_id_accounts_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "membership_tombstones_organization_id_organizations_id_fk": { + "name": "membership_tombstones_organization_id_organizations_id_fk", + "tableFrom": "membership_tombstones", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memberships": { + "name": "memberships", + "schema": "", + "columns": { + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "membership_id": { + "name": "membership_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "memberships_membership_id_unique": { + "name": "memberships_membership_id_unique", + "columns": [ + { + "expression": "membership_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memberships_organization_id_idx": { + "name": "memberships_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memberships_account_id_accounts_id_fk": { + "name": "memberships_account_id_accounts_id_fk", + "tableFrom": "memberships", + "tableTo": "accounts", + "columnsFrom": ["account_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "memberships_organization_id_organizations_id_fk": { + "name": "memberships_organization_id_organizations_id_fk", + "tableFrom": "memberships", + "tableTo": "organizations", + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "memberships_account_id_organization_id_pk": { + "name": "memberships_account_id_organization_id_pk", + "columns": ["account_id", "organization_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organizations": { + "name": "organizations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "backfilled_at": { + "name": "backfilled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "workos_updated_at": { + "name": "workos_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "organizations_slug_unique": { + "name": "organizations_slug_unique", + "columns": [ + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workos_sync": { + "name": "workos_sync", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "range_start": { + "name": "range_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "backfill_completed_at": { + "name": "backfill_completed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "drained_at": { + "name": "drained_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.artifact": { + "name": "artifact", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bindings": { + "name": "bindings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "preview": { + "name": "preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "artifact_uidx": { + "name": "artifact_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "blob_id_uidx": { + "name": "blob_id_uidx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "item_ids": { + "name": "item_ids", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_health": { + "name": "last_health", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "tools_synced_at": { + "name": "tools_synced_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_client": { + "name": "oauth_client", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_owner": { + "name": "oauth_client_owner", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_item_id": { + "name": "refresh_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "oauth_scope": { + "name": "oauth_scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_token_url": { + "name": "oauth_token_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "connection_uidx": { + "name": "connection_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_uidx": { + "name": "definition_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.integration": { + "name": "integration", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "health_check": { + "name": "health_check", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "config_revised_at": { + "name": "config_revised_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "can_remove": { + "name": "can_remove", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "can_refresh": { + "name": "can_refresh", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "integration_uidx": { + "name": "integration_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_client": { + "name": "oauth_client", + "schema": "", + "columns": { + "slug": { + "name": "slug", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "authorization_url": { + "name": "authorization_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_url": { + "name": "token_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "grant": { + "name": "grant", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_id": { + "name": "client_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_secret_item_id": { + "name": "client_secret_item_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_write": { + "name": "credential_write", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "token_endpoint_auth_method": { + "name": "token_endpoint_auth_method", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource": { + "name": "resource", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_kind": { + "name": "origin_kind", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_integration": { + "name": "origin_integration", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_issuer": { + "name": "origin_issuer", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "origin_redirect_uri": { + "name": "origin_redirect_uri", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_client_uidx": { + "name": "oauth_client_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "slug", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.oauth_session": { + "name": "oauth_session", + "schema": "", + "columns": { + "state": { + "name": "state", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "client_slug": { + "name": "client_slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "integration": { + "name": "integration", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "template": { + "name": "template", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "redirect_url": { + "name": "redirect_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "pkce_verifier": { + "name": "pkce_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "oauth_session_uidx": { + "name": "oauth_session_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.plugin_storage": { + "name": "plugin_storage", + "schema": "", + "columns": { + "plugin_id": { + "name": "plugin_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "collection": { + "name": "collection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "plugin_storage_uidx": { + "name": "plugin_storage_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "collection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.private_executor_cloud_settings": { + "name": "private_executor_cloud_settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "version": { + "name": "version", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true, + "default": "'1.0.0'" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subject": { + "name": "subject", + "schema": "", + "columns": { + "external_id": { + "name": "external_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "subject_uidx": { + "name": "subject_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "integration": { + "name": "integration", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "connection": { + "name": "connection", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "annotations": { + "name": "annotations", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_uidx": { + "name": "tool_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "integration", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "connection", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool_policy": { + "name": "tool_policy", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "pattern": { + "name": "pattern", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "varchar(255)", + "primaryKey": true, + "notNull": true + }, + "tenant": { + "name": "tenant", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "owner": { + "name": "owner", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "subject": { + "name": "subject", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "tool_policy_uidx": { + "name": "tool_policy_uidx", + "columns": [ + { + "expression": "tenant", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "owner", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "subject", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 375397ceca..73842e4d59 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -127,6 +127,27 @@ "when": 1788287088210, "tag": "0017_lush_thunderbolts", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1789570778982, + "tag": "0018_member_directory_mirror", + "breakpoints": true + }, + { + "idx": 19, + "version": "7", + "when": 1789571259533, + "tag": "0019_workos_mirror_sync_state", + "breakpoints": true + }, + { + "idx": 20, + "version": "7", + "when": 1789575639971, + "tag": "0020_workos_sync_drained_at", + "breakpoints": true } ] } diff --git a/apps/cloud/package.json b/apps/cloud/package.json index 4feae6f1ac..b7871ba869 100644 --- a/apps/cloud/package.json +++ b/apps/cloud/package.json @@ -32,6 +32,11 @@ "db:backfill-org-slugs:dev": "op run --env-file=.env.op -- bun run scripts/backfill-org-slugs.ts", "db:backfill-subjects:prod": "op run --env-file=.env.production -- bun run scripts/backfill-subjects.ts", "db:backfill-subjects:dev": "op run --env-file=.env.op -- bun run scripts/backfill-subjects.ts", + "db:backfill-workos-mirror:prod": "op run --env-file=.env.production -- bun run scripts/backfill-workos-mirror.ts", + "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/backfill-workos-mirror.ts b/apps/cloud/scripts/backfill-workos-mirror.ts new file mode 100644 index 0000000000..945de792b2 --- /dev/null +++ b/apps/cloud/scripts/backfill-workos-mirror.ts @@ -0,0 +1,102 @@ +// --------------------------------------------------------------------------- +// One-off data backfill: fill the membership mirror (`accounts` profile +// columns + `memberships` rows, migration 0018) from WorkOS for every +// organization the mirror already knows. +// +// bun run db:backfill-workos-mirror:prod # op run --env-file=.env.production +// bun run db:backfill-workos-mirror:dev # against the local PGlite dev db +// +// For each live row in `organizations`: list EVERY membership WorkOS holds +// for it (active, pending, and inactive — a listing that skipped inactive +// ones would have the scan tombstone them, see `auth/workos-mirror-backfill.ts`), +// fetch each member's user (concurrency 5), and apply the listing in one +// transaction through the same guarded store the request path +// uses (`auth/workos-mirror-store.ts`): upsert user + membership, tombstone +// any mirrored membership of that org WorkOS no longer lists, and mark the +// org backfilled (`organizations.backfilled_at`) — the per-org mark the seat +// gates check before trusting a count from the mirror; an org left unmarked +// is scanned on demand the first time its seats are counted. Idempotent — +// the upserts refuse anything older than the stored WorkOS `updatedAt`, so +// re-running is safe, never rewinds a fresher row, and repairs a stale one; +// and a listing older than one already applied (two runs overlapping) is +// refused whole, so it cannot resurrect a membership the later listing found +// gone. Pass --dry-run to read and count without writing. +// +// DEPLOY ORDER: run this against production BEFORE deploying the builds that +// reconcile from the Events API and read seat counts from the mirror, so no +// request pays for an on-demand scan. The FIRST run records the events +// replay boundary BEFORE it lists anything (the reconciler's first run reads +// from it; without one it waits); later runs — a retry included — keep it, +// since only the events stream covers the org renames and user deletions +// after that instant. A run that fails part-way keeps the marks of the orgs +// it finished and the boundary it recorded, and is safe to repeat. Verify +// the printed membership count against the WorkOS dashboard. +// --------------------------------------------------------------------------- + +import { asc, isNull } from "drizzle-orm"; +import { drizzle } from "drizzle-orm/postgres-js"; +import { Effect } from "effect"; +import postgres from "postgres"; +import { WorkOS } from "@workos-inc/node"; + +import { backfillWorkOsMirror } from "../src/auth/workos-mirror-backfill"; +import { makeWorkOsMirrorStore } from "../src/auth/workos-mirror-store"; +import { organizations } from "../src/db/schema"; + +const dryRun = process.argv.includes("--dry-run"); + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + console.error("DATABASE_URL is not set"); + process.exit(1); +} +const apiKey = process.env.WORKOS_API_KEY; +if (!apiKey) { + console.error("WORKOS_API_KEY 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 workos = new WorkOS(apiKey); + +// The script boundary: raw SDK / driver promises lifted once, here. +const fromPromise = (fn: () => Promise) => + Effect.tryPromise({ try: fn, catch: (cause) => cause }); + +await Effect.runPromise( + backfillWorkOsMirror( + { + listOrganizationIds: () => + fromPromise(async () => { + // Never a deleted organization: its row is a tombstone (its + // memberships are purged, WorkOS no longer has it) and the mirror + // refuses a scan of it anyway. + const rows = await db + .select({ id: organizations.id }) + .from(organizations) + .where(isNull(organizations.deletedAt)) + .orderBy(asc(organizations.createdAt)); + return rows.map((row) => row.id); + }), + listOrgMembers: (organizationId) => + fromPromise(async () => { + const page = await workos.userManagement.listOrganizationMemberships({ + organizationId, + statuses: ["active", "pending", "inactive"], + }); + return page.listMetadata.after ? page.autoPagination() : page.data; + }), + getUser: (userId) => fromPromise(() => workos.userManagement.getUser(userId)), + }, + makeWorkOsMirrorStore(db), + { dryRun, log: (line) => console.log(line) }, + ).pipe(Effect.ensuring(Effect.promise(() => sql.end({ timeout: 5 })))), +); diff --git a/apps/cloud/scripts/drain-workos-events.ts b/apps/cloud/scripts/drain-workos-events.ts new file mode 100644 index 0000000000..b5cfc6f3db --- /dev/null +++ b/apps/cloud/scripts/drain-workos-events.ts @@ -0,0 +1,128 @@ +// --------------------------------------------------------------------------- +// Out-of-band reconciler run: replay the WorkOS Events API into the +// membership mirror from the persisted cursor until the stream is drained, +// over a plain postgres.js connection under bun — the SAME replay the +// Worker's every-minute cron runs (`src/auth/workos-events-replay.ts`). +// +// bun run db:drain-workos-events:prod # op run --env-file=.env.production +// +// Exists for the deploy gate (`scripts/ensure-workos-mirror-ready.ts`): the +// build that authorizes from the mirror trusts it only once the reconciler +// has drained the stream recently, and the gate must be able to MAKE that +// true itself rather than wait for a cron that may not be deployed yet — +// otherwise the reconciler build could only ever ship ahead of the gated +// one, by hand. Safe to run beside a live cron: a page is applied under the +// cursor's compare-and-set, so whichever run loses the stream writes +// nothing and stops. Runs until the stream is drained or another run owns +// it; a page budget bounds one pass, so a long backlog takes several. Exits +// 0 on a drain, 1 otherwise, with the reason. +// --------------------------------------------------------------------------- + +import { drizzle } from "drizzle-orm/postgres-js"; +import { Effect, Option } from "effect"; +import postgres from "postgres"; +import { WorkOS } from "@workos-inc/node"; + +import { makeUserStore } from "../src/auth/user-store"; +import { replayWorkOsEvents, type WorkOsEventsSyncReport } from "../src/auth/workos-events-replay"; +import { makeWorkOsMirrorStore } from "../src/auth/workos-mirror-store"; + +const connectionString = process.env.DATABASE_URL; +if (!connectionString) { + console.error("DATABASE_URL is not set"); + process.exit(1); +} +const apiKey = process.env.WORKOS_API_KEY; +if (!apiKey) { + console.error("WORKOS_API_KEY 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 workos = new WorkOS(apiKey); +const users = makeUserStore(db); + +// The script boundary: raw SDK / driver promises lifted once, here. Only a +// 404 is the deterministic "gone" the replay acts on; every other failure +// fails the pass, as in the Worker (`src/auth/workos-events-sync.ts`). +const fromPromise = (fn: () => Promise) => + Effect.tryPromise({ try: fn, catch: (cause) => cause }); + +const isNotFound = (cause: unknown): boolean => + typeof cause === "object" && + cause !== null && + "status" in cause && + (cause as { readonly status: unknown }).status === 404; + +const noneWhenGone = (fn: () => Promise) => + Effect.tryPromise({ try: fn, catch: (cause) => cause }).pipe( + Effect.map(Option.some), + Effect.catch((cause) => + isNotFound(cause) ? Effect.succeed(Option.none()) : Effect.fail(cause), + ), + ); + +// One pass is bounded by the replay's page budget; loop until the stream +// is drained, another run owns it, or the backfill has not run. +const MAX_PASSES = 50; + +const drain = Effect.gen(function* () { + const deps = { + source: { + listEvents: (options: Parameters[0]) => + fromPromise(async () => { + const page = await workos.events.listEvents({ + ...options, + events: [...options.events], + }); + return { data: page.data, after: page.listMetadata.after ?? null }; + }), + getOrganization: (organizationId: string) => + noneWhenGone(() => workos.organizations.getOrganization(organizationId)), + getUser: (userId: string) => noneWhenGone(() => workos.userManagement.getUser(userId)), + }, + store: { + getOrganization: (organizationId: string) => + fromPromise(() => users.getOrganization(organizationId)), + upsertOrganization: (organization: Parameters[0]) => + fromPromise(() => users.upsertOrganization(organization)), + getAccount: (accountId: string) => fromPromise(() => users.getAccount(accountId)), + }, + mirror: makeWorkOsMirrorStore(db), + }; + let last: WorkOsEventsSyncReport | null = null; + for (let pass = 0; pass < MAX_PASSES; pass++) { + const report = yield* replayWorkOsEvents(deps); + console.log( + `[drain-events] pass ${pass + 1}: ${report.pages} page(s), ${report.events} event(s), ` + + `${report.applied} applied, ${report.stale} stale, ${report.absent} absent — ${report.stopped}`, + ); + last = report; + if (report.stopped !== "page_budget") break; + } + return last; +}); + +const report = await Effect.runPromise( + drain.pipe(Effect.ensuring(Effect.promise(() => sql.end({ timeout: 5 })))), +); + +if (report === null || report.stopped !== "drained") { + console.error( + `[drain-events] the events stream was not drained: ${report?.stopped ?? "no pass ran"}` + + (report?.stopped === "awaiting_backfill" + ? " (run scripts/backfill-workos-mirror.ts first)" + : report?.stopped === "cursor_contended" + ? " (another run owns the stream; rerun once it finishes)" + : ""), + ); + process.exit(1); +} 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/scripts/test-globalsetup.ts b/apps/cloud/scripts/test-globalsetup.ts index 7efc25afe0..1bdb1cd9a5 100644 --- a/apps/cloud/scripts/test-globalsetup.ts +++ b/apps/cloud/scripts/test-globalsetup.ts @@ -42,7 +42,11 @@ export default async function setup() { db = await PGlite.create(); await migrate(drizzle(db), { migrationsFolder: MIGRATIONS_FOLDER }); - server = new PGLiteSocketServer({ db, port: PORT, host: "127.0.0.1" }); + // PGlite is single-session; pglite-socket multiplexes connections onto it + // by queueing whole transactions. Two connections let a test open two + // transactions and interleave them (the mirror's scan-vs-feeder race in + // auth/workos-mirror.node.test.ts); a third would be refused at once. + server = new PGLiteSocketServer({ db, port: PORT, host: "127.0.0.1", maxConnections: 2 }); await server.start(); // eslint-disable-next-line no-console diff --git a/apps/cloud/src/account/account-api.ts b/apps/cloud/src/account/account-api.ts index a67d6f4f6d..d9aaf70d27 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 { WorkOsMirror } from "../auth/workos-mirror"; import { sessionFromSealed, type Session } from "../auth/middleware"; import { WorkOSClient } from "../auth/workos"; import { AutumnService } from "../extensions/billing/service"; @@ -45,9 +47,12 @@ 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 }>()( +const AccountProviderMiddleware = HttpRouter.middleware<{ + provides: AccountProvider; +}>()( Effect.gen(function* () { // Long-lived services only (built once at boot). `UserStoreService` and // `DbService` are NOT grabbed here — they come per request from the combined @@ -95,10 +100,13 @@ const AccountProviderMiddleware = HttpRouter.middleware<{ provides: AccountProvi * account service closes over the per-request postgres socket). `AutumnService` * (the seat-gate) stays a residual requirement, satisfied by the app `boot`. */ -export const workosAccountMiddleware = (rsLive: Layer.Layer) => - AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer; +export const workosAccountMiddleware = ( + rsLive: Layer.Layer, +) => AccountProviderMiddleware.combine(requestScopedMiddleware(rsLive)).layer; -export const makeAccountApiLive = (rsLive: Layer.Layer) => { +export const makeAccountApiLive = ( + rsLive: Layer.Layer, +) => { // 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- // contained `Layer` — it combines its own middleware with 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 5b96f01f74..48db9c2df3 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,13 +1,14 @@ 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 { ORG_SELECTOR_HEADER } from "../auth/organization"; import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; +import { WorkOsMirror } from "../auth/workos-mirror"; import { AutumnService } from "../extensions/billing/service"; import { AccountCaller, workosAccountProvider } from "./workos-account-service"; @@ -37,6 +38,19 @@ const MEMBER = "user_member"; const ORG_KEY = "key_org_1"; const USER_KEY = "key_user_1"; const createdAt = new Date("2026-01-01T00:00:00.000Z"); + +// 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, +}); const orgHeaders = { [ORG_SELECTOR_HEADER]: ORG }; const session = (accountId: string) => ({ @@ -49,32 +63,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`), }), ); @@ -82,30 +76,85 @@ const stubUsers = Layer.succeed(UserStoreService)({ use: (_op, fn) => Effect.promise(() => fn({ - ensureAccount: async (id: string) => ({ id, createdAt }), - getAccount: async (id: string) => ({ id, createdAt }), + 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 () => {}, }), ), }); +// Revoke changes no membership, so the mirror is never written. +const stubMirror = Layer.succeed(WorkOsMirror)({ + upsertUser: () => Effect.die("revoke does not write the membership mirror"), + upsertMembership: () => Effect.die("revoke does not write the membership mirror"), + deleteMembership: () => Effect.die("revoke does not write the membership mirror"), + deleteUser: () => Effect.die("revoke does not write the membership mirror"), + getCursor: () => Effect.die("revoke does not read the events cursor"), + applyPage: () => Effect.die("revoke does not move the events cursor"), + applyOrganizationScan: () => Effect.die("revoke does not run the backfill"), + replayBoundary: () => Effect.die("revoke does not run the reconciler"), + setReplayBoundary: () => Effect.die("revoke does not run the backfill"), + backfillCompletedAt: () => Effect.die("revoke does not check mirror readiness"), + markBackfillCompleted: () => Effect.die("revoke does not run the backfill"), + drainedAt: () => Effect.die("revoke does not check mirror readiness"), + markDrained: () => Effect.die("revoke does not run the reconciler"), + 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. +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"), @@ -143,6 +192,8 @@ const providerWith = (accountId: string) => { Layer.mergeAll( stubWorkOS, stubUsers, + stubMirror, + stubDirectory, 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 dc8b234b1f..94f5aed511 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, @@ -12,6 +12,8 @@ import { ApiKeyService } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; import type { Session } from "../auth/middleware"; 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"; import { forkReportMemberSeats } from "../extensions/billing/member-seats"; @@ -50,7 +52,7 @@ export class AccountCaller extends Context.Service< // (me / API keys) and `org/handlers.ts` (members / roles / invite / role / // name). Native WorkOS / store failures are mapped at this boundary onto the // neutral account errors so the shared UI sees one shape: -// WorkOSError | UserStoreError | ApiKeyManagementError → AccountError +// WorkOSError | UserStoreError | ApiKeyManagementError | WorkOsMirrorError → AccountError // no organization in session → AccountNoOrganization // not-an-admin / over-seat-limit / not-allowed → AccountForbidden // --------------------------------------------------------------------------- @@ -65,13 +67,27 @@ const toAccountError = () => Effect.fail(new AccountError({ message: "Account re export const workosAccountProvider: Layer.Layer< AccountProvider, never, - WorkOSClient | UserStoreService | ApiKeyService | AutumnService | AccountCaller + | WorkOSClient + | UserStoreService + | WorkOsMirror + | MemberDirectory + | ApiKeyService + | AutumnService + | AccountCaller > = Layer.effect(AccountProvider)( Effect.gen(function* () { const workos = yield* WorkOSClient; const apiKeys = yield* ApiKeyService; const autumn = yield* AutumnService; const users = yield* UserStoreService; + // Membership writes below go to WorkOS FIRST (the authority), then are + // 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` @@ -80,10 +96,13 @@ 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 | WorkOsMirror + >(); // Unauthenticated (missing/invalid session) => AccountUnauthorized, exactly // as the old inline `requireSession` did. @@ -97,10 +116,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(); @@ -117,29 +137,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) => @@ -148,15 +180,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, }; @@ -215,7 +248,10 @@ export const workosAccountProvider: Layer.Layer< Effect.gen(function* () { const { session, org } = yield* requireOrganization(headers); const keys = yield* apiKeys - .listUserKeys({ accountId: session.accountId, organizationId: org.id }) + .listUserKeys({ + accountId: session.accountId, + organizationId: org.id, + }) .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); return { apiKeys: keys }; }), @@ -225,10 +261,16 @@ export const workosAccountProvider: Layer.Layer< const { session, org } = yield* requireOrganization(headers); const trimmed = name.trim().slice(0, MAX_API_KEY_NAME_LENGTH); if (!trimmed) { - return yield* new AccountError({ message: "API key name is required" }); + return yield* new AccountError({ + message: "API key name is required", + }); } return yield* apiKeys - .createUserKey({ accountId: session.accountId, organizationId: org.id, name: trimmed }) + .createUserKey({ + accountId: session.accountId, + organizationId: org.id, + name: trimmed, + }) .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); }), @@ -236,7 +278,10 @@ export const workosAccountProvider: Layer.Layer< Effect.gen(function* () { const { session, org } = yield* requireOrganization(headers); const ownedKeys = yield* apiKeys - .listUserKeys({ accountId: session.accountId, organizationId: org.id }) + .listUserKeys({ + accountId: session.accountId, + organizationId: org.id, + }) .pipe(Effect.catchTag("ApiKeyManagementError", toAccountError)); if (!ownedKeys.some((key) => key.id === apiKeyId)) { return yield* new AccountError({ message: "API key not found" }); @@ -252,8 +297,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)); @@ -262,11 +307,13 @@ 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({ message: "API key name is required" }); + return yield* new AccountError({ + message: "API key name is required", + }); } return yield* apiKeys .createOrgKey({ organizationId: org.id, name: trimmed }) @@ -282,12 +329,16 @@ 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", () => - Effect.fail(new AccountError({ message: "Organization API key not found" })), + Effect.fail( + new AccountError({ + message: "Organization API key not found", + }), + ), ), ); return { success: true }; @@ -304,29 +355,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 }; }), @@ -344,8 +389,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({ @@ -354,42 +399,85 @@ 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); - yield* assertMembershipInOrg(org.id, membershipId); + const { org } = yield* requireOrganization(headers); + yield* requireAdmin(org); + const membership = yield* assertMembershipInOrg(org.id, membershipId); yield* workos .deleteOrgMembership(membershipId) .pipe(Effect.catchTag("WorkOSError", toAccountError)); + // Tombstoned by identity: the deleted WorkOS id never returns, so + // a login or backfill that fetched this membership before the + // 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 (`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.accountId, + organizationId: membership.organizationId, + }, + null, + ) + .pipe(Effect.catchTag("WorkOsMirrorError", toAccountError)); yield* forkReportMemberSeats(org.id).pipe(Effect.provideContext(ctx)); return { success: true }; }), 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); - yield* workos + const updated = yield* workos .updateOrgMembershipRole(membershipId, roleSlug) .pipe(Effect.catchTag("WorkOSError", toAccountError)); + yield* mirror + .upsertMembership(mirrorMembershipFromWorkOs(updated)) + .pipe(Effect.catchTag("WorkOsMirrorError", toAccountError)); return { success: true }; }), 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)); yield* users .use("upsertOrganization", (s) => - s.upsertOrganization({ id: updated.id, name: updated.name }), + s.upsertOrganization({ + id: updated.id, + name: updated.name, + updatedAt: new Date(updated.updatedAt), + }), ) .pipe(Effect.catchTag("UserStoreError", toAccountError)); return { name: updated.name }; 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..68f796bea3 --- /dev/null +++ b/apps/cloud/src/admin/admin-users-api.node.test.ts @@ -0,0 +1,192 @@ +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 { 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" })], +]); + +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), + ), + ); + +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..0c77c03a93 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,7 @@ import type { Executor } from "@executor-js/sdk"; import { ApiKeyService } from "../auth/api-keys"; import { UserStoreService } from "../auth/context"; +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 +69,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 | WorkOsMirror > => Effect.gen(function* () { // (1) The bearer path. `resolveBearerAuth` (not `resolveApiKeyPrincipal`, @@ -96,7 +95,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 +105,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 +133,14 @@ const withPlatformView = => Effect.gen(function* () { const organizationId = yield* authorizeTenant( @@ -264,8 +149,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 +161,48 @@ const withPlatformView = = Layer.effect(AdminUsersProvider)( Effect.gen(function* () { const context = yield* Effect.context< - WorkOSClient | ApiKeyService | UserStoreService | DbProvider | PluginsProvider | HostConfig + | WorkOSClient + | ApiKeyService + | UserStoreService + | MemberDirectory + | 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 +210,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 +224,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,9 +234,12 @@ 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`. -const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUsersProvider }>()( +// 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(); return (httpEffect) => @@ -348,7 +263,7 @@ const AdminUsersProviderMiddleware = HttpRouter.middleware<{ provides: AdminUser * `/api` prefix as the rest of the cloud router. */ export const makeCloudAdminUsersRoutes = ( - rsLive: Layer.Layer, + rsLive: Layer.Layer, 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 6e73261264..c64452e6dd 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 { WorkOsMirror } from "../auth/workos-mirror"; import { CloudAuthPublicHandlers, CloudSessionAuthHandlers, @@ -25,12 +31,18 @@ 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)); // 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); +export const RequestScopedServicesLive: Layer.Layer< + DbService | UserStoreService | WorkOsMirror | MemberDirectory +> = Layer.mergeAll(DbLive, UserStoreLive, WorkOsMirrorLive, MemberDirectoryLive); // Boot-scoped layer. Built once at worker boot, reused across requests. // Safe for config, in-memory caches, the global tracer provider, and @@ -54,7 +66,9 @@ export const BootSharedServices = Layer.mergeAll( // `AutumnService.Default` is provided HERE because the `createOrganization` // 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) => +export const makeNonProtectedApiLive = ( + rsLive: Layer.Layer, +) => HttpApiBuilder.layer(NonProtectedApi).pipe( Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), Layer.provide(requestScopedMiddleware(rsLive).layer), @@ -68,7 +82,9 @@ export const makeNonProtectedApiLive = (rsLive: Layer.Layer) => +export const makeOrgApiLive = ( + rsLive: Layer.Layer, +) => HttpApiBuilder.layer(OrgHttpApi).pipe( Layer.provide(OrgHandlers), Layer.provide(orgAuthMiddleware(rsLive)), @@ -113,7 +129,9 @@ export const OrgApiLive = makeOrgApiLive(RequestScopedServicesLive); // folded into `.layer` here; the rest of the router (`makeApiLive` in // `./router.ts`, `./protected.ts`, the test harness) re-provides the same // shared `RouterConfigLive` directly. -const protectedApi = makeProtectedApiLayer(cloudPlugins, { errorCapture: ErrorCaptureLive }); +const protectedApi = makeProtectedApiLayer(cloudPlugins, { + errorCapture: ErrorCaptureLive, +}); export const ProtectedCloudApi = protectedApi.api; export const ProtectedCloudApiHandlers = protectedApi.handlers; 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 d92ffe723e..aac518b0f6 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,13 +1,29 @@ 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 { 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"); +// 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, +}); + const stubApiKeys = Layer.succeed(ApiKeyService)({ validate: (value: string) => Effect.succeed( @@ -32,51 +48,89 @@ 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 always read from the mirror, never +// from WorkOS. +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(() => fn({ - ensureAccount: async (id: string) => ({ id, createdAt }), - getAccount: async (id: string) => ({ id, createdAt }), + 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: null, + workosUpdatedAt: null, createdAt, }), getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: `org-slug-${id}`, + backfilledAt: createdAt, + deletedAt: null, + workosUpdatedAt: null, createdAt, }), getOrganizationBySlug: async (slug: string) => ({ id: "org_by_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`), + }), +); + const run = (request: Request) => resolveProtectedPrincipal(request).pipe( - Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers)), + Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers, stubDirectory, stubMirror)), ); 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 b330ecdd70..edf2a0796c 100644 --- a/apps/cloud/src/api/protected-jwt-auth.node.test.ts +++ b/apps/cloud/src/api/protected-jwt-auth.node.test.ts @@ -2,13 +2,29 @@ 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 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"); + +// 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, +}); const issuer = "https://test-authkit.example.com"; const audience = "client_test_audience"; @@ -49,51 +65,89 @@ 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 always read from the mirror, never +// from WorkOS. +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(() => fn({ - ensureAccount: async (id: string) => ({ id, createdAt }), - getAccount: async (id: string) => ({ id, createdAt }), + 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: null, + workosUpdatedAt: null, createdAt, }), getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: `org-slug-${id}`, + backfilledAt: createdAt, + deletedAt: null, + workosUpdatedAt: null, createdAt, }), getOrganizationBySlug: async (slug: string) => ({ id: "org_by_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`), + }), +); + 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)), ); const request = (token: string) => diff --git a/apps/cloud/src/api/protected.ts b/apps/cloud/src/api/protected.ts index 417525d823..ac97dd5850 100644 --- a/apps/cloud/src/api/protected.ts +++ b/apps/cloud/src/api/protected.ts @@ -10,11 +10,13 @@ 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 { 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 +34,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 +95,11 @@ 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, +) => { // 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 d74f9c0b25..8c80825ef2 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 { WorkOsMirror } from "../auth/workos-mirror"; import { DbService } from "../db/db"; import { makeAccountApiLive } from "../account/account-api"; @@ -29,7 +34,9 @@ import { makeProtectedApiLive } from "./protected"; // so tests can substitute a counting fake for `DbService.Live` and // assert per-request semantics — see // `apps/cloud/src/api.request-scope.node.test.ts`. -export const makeApiLive = (requestScopedLive: Layer.Layer) => { +export const makeApiLive = ( + requestScopedLive: Layer.Layer, +) => { 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 a96a1e21e7..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 } from "./errors"; -import { NoOrganization } from "@executor-js/api/server"; +import { UserStoreError, WorkOSError, WorkOsMirrorError } from "./errors"; +import { MemberDirectoryError, NoOrganization } from "@executor-js/api/server"; import { SessionAuth } from "./middleware"; const AuthUser = Schema.Struct({ @@ -166,13 +166,27 @@ 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", callback: "/api/auth/callback", } as const; -const AuthErrors = [UserStoreError, WorkOSError] 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); 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, @@ -214,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( @@ -228,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..7fcd3b0748 100644 --- a/apps/cloud/src/auth/doc-gate.ts +++ b/apps/cloud/src/auth/doc-gate.ts @@ -41,6 +41,8 @@ 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 { makeWorkOsMirrorLayer } from "./workos-mirror"; import { sealedSessionDisplayName } from "./middleware"; import { authorizeOrganizationSelector } from "./organization"; import { loginPath, safeReturnTo } from "./return-to"; @@ -164,18 +166,26 @@ 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(), + makeWorkOsMirrorLayer(), + ).pipe(Layer.provide(dbLive)), + ), ), ); return Exit.isSuccess(exit) ? exit.value : null; @@ -246,7 +256,9 @@ export const authGateMiddleware = createMiddleware({ type: "request" }).server( // the client AuthGate makes mid-session, made here before the document // exists so the app shell is never painted for an org-less session. if (!session.organizationId && !ONBOARDING_PATHS.has(pathname)) { - return redirect("/create-org", { refreshedSession: session.refreshedSession }); + return redirect("/create-org", { + refreshedSession: session.refreshedSession, + }); } // A BARE console path (no org slug in the URL) canonicalizes onto the org @@ -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/errors.ts b/apps/cloud/src/auth/errors.ts index debf0b0635..6c3c8e3ac2 100644 --- a/apps/cloud/src/auth/errors.ts +++ b/apps/cloud/src/auth/errors.ts @@ -38,6 +38,27 @@ export class UserStoreError extends Schema.TaggedErrorClass()( } } +/** + * The public failure of every cloud membership-mirror write (`WorkOsMirror`). + * Same two diagnosable fields as `UserStoreError` — which mirror call failed, + * and how — classified from the driver cause the same way. Declared here, + * beside `UserStoreError`, because the auth API (`auth/api.ts`, in the SPA + * bundle) names it on the wire for the login and org handlers that feed the + * mirror; the service itself lives in `workos-mirror.ts`. + */ +export class WorkOsMirrorError extends Schema.TaggedErrorClass()( + "WorkOsMirrorError", + { + operation: Schema.String, + reason: Schema.Literals(USER_STORE_FAILURE_REASONS), + }, + { httpApiStatus: 500 }, +) { + override get message(): string { + return `workos mirror ${this.operation} failed: ${this.reason}`; + } +} + /** Reasons a retry can plausibly clear: the query never reached a healthy * server. A `query` failure is deterministic and must not be retried. */ export const isTransientUserStoreReason = (reason: UserStoreFailureReason): boolean => diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 1eefa96df7..6453a0547f 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -1,6 +1,6 @@ import { HttpApi, HttpApiBuilder } from "effect/unstable/httpapi"; import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; -import { Duration, Effect, Predicate } from "effect"; +import { Clock, Duration, Effect, Predicate } from "effect"; import { isValidOrgSlug } from "@executor-js/api"; import { @@ -10,18 +10,20 @@ 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"; import { encodeLoginState, decodeLoginState } from "./login-state"; import { safeReturnTo } from "./return-to"; import { UserStoreService } from "./context"; +import { mirrorMembership, mirrorSignIn } from "./mirror-feeders"; import { env } from "cloudflare:workers"; import { WorkOSError } from "./errors"; import { WorkOSClient } from "./workos"; -import { AutumnService } from "../extensions/billing/service"; +import { AutumnService, autumnStatusOf } from "../extensions/billing/service"; import { forkReportMemberSeats } from "../extensions/billing/member-seats"; import { captureCauseEffect } from "../observability"; import { @@ -34,7 +36,9 @@ import { ORG_SELECTOR_HEADER, authorizeOrganization, authorizeOrganizationSelector, + markOrganizationDeleted, resolveOrganization, + type AuthorizeOrganizationOptions, } from "./organization"; import { mcpSessionStub } from "@executor-js/cloudflare/mcp/session-stub"; @@ -102,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); @@ -188,14 +195,19 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( Effect.gen(function* () { const workos = yield* WorkOSClient; const users = yield* UserStoreService; + // Hosted invitations can start at WorkOS without app-issued state. + // Discard that unbound code and start a fresh browser-bound login. + // Exchanging it here would allow login CSRF. + if (query.state === undefined) { + return deleteResponseCookie( + HttpServerResponse.redirect(AUTH_PATHS.login, { status: 302 }), + STATE_COOKIE, + ); + } + const cookieState = request.cookies[STATE_COOKIE] ?? null; - // CSRF is unconditional: every callback must carry a state that - // matches the cookie set on /login. There is no legitimate - // no-state entry path — omitting state previously allowed an - // attacker to complete their own OAuth round-trip and redirect a - // victim's browser through this callback, signing the victim into - // the attacker's account (login CSRF). - if (!cookieState || !timingSafeEqual(cookieState, query.state ?? "")) { + // Only exchange codes bound to the state cookie set on /login. + if (!cookieState || !timingSafeEqual(cookieState, query.state)) { return deleteResponseCookie( HttpServerResponse.text("Invalid login state", { status: 400 }), STATE_COOKIE, @@ -204,8 +216,16 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( const result = yield* workos.authenticateWithCode(query.code); - // Mirror the account locally - yield* users.use("ensureAccount", (s) => s.ensureAccount(result.user.id)); + // ONE membership list for the whole callback. It feeds the mirror + // (the user + every org they hold a membership in, all already in + // hand) and it is the membership check for every landing-org + // candidate below, so the callback makes no per-candidate WorkOS + // call. The user's account row is minted by the mirror's user + // upsert. The list's fetch instant, taken before the read, stamps + // the organization names it carries (see `mirrorSignIn`). + const fetchedAt = new Date(yield* Clock.currentTimeMillis); + const memberships = yield* workos.listUserMemberships(result.user.id); + yield* mirrorSignIn(result.user, memberships.data, fetchedAt); let sealedSession = result.sealedSession; @@ -215,34 +235,43 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( // any other untrusted path. const returnTo = safeReturnTo(decodeLoginState(query.state)?.returnTo) ?? "/"; const requestedOrgSelector = requestedOrgSelectorFromReturnTo(returnTo); - const requestedOrg = requestedOrgSelector - ? yield* authorizeOrganizationSelector(result.user.id, requestedOrgSelector).pipe( + + // An org SLUG (both candidate sources below are slug-validated, so + // an `org_…` id never reaches here) resolves to its id only when the + // list above holds an ACTIVE membership in it. Pending memberships + // are skipped because refreshing into one 400s and would bypass + // invite consent. A slug that fails to resolve (unknown, or a store + // hiccup) is not a candidate, the same as an org the user is not in. + const activeOrganizationIds = new Set( + memberships.data.filter((m) => m.status === "active").map((m) => m.organizationId), + ); + const activeOrganizationFor = (slug: string) => + users + .use("getOrganizationBySlug", (s) => s.getOrganizationBySlug(slug)) + .pipe( + Effect.map((org) => (org && activeOrganizationIds.has(org.id) ? org.id : null)), Effect.orElseSucceed(() => null), - ) - : null; + ); // Prefer the org in the URL that sent the user to login. If the URL // is bare, or not an org route, prefer the org this browser last // worked in (the last-org cookie — it outlives the session precisely // so a fresh login lands where the user left off), then WorkOS's // org, then the first active membership for org-less sessions. - // Pending memberships are skipped because refreshing into one 400s - // and would bypass invite consent. The cookie is membership-checked - // like any selector, so a stale one just falls through. - let targetOrganizationId = requestedOrg?.id ?? null; + // The cookie is membership-checked like any selector, so a stale + // one just falls through. + let targetOrganizationId = requestedOrgSelector + ? yield* activeOrganizationFor(requestedOrgSelector) + : null; if (!targetOrganizationId && !requestedOrgSelector) { const lastOrgSlug = request.cookies[LAST_ORG_COOKIE]; - const lastOrg = + targetOrganizationId = lastOrgSlug && isValidOrgSlug(lastOrgSlug) - ? yield* authorizeOrganizationSelector(result.user.id, lastOrgSlug).pipe( - Effect.orElseSucceed(() => null), - ) + ? yield* activeOrganizationFor(lastOrgSlug) : null; - targetOrganizationId = lastOrg?.id ?? null; } targetOrganizationId ??= result.organizationId ?? null; if (!targetOrganizationId && !requestedOrgSelector) { - const memberships = yield* workos.listUserMemberships(result.user.id); const existingActive = memberships.data.find((m) => m.status === "active"); targetOrganizationId = existingActive?.organizationId ?? null; } @@ -306,7 +335,9 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( ? yield* workos.logoutUrl(sealedSession, origin ? `${origin}/` : undefined) : null; - const response = HttpServerResponse.redirect(logoutUrl ?? "/", { status: 302 }); + const response = HttpServerResponse.redirect(logoutUrl ?? "/", { + status: 302, + }); // Drop only what this browser actually presented. Both cookies are // SameSite=Lax, so a cross-site form POST carries neither — it gets @@ -372,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), ), ), @@ -406,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( @@ -442,11 +482,18 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( } const org = yield* workos.createOrganization(name); - yield* workos.createMembership(org.id, session.accountId, "admin"); + const membership = yield* workos.createMembership(org.id, session.accountId, "admin"); // `upsertOrganization` mints the slug at insert — no separate heal step. const mirrored = yield* users.use("upsertOrganization", (s) => - s.upsertOrganization({ id: org.id, name: org.name }), + s.upsertOrganization({ + id: org.id, + name: org.name, + updatedAt: new Date(org.updatedAt), + }), ); + // Write-through: the creator's admin membership, from the create + // response, lands in the mirror before anything reads it. + yield* mirrorMembership(membership); // Provision the org's billing customer while we're the ones creating // the org. Without this the first billing call an org ever makes is a @@ -504,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(); } @@ -523,42 +573,97 @@ 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 identity mirror - // (cascades local memberships) in one transaction. 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. - yield* users - .use("deleteOrganizationCascade", (s) => s.deleteOrganizationCascade(organizationId)) + // 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. A 404 — "no such customer" — is a retry after + // this step landed (or an org that was never provisioned): + // nothing to cancel, and not a failure. Matched on the status, + // not Autumn's `customer_not_found` code, because the delete + // endpoint answers an unknown customer with a bare 404 (and the + // Autumn emulator serves no delete route at all). 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.catchIf( + (failure) => + Predicate.isTagged(failure, "AutumnCustomerNotFoundError") || + autumnStatusOf(failure) === 404, + () => + Effect.logInfo( + "deleteOrganization: Autumn has no customer for the org; nothing to cancel", + { organizationId }, + ), + ), Effect.tapError((error) => Effect.logError( - "deleteOrganization: WorkOS org deleted but local purge failed, tenant data and secrets orphaned", + "deleteOrganization: org marked deleted but the Autumn customer could not be deleted; retry the deletion", { organizationId, error }, ), ), + Effect.mapError(() => new OrganizationDeletionIncomplete({ step: "billing" })), ); - // 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 })) + // 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( - // 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, - }), + 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) => + s.deleteOrganizationCascade(organizationId, deletedAt), + ) + .pipe( + Effect.tapError((error) => + Effect.logError( + "deleteOrganization: org marked deleted, removed from WorkOS and Autumn, but local purge failed, tenant data and secrets orphaned; retry the deletion", + { organizationId, error }, + ), ), ); @@ -640,9 +745,31 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( // upsert mints the slug at insert — no separate heal step. const org = yield* workos.getOrganization(invitation.organizationId); const mirrored = yield* users.use("upsertOrganization", (s) => - s.upsertOrganization({ id: org.id, name: org.name }), + s.upsertOrganization({ + id: org.id, + name: org.name, + updatedAt: new Date(org.updatedAt), + }), ); + // Write-through: acceptance returns the invitation, not the + // membership it activated, so this is the one feeder that reads the + // membership back (a rare path; one extra call). WorkOS activates it + // as part of acceptance, so its absence is worth a warning — the + // Events reconciler will still land it. + const membership = yield* workos.getUserOrgMembership(org.id, session.accountId); + if (membership) { + yield* mirrorMembership(membership); + } else { + yield* Effect.logWarning( + "acceptInvitation: accepted invitation has no membership yet", + { + userId: session.accountId, + organizationId: org.id, + }, + ); + } + // The membership is active in WorkOS from this point even if // attaching the session below fails, so reconcile the org's billed // seat count now. 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 new file mode 100644 index 0000000000..d5fedb38a2 --- /dev/null +++ b/apps/cloud/src/auth/member-directory.ts @@ -0,0 +1,217 @@ +// --------------------------------------------------------------------------- +// Cloud's `MemberDirectory`: the shared read seam over the LOCAL membership +// mirror (`memberships` join `accounts`, db/schema.ts), never over WorkOS. +// +// The mirror is written by `WorkOsMirror` (login, write-through, the Events +// API reconciler); this file only reads it. A row without a `membership_id` +// was never written by a feeder — it predates the mirror — and is not +// reported: the directory answers only for memberships it actually knows the +// WorkOS identity of, and every feeder fills the id in on its next pass. +// A deleted membership is never dropped, it is TOMBSTONED (`status = +// 'inactive'`, `deleted_at` set) so a feeder replaying a payload of the +// deleted membership cannot bring it back; every read here filters on status, +// active + pending unless the caller names the statuses it wants. +// +// Per-request layer: it holds the request's postgres socket via `DbService`. +// --------------------------------------------------------------------------- + +import { and, asc, eq, ilike, inArray, isNotNull, or, sql } from "drizzle-orm"; +import { Effect, Layer } from "effect"; + +import { + DEFAULT_MEMBER_STATUSES, + MemberDirectory, + MemberDirectoryError, + normalizeMemberSearch, + type DirectoryMember, + type MemberDirectoryShape, + type MemberQuery, +} from "@executor-js/api/server"; + +import { accounts, memberships } from "../db/schema"; +import { DbService, type DrizzleDb } from "../db/db"; +import { tryPromiseService, withServiceLogging } from "./errors"; + +// Escape LIKE wildcards in a user-typed search term so `_` and `%` match +// themselves. Same treatment `org-deletion.ts` gives an org id prefix. +const escapeLike = (value: string): string => value.replace(/[\\%_]/g, "\\$&"); + +// The display name the seam reports: first + last, or nothing. Computed in SQL +// too (below) so the search term matches what the caller sees. +const displayName = (firstName: string | null, lastName: string | null): string | null => + [firstName, lastName].filter(Boolean).join(" ") || null; + +const makeService = (db: DrizzleDb): MemberDirectoryShape => { + const read = (op: string, fn: () => Promise) => + withServiceLogging( + `member_directory.${op}`, + () => + new MemberDirectoryError({ + message: `Failed to read the member directory (${op})`, + }), + tryPromiseService(fn), + ); + + // One projection for every read, so the seam's row shape is built in exactly + // one place. `membershipId` is non-null by the `known` predicate below. + const select = () => + db + .select({ + accountId: memberships.accountId, + membershipId: memberships.membershipId, + organizationId: memberships.organizationId, + role: memberships.role, + status: memberships.status, + email: accounts.email, + firstName: accounts.firstName, + lastName: accounts.lastName, + avatarUrl: accounts.avatarUrl, + lastSignInAt: accounts.lastSignInAt, + }) + .from(memberships) + .innerJoin(accounts, eq(accounts.id, memberships.accountId)) + .$dynamic(); + + type Row = Awaited>[number]; + + const toMember = (row: Row): DirectoryMember | null => + row.membershipId === null + ? null + : { + accountId: row.accountId, + membershipId: row.membershipId, + organizationId: row.organizationId, + email: row.email, + name: displayName(row.firstName, row.lastName), + avatarUrl: row.avatarUrl, + role: row.role, + status: row.status, + lastActiveAt: row.lastSignInAt === null ? null : row.lastSignInAt.getTime(), + }; + + const toMembers = (rows: readonly Row[]): DirectoryMember[] => { + const members: DirectoryMember[] = []; + for (const row of rows) { + const member = toMember(row); + if (member !== null) members.push(member); + } + return members; + }; + + const known = isNotNull(memberships.membershipId); + + const members = (organizationId: string, query: MemberQuery = {}) => + read("members", async () => { + const term = normalizeMemberSearch(query.search); + const pattern = term === undefined ? undefined : `%${escapeLike(term)}%`; + let statement = select() + .where( + and( + eq(memberships.organizationId, organizationId), + inArray(memberships.status, query.statuses ?? DEFAULT_MEMBER_STATUSES), + known, + pattern === undefined + ? undefined + : or( + ilike(accounts.email, pattern), + ilike(sql`concat_ws(' ', ${accounts.firstName}, ${accounts.lastName})`, pattern), + ), + ), + ) + .orderBy(asc(accounts.email), asc(memberships.accountId)); + if (query.limit !== undefined) statement = statement.limit(query.limit); + if (query.offset !== undefined) statement = statement.offset(query.offset); + return toMembers(await statement); + }); + + return { + membership: (accountId, organizationId, statuses = DEFAULT_MEMBER_STATUSES) => + read("membership", async () => { + const rows = await select() + .where( + and( + eq(memberships.accountId, accountId), + eq(memberships.organizationId, organizationId), + inArray(memberships.status, statuses), + known, + ), + ) + .limit(1); + const row = rows[0]; + 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) => + accountIds.length === 0 + ? Effect.succeed(new Map()) + : read("membersById", async () => { + const rows = await select().where( + and( + eq(memberships.organizationId, organizationId), + inArray(memberships.accountId, accountIds), + inArray(memberships.status, statuses), + known, + ), + ); + return new Map(toMembers(rows).map((member) => [member.accountId, member])); + }), + + findByEmail: (organizationId, email, statuses = DEFAULT_MEMBER_STATUSES) => + read("findByEmail", async () => { + const rows = await select() + .where( + and( + eq(memberships.organizationId, organizationId), + eq(sql`lower(${accounts.email})`, email), + inArray(memberships.status, statuses), + known, + ), + ) + .limit(1); + const row = rows[0]; + return row === undefined ? null : toMember(row); + }), + }; +}; + +/** 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 new file mode 100644 index 0000000000..b1cb9ba1de --- /dev/null +++ b/apps/cloud/src/auth/mirror-feeders.node.test.ts @@ -0,0 +1,2021 @@ +// --------------------------------------------------------------------------- +// The membership mirror's FEEDERS, end to end through the code that runs in +// production, against the real PGlite Postgres every cloud unit test runs on +// (scripts/test-globalsetup.ts). WorkOS is a fake `WorkOSClient` (the +// emulator has no list-users / events routes); the mirror, the user store, +// and the directory read are the live layers over `DbService.Live`. +// +// What this pins: +// - the login callback records the signed-in user and EVERY membership +// WorkOS lists (active and pending), with the org row minted so the FK +// holds — from the one membership list it already fetches +// - 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 +// listing was taken — marks each org backfilled as of its listing, and +// records the events replay boundary BEFORE its first listing and only +// ONCE: a run that fails part-way keeps the marks of the orgs it +// finished and the boundary it recorded, and its retry (like any later +// run) keeps that first boundary — so a user deleted between the failed +// attempt and the retry is still inside the events replay, and the +// reconciler clears their profile +// - two scans of one org that overlap cannot resurrect a membership: a scan +// that listed it, stalled, and resumed after a later listing (which no +// longer had it) was applied is refused whole +// - a login whose membership list was fetched BEFORE the org was purged and +// written after cannot re-mint the org or its membership, one fetched +// before a rename cannot revert the rename, and one fetched before a +// revocation the backfill has since scanned cannot reinstate the +// membership +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { sql } from "drizzle-orm"; +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, + RouterConfigLive, + requestScopedMiddleware, +} from "@executor-js/api/server"; + +import { AccountCaller, workosAccountProvider } from "../account/workos-account-service"; +import { RequestScopedServicesLive } from "../api/layers"; +import { DbService } from "../db/db"; +import { forkReportMemberSeats } from "../extensions/billing/member-seats"; +import { AutumnError, AutumnService, type AutumnFailure } from "../extensions/billing/service"; +import { ApiKeyService } from "./api-keys"; +import { UserStoreService } from "./context"; +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, + authorizeOrganization, + markOrganizationDeleted, +} from "./organization"; +import { WorkOSClient, type WorkOSClientService } from "./workos"; +import { WorkOsMirror, type WorkOsMirrorShape } from "./workos-mirror"; +import { backfillOrganization, backfillWorkOsMirror } from "./workos-mirror-backfill"; +import type { WorkOsMembershipPayload, WorkOsUserPayload } from "./workos-mirror-store"; + +const T1 = "2026-01-01T00:00:00.000Z"; +const T2 = "2026-01-02T00:00:00.000Z"; + +// Synthetic identities only. Every test mints its own org ids so the shared +// test database never couples two tests. +const freshId = (prefix: string) => `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`; + +const workosUser = (id: string, overrides: Partial = {}) => ({ + object: "user" as const, + id, + email: `${id}@placeholder.test`, + emailVerified: true, + firstName: "Ada", + lastName: "Placeholder", + profilePictureUrl: null, + lastSignInAt: T1, + locale: null, + createdAt: T1, + updatedAt: T1, + externalId: null, + metadata: {}, + ...overrides, +}); + +interface FakeMembership extends WorkOsMembershipPayload { + readonly organizationName: string; +} + +const workosMembership = ( + userId: string, + organizationId: string, + overrides: Partial = {}, +): FakeMembership => ({ + id: `om_${userId}_${organizationId}`, + userId, + organizationId, + organizationName: `Org ${organizationId}`, + role: { slug: "member" }, + status: "active", + updatedAt: T1, + ...overrides, +}); + +/** Mirrored rows for one org, read through the live cloud `MemberDirectory`. */ +const readMembers = (organizationId: string) => + Effect.runPromise( + Effect.flatMap(MemberDirectory.asEffect(), (directory) => + directory.members(organizationId, { + statuses: ["active", "pending", "inactive"], + }), + ).pipe( + Effect.provide(cloudMemberDirectoryLayer.pipe(Layer.provide(DbService.Live))), + Effect.scoped, + ), + ); + +/** Mirror an org row (named as of T1) and return the URL slug the store minted for it. */ +const seedOrganization = (id: string) => + Effect.runPromise( + Effect.flatMap(UserStoreService.asEffect(), (users) => + users.use("upsertOrganization", (s) => + s.upsertOrganization({ + id, + name: `Org ${id}`, + updatedAt: new Date(T1), + }), + ), + ).pipe( + Effect.map((org) => org.slug), + Effect.provide(UserStoreService.Live.pipe(Layer.provide(DbService.Live))), + Effect.scoped, + ), + ); + +const stubAutumn = Layer.succeed(AutumnService)({ + use: () => Effect.die("feeders do not read billing"), + ensureCustomer: () => Effect.void, + checkExecutionBalance: () => Effect.die("feeders do not check balances"), + trackExecution: () => Effect.void, + setMemberSeats: () => Effect.void, +}); + +/** + * A `WorkOSClient` whose every method is one of `methods`; anything else is + * an unexpected call and dies, so a feeder that silently adds a WorkOS read + * fails the test instead of passing on a fake. + */ +const stubWorkOS = (methods: Partial) => + Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => + (methods as Record)[prop] ?? + (() => Effect.die(`unexpected WorkOSClient.${String(prop)} call`)), + }), + ); + +describe("login callback", () => { + const callbackHandler = (workos: Layer.Layer) => + HttpRouter.toWebHandler( + HttpApiBuilder.layer(NonProtectedApi).pipe( + Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), + Layer.provide(requestScopedMiddleware(RequestScopedServicesLive).layer), + Layer.provideMerge(SessionAuthLive), + Layer.provideMerge(stubAutumn), + Layer.provideMerge(workos), + Layer.provideMerge(HttpServer.layerServices), + Layer.provideMerge(RouterConfigLive), + ), + { disableLogger: true }, + ).handler; + + const STATE_COOKIE = "wos-login-state"; + + /** + * A callback handler over a fake WorkOS that authenticates `user` with the + * memberships `listed`, recording every WorkOS read (`calls`) and every + * session refresh (`refreshedInto`, the org ids) so the landing-org choice + * is assertable from the outside. + */ + const signIn = (user: ReturnType, listed: readonly FakeMembership[]) => { + const calls: string[] = []; + const refreshedInto: (string | undefined)[] = []; + const handler = callbackHandler( + stubWorkOS({ + authenticateWithCode: () => + Effect.succeed({ + user, + organizationId: undefined, + accessToken: "access", + refreshToken: "refresh", + sealedSession: "sealed", + }), + listUserMemberships: (id) => { + calls.push(`listUserMemberships:${id}`); + return Effect.succeed({ + object: "list" as const, + data: listed as never[], + listMetadata: { before: null, after: null }, + }); + }, + // 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: 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"); + }, + }), + ); + return { handler, calls, refreshedInto }; + }; + + /** + * `GET /auth/callback` with the CSRF-matched login `state` (the callback + * refuses any request without one) and any extra cookies; `returnTo` + * rides inside the state as /login mints it. + */ + const callbackRequest = (options: { returnTo?: string; cookies?: Record }) => { + const url = new URL("http://test.local/auth/callback"); + url.searchParams.set("code", "code_1"); + const state = encodeLoginState({ + nonce: "nonce", + ...(options.returnTo === undefined ? {} : { returnTo: options.returnTo }), + }); + url.searchParams.set("state", state); + const cookies = { ...options.cookies, [STATE_COOKIE]: state }; + const cookie = Object.entries(cookies) + .map(([name, value]) => `${name}=${value}`) + .join("; "); + return new Request(url, { headers: cookie ? { cookie } : {} }); + }; + + it("records the user and every listed membership from the one list it already fetches", async () => { + const userId = freshId("user"); + const activeOrg = freshId("org"); + const pendingOrg = freshId("org"); + const { handler, calls } = signIn( + workosUser(userId, { + firstName: "Grace", + lastName: "Hopper", + updatedAt: T2, + }), + [ + workosMembership(userId, activeOrg, { + role: { slug: "admin" }, + updatedAt: T2, + }), + workosMembership(userId, pendingOrg, { status: "pending" }), + ], + ); + + const response = await handler(callbackRequest({})); + + expect(response.status).toBe(302); + 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}`, + ]); + + const active = await readMembers(activeOrg); + expect(active).toHaveLength(1); + expect(active[0]).toMatchObject({ + accountId: userId, + membershipId: `om_${userId}_${activeOrg}`, + email: `${userId}@placeholder.test`, + name: "Grace Hopper", + role: "admin", + status: "active", + lastActiveAt: new Date(T1).getTime(), + }); + const pending = await readMembers(pendingOrg); + expect( + pending.map((m) => m.status), + "pending memberships are mirrored too", + ).toEqual(["pending"]); + }); + + describe("lands in the org the returnTo slug names", () => { + it("when the user holds an active membership there", async () => { + const userId = freshId("user"); + const requested = freshId("org"); + const other = freshId("org"); + const slug = await seedOrganization(requested); + const { handler, refreshedInto } = signIn(workosUser(userId), [ + workosMembership(userId, other), + workosMembership(userId, requested), + ]); + + const response = await handler(callbackRequest({ returnTo: `/${slug}/settings` })); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(`/${slug}/settings`); + expect(refreshedInto, "the session is switched into the requested org").toEqual([requested]); + }); + + it("never when the membership there is only pending", async () => { + const userId = freshId("user"); + const requested = freshId("org"); + const other = freshId("org"); + const slug = await seedOrganization(requested); + const { handler, refreshedInto } = signIn(workosUser(userId), [ + workosMembership(userId, other), + workosMembership(userId, requested, { status: "pending" }), + ]); + + const response = await handler(callbackRequest({ returnTo: `/${slug}` })); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe(`/${slug}`); + expect( + refreshedInto, + "a pending membership is not a landing candidate, and an explicit slug does not fall back to another org", + ).toEqual([]); + }); + }); + + describe("without a returnTo org", () => { + it("lands in the last-org cookie's org when the user is active there", async () => { + const userId = freshId("user"); + const last = freshId("org"); + const other = freshId("org"); + const slug = await seedOrganization(last); + const { handler, refreshedInto } = signIn(workosUser(userId), [ + workosMembership(userId, other), + workosMembership(userId, last), + ]); + + const response = await handler(callbackRequest({ cookies: { [LAST_ORG_COOKIE]: slug } })); + + expect(response.status).toBe(302); + expect(response.headers.get("location")).toBe("/"); + expect(refreshedInto).toEqual([last]); + }); + + it("falls through an unknown last-org slug to the first active membership", async () => { + const userId = freshId("user"); + const pendingOrg = freshId("org"); + const activeOrg = freshId("org"); + const { handler, refreshedInto } = signIn(workosUser(userId), [ + workosMembership(userId, pendingOrg, { status: "pending" }), + workosMembership(userId, activeOrg), + ]); + + const response = await handler( + // Valid slug grammar, never minted: the store finds no org for it. + callbackRequest({ cookies: { [LAST_ORG_COOKIE]: "no-such-org-slug" } }), + ); + + expect(response.status).toBe(302); + expect(refreshedInto).toEqual([activeOrg]); + }); + }); +}); + +describe("a delayed sign-in feeder", () => { + /** The live mirror, user store, and directory over one test-db socket. */ + const Services = Layer.mergeAll( + UserStoreService.Live, + WorkOsMirror.Live, + cloudMemberDirectoryLayer, + ).pipe(Layer.provideMerge(DbService.Live)); + + const run = ( + body: Effect.Effect, + ) => Effect.runPromise(body.pipe(Effect.provide(Services), Effect.scoped)); + + const readOrganization = (org: string) => + Effect.flatMap(UserStoreService.asEffect(), (users) => + users.use("getOrganization", (s) => s.getOrganization(org)), + ); + + const readMembership = (userId: string, org: string) => + Effect.flatMap(MemberDirectory.asEffect(), (directory) => + directory.membership(userId, org, ["active", "pending", "inactive"]), + ); + + it("cannot re-mint a purged organization or its membership from a list fetched before the purge", async () => { + const userId = freshId("user"); + const org = freshId("org"); + await seedOrganization(org); + const result = await run( + Effect.gen(function* () { + const users = yield* UserStoreService; + // The login fetched its membership list at T1, while the org lived... + const fetchedAt = new Date(T1); + const listed = [workosMembership(userId, org)]; + // ...then stalled while cloud's deletion flow purged the org at T2. + yield* users.use("deleteOrganizationCascade", (s) => + s.deleteOrganizationCascade(org, new Date(T2)), + ); + // The stalled login resumes and writes what it holds. + yield* mirrorSignIn(workosUser(userId), listed, fetchedAt); + return { + organization: yield* readOrganization(org), + membership: yield* readMembership(userId, org), + }; + }), + ); + expect(result.organization?.deletedAt, "the org stays a deleted tombstone").toEqual( + new Date(T2), + ); + expect(result.membership, "and holds no membership: nothing to authorize").toBeNull(); + }); + + it("cannot reinstate a membership from a list fetched before a revocation the backfill has since scanned", async () => { + const userId = freshId("user"); + const org = freshId("org"); + await seedOrganization(org); + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + // The login fetched its membership list at T1, while the user was + // a member, then stalled... + const fetchedAt = new Date(T1); + const listed = [workosMembership(userId, org)]; + // ...WorkOS revoked the membership before the mirror was ever + // backfilled, and the backfill then scanned the org at T2 without + // it: no tombstone, the row was never there — and the revocation + // predates the events replay boundary, so no event will land it. + yield* mirror.applyOrganizationScan({ + organizationId: org, + listedAt: new Date(T2), + members: [], + }); + // The stalled login resumes and writes what it holds. + yield* mirrorSignIn(workosUser(userId), listed, fetchedAt); + const membership = yield* readMembership(userId, org); + // A login after the scan, carrying a membership WorkOS created + // since (stamped past the scan), is recorded. + const rejoinedAt = "2026-01-03T00:00:00.000Z"; + yield* mirrorSignIn( + workosUser(userId), + [ + workosMembership(userId, org, { + id: `om_${userId}_${org}_2`, + updatedAt: rejoinedAt, + }), + ], + new Date(rejoinedAt), + ); + return { membership, rejoined: yield* readMembership(userId, org) }; + }), + ); + expect(result.membership, "the pre-scan list reinstates nothing").toBeNull(); + expect(result.rejoined?.membershipId, "a membership newer than the scan is recorded").toBe( + `om_${userId}_${org}_2`, + ); + }); + + it("cannot revert a rename from a list fetched before it, and applies a newer name", async () => { + const userId = freshId("user"); + const org = freshId("org"); + await seedOrganization(org); + const result = await run( + Effect.gen(function* () { + const users = yield* UserStoreService; + // The org is renamed through Executor (write-through of the WorkOS + // organization payload, stamped T2)... + yield* users.use("upsertOrganization", (s) => + s.upsertOrganization({ + id: org, + name: "Renamed Org", + updatedAt: new Date(T2), + }), + ); + // ...after a login had fetched a list still carrying the old name at T1. + yield* mirrorSignIn( + workosUser(userId), + [workosMembership(userId, org, { organizationName: `Org ${org}` })], + new Date(T1), + ); + const afterStale = yield* readOrganization(org); + // A login whose list was fetched after the rename carries the new name. + yield* mirrorSignIn( + workosUser(userId), + [ + workosMembership(userId, org, { + organizationName: "Renamed Again", + }), + ], + new Date("2026-01-03T00:00:00.000Z"), + ); + const afterNewer = yield* readOrganization(org); + return { + afterStale, + afterNewer, + membership: yield* readMembership(userId, org), + }; + }), + ); + expect(result.afterStale?.name, "the stale list does not revert the rename").toBe( + "Renamed Org", + ); + expect(result.afterStale?.slug, "and the slug is untouched").toBe(result.afterNewer?.slug); + expect(result.afterNewer?.name, "a list fetched after the rename is applied").toBe( + "Renamed Again", + ); + expect(result.membership?.status, "the membership itself is recorded either way").toBe( + "active", + ); + }); +}); + +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; + } = {}, + ) => + HttpRouter.toWebHandler( + HttpApiBuilder.layer(NonProtectedApi).pipe( + Layer.provide(Layer.mergeAll(CloudAuthPublicHandlers, CloudSessionAuthHandlers)), + Layer.provide( + requestScopedMiddleware(Layer.mergeAll(options.services ?? RequestScopedServicesLive)) + .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, as every protected request + * runs it: membership is read from the mirror unconditionally; `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).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. The delete + // endpoint says so with a bare 404 (no `customer_not_found` code), so + // that is the shape the retry gets: an `AutumnError` whose SDK cause + // carries the status. + 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 AutumnError({ + message: "Autumn SDK request failed", + cause: { statusCode: 404, body: '{"message":"Not Found"}' }, + }); + 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 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"]); + + // WorkOS no longer has the org to delete a second time. The admin's own + // mirror row, which the failed purge left behind, is what admits the + // retry — membership is read from the mirror unconditionally. + const retry = sessionHandler(admin, { + autumn: deletingAutumn, + workos: { + deleteOrganization: () => Effect.fail(new WorkOSError({ status: 404 })), + }, + }); + const second = await retry(deleteOrganizationRequest(org)); + expect(second.status, "the retry is admitted from the mirror").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"); + + const session = (accountId: string) => ({ + accountId, + email: `${accountId}@placeholder.test`, + name: null, + avatarUrl: null, + organizationId: null, + sealedSession: "sealed", + refreshedSession: null, + }); + + const stubApiKeys = Layer.succeed(ApiKeyService)({ + validate: () => Effect.die("membership writes do not validate keys"), + listUserKeys: () => Effect.die("membership writes do not list keys"), + createUserKey: () => Effect.die("membership writes do not create keys"), + revokeUserKey: () => Effect.die("membership writes do not revoke keys"), + listOrgKeys: () => Effect.die("membership writes do not list keys"), + createOrgKey: () => Effect.die("membership writes do not create keys"), + revokeOrgKey: () => Effect.die("membership writes do not revoke keys"), + }); + + /** + * 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[], + options: { + readonly workos?: Partial; + readonly autumn?: Layer.Layer; + } = {}, + ) => { + const workos = stubWorkOS({ + ...options.workos, + deleteOrgMembership: (membershipId) => + Effect.sync(() => { + deleted.push(membershipId); + }), + updateOrgMembershipRole: (membershipId, roleSlug) => + Effect.succeed( + workosMembership(TARGET, org, { + id: membershipId, + role: { slug: roleSlug }, + updatedAt: T2, + }) as never, + ), + }); + // The test database serves ONE connection at a time, so the seed, the + // provider, and the directory read all share this layer's socket. + const stores = Layer.mergeAll( + UserStoreService.Live, + WorkOsMirror.Live, + cloudMemberDirectoryLayer, + ); + return workosAccountProvider.pipe( + Layer.provide( + Layer.mergeAll( + workos, + stubApiKeys, + options.autumn ?? stubAutumn, + Layer.succeed(AccountCaller)({ session: session(ADMIN) }), + ), + ), + Layer.provideMerge(stores), + Layer.provide(DbService.Live), + ); + }; + + // 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; + yield* users.use("upsertOrganization", (s) => + s.upsertOrganization({ + id: org, + name: `Org ${org}`, + 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, + organizationId: org, + role: "member", + 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[] = []; + return Effect.gen(function* () { + yield* seedTarget(org); + const account = yield* AccountProvider; + + const result = yield* account.removeMember( + { [ORG_SELECTOR_HEADER]: org }, + `om_${TARGET}_${org}`, + ); + + expect(result).toEqual({ success: true }); + expect(deleted, "WorkOS is the authority and is written first").toEqual([ + `om_${TARGET}_${org}`, + ]); + const members = yield* membersOf(org); + expect(members.map((m) => m.accountId)).not.toContain(TARGET); + + // A login or backfill that listed TARGET's membership BEFORE the + // removal writes it afterwards: the tombstone refuses it. + const mirror = yield* WorkOsMirror; + const replayed = yield* mirror.upsertMembership({ + id: `om_${TARGET}_${org}`, + accountId: TARGET, + organizationId: org, + role: "member", + status: "active", + updatedAt: new Date(T1), + }); + expect(replayed, "the pre-removal payload is refused").toBe(false); + expect((yield* membersOf(org)).map((m) => m.accountId)).not.toContain(TARGET); + + // The tombstone carries the membership's last WorkOS stamp (T1), not + // the wall clock at the delete: a replacement membership WorkOS + // created for TARGET while the removal was in flight — stamped T2, + // long before any clock this test runs under — is accepted. + const replaced = yield* mirror.upsertMembership({ + id: `om_${TARGET}_${org}_2`, + accountId: TARGET, + organizationId: org, + role: "member", + status: "active", + updatedAt: new Date(T2), + }); + expect(replaced, "a replacement newer than the removed state is accepted").toBe(true); + expect((yield* membersOf(org)).map((m) => m.accountId)).toContain(TARGET); + }).pipe(Effect.provide(providerLayer(org, deleted))); + }); + + it.effect("updateMemberRole writes the role WorkOS returned", () => { + const org = freshId("org"); + return Effect.gen(function* () { + yield* seedTarget(org); + const account = yield* AccountProvider; + + const result = yield* account.updateMemberRole( + { [ORG_SELECTOR_HEADER]: org }, + `om_${TARGET}_${org}`, + "admin", + ); + + expect(result).toEqual({ success: true }); + const members = yield* membersOf(org); + 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", () => { + /** A fake WorkOS holding `orgs` → members, counting `getUser` calls. */ + const source = (orgs: ReadonlyMap, userCalls: string[]) => ({ + listOrganizationIds: () => Effect.succeed([...orgs.keys()]), + listOrgMembers: (organizationId: string) => Effect.succeed(orgs.get(organizationId) ?? []), + getUser: (userId: string) => + Effect.sync(() => { + userCalls.push(userId); + return workosUser(userId); + }), + }); + + const withMirror = (body: (mirror: WorkOsMirrorShape) => Effect.Effect) => + Effect.runPromise( + Effect.flatMap(WorkOsMirror.asEffect(), body).pipe( + Effect.provide(WorkOsMirror.Live.pipe(Layer.provide(DbService.Live))), + Effect.scoped, + ), + ); + + const runBackfill = ( + orgs: ReadonlyMap, + dryRun: boolean, + userCalls: string[] = [], + ) => + withMirror((mirror) => + backfillWorkOsMirror(source(orgs, userCalls), mirror, { + dryRun, + log: () => undefined, + }), + ); + + /** The instance-wide replay boundary a completed run records. */ + const syncState = () => withMirror((mirror) => mirror.replayBoundary()); + + /** When a run first covered every organization, or null: the authorization gate's first half. */ + const completedAt = () => withMirror((mirror) => mirror.backfillCompletedAt()); + + /** Drop the instance-wide events row, so the run under test is the first ever. */ + const clearEventsRow = () => + Effect.runPromise( + Effect.flatMap(DbService.asEffect(), ({ db }) => + Effect.promise(() => db.execute(sql`delete from workos_sync where id = 'events'`)), + ).pipe(Effect.provide(DbService.Live), Effect.scoped), + ); + + const backfilledAt = (org: string) => + withMirror((mirror) => mirror.organizationBackfilledAt(org)); + + it("records the replay boundary at its start, marks and mirrors every organization's members, counts the writes, converges and repairs on a re-run", async () => { + const orgA = freshId("org"); + const orgB = freshId("org"); + await seedOrganization(orgA); + await seedOrganization(orgB); + const shared = freshId("user"); + const leaving = freshId("user"); + const orgs = new Map([ + [orgA, [workosMembership(shared, orgA), workosMembership(leaving, orgA)]], + [orgB, [workosMembership(shared, orgB, { status: "pending" })]], + ]); + + // The boundary is instance-wide (migration 0019 seeds it on the empty + // test database, other tests may have written it): start as a database + // that has never been backfilled. + await clearEventsRow(); + const startedAt = Date.now(); + + const dry = await runBackfill(orgs, true); + expect(dry).toEqual({ + organizations: 2, + memberships: 3, + usersWritten: 0, + membershipsWritten: 0, + membershipsTombstoned: 0, + }); + expect(await readMembers(orgA), "a dry run writes nothing").toEqual([]); + expect(await syncState(), "a dry run records no boundary").toBeNull(); + expect(await completedAt(), "nor a completion").toBeNull(); + expect(await backfilledAt(orgA), "and marks nothing").toBeNull(); + + const userCalls: string[] = []; + const first = await runBackfill(orgs, false, userCalls); + expect(first).toEqual({ + organizations: 2, + memberships: 3, + usersWritten: 3, + membershipsWritten: 3, + membershipsTombstoned: 0, + }); + expect(userCalls, "one getUser per membership").toHaveLength(3); + expect((await readMembers(orgA)).map((m) => m.status)).toEqual(["active", "active"]); + expect((await readMembers(orgB)).map((m) => m.status)).toEqual(["pending"]); + const after = await syncState(); + expect(after, "the run records where the events replay starts").not.toBeNull(); + const firstCompletion = await completedAt(); + expect(firstCompletion, "and that every organization is now covered").not.toBeNull(); + expect(firstCompletion!.getTime()).toBeGreaterThanOrEqual(after!.getTime()); + expect(after!.getTime()).toBeGreaterThanOrEqual(startedAt); + expect( + after!.getTime(), + "the boundary is the instant the run began reading, before any listing", + ).toBeLessThanOrEqual(startedAt + 60 * 1000); + for (const org of [orgA, orgB]) { + const marked = await backfilledAt(org); + expect(marked, "each scanned organization is marked as of its listing").not.toBeNull(); + expect(marked!.getTime()).toBeGreaterThanOrEqual(after!.getTime()); + } + + // Same payloads again, minus one member WorkOS no longer lists: the + // `updatedAt` guard lets equal payloads through (replays converge), and + // the missing membership is tombstoned — a re-run repairs a stale row. + orgs.set(orgA, [workosMembership(shared, orgA)]); + const again = await runBackfill(orgs, false); + expect(again).toMatchObject({ memberships: 2, membershipsTombstoned: 1 }); + expect( + await syncState(), + "the re-run keeps the first boundary: an org rename or user deletion between the two runs is only in the events stream", + ).toEqual(after); + expect(await completedAt(), "and the first completion").toEqual(firstCompletion); + expect(new Map((await readMembers(orgA)).map((m) => [m.accountId, m.status]))).toEqual( + new Map([ + [shared, "active"], + [leaving, "inactive"], + ]), + ); + expect( + (await readMembers(orgB)).map((m) => m.status), + "the other org is untouched", + ).toEqual(["pending"]); + // The tombstone is keyed to the deleted membership id, so the member's + // pre-removal payload (as a late login or event would carry) is refused. + const replayed = await withMirror((mirror) => + mirror.upsertMembership({ + id: `om_${leaving}_${orgA}`, + accountId: leaving, + organizationId: orgA, + role: "member", + status: "active", + updatedAt: new Date(T1), + }), + ); + expect(replayed).toBe(false); + }); + + it("keeps a membership WorkOS merely deactivated as inactive, not tombstoned, so a later reactivation lands", async () => { + const org = freshId("org"); + await seedOrganization(org); + const paused = freshId("user"); + // Mirrored while active (a sign-in), then deactivated in WorkOS: the + // listing carries the membership under the SAME id with its real status. + await withMirror((mirror) => + mirror.upsertMembership({ + id: `om_${paused}_${org}`, + accountId: paused, + organizationId: org, + role: "member", + status: "active", + updatedAt: new Date(T1), + }), + ); + + const counts = await runBackfill( + new Map([ + [ + org, + [ + workosMembership(paused, org, { + status: "inactive", + updatedAt: T2, + }), + ], + ], + ]), + false, + ); + expect(counts, "the deactivated membership is written, not tombstoned").toMatchObject({ + memberships: 1, + membershipsWritten: 1, + membershipsTombstoned: 0, + }); + expect((await readMembers(org)).map((m) => [m.accountId, m.status])).toEqual([ + [paused, "inactive"], + ]); + + // WorkOS reactivates it under the same id AFTER the scan (so the payload + // is stamped past the org's `backfilled_at`): an ordinary newer payload, + // which a tombstone keyed to that id would have refused for good. + const reactivated = await withMirror((mirror) => + mirror.upsertMembership({ + id: `om_${paused}_${org}`, + accountId: paused, + organizationId: org, + role: "member", + status: "active", + updatedAt: new Date(Date.now() + 60 * 1000), + }), + ); + expect(reactivated).toBe(true); + expect((await readMembers(org)).map((m) => m.status)).toEqual(["active"]); + }); + + it("leaves a membership written after its listing alone when tombstoning what the listing lacks", async () => { + const org = freshId("org"); + await seedOrganization(org); + const listed = freshId("user"); + const stale = freshId("user"); + const joinedMeanwhile = freshId("user"); + // Both rows are absent from the listing below. `stale` is stamped before + // the listing (a genuine leaver); `joinedMeanwhile` carries a stamp AFTER + // any listing this run can take — the membership WorkOS created between + // the listing and the cleanup, whose own event lands via the reconciler. + const afterListing = new Date(Date.now() + 60 * 60 * 1000); + await withMirror((mirror) => + Effect.all([ + mirror.upsertMembership({ + id: `om_${stale}_${org}`, + accountId: stale, + organizationId: org, + role: "member", + status: "active", + updatedAt: new Date(T1), + }), + mirror.upsertMembership({ + id: `om_${joinedMeanwhile}_${org}`, + accountId: joinedMeanwhile, + organizationId: org, + role: "member", + status: "active", + updatedAt: afterListing, + }), + ]), + ); + + const counts = await runBackfill(new Map([[org, [workosMembership(listed, org)]]]), false); + + expect(counts).toMatchObject({ memberships: 1, membershipsTombstoned: 1 }); + expect(new Map((await readMembers(org)).map((m) => [m.accountId, m.status]))).toEqual( + new Map([ + [listed, "active"], + [stale, "inactive"], + [joinedMeanwhile, "active"], + ]), + ); + }); + + it("keeps the boundary a run that fails part-way recorded, so a user deleted before the retry is still the reconciler's to clear", async () => { + const orgA = freshId("org"); + const orgB = freshId("org"); + await seedOrganization(orgA); + await seedOrganization(orgB); + const staying = freshId("user"); + const deletedMeanwhile = freshId("user"); + await clearEventsRow(); + + // Attempt A mirrors both users of orgA, then fails on orgB's listing. + const attemptA = new Map([ + [orgA, [workosMembership(staying, orgA), workosMembership(deletedMeanwhile, orgA)]], + [orgB, [workosMembership(staying, orgB)]], + ]); + const failing = { + ...source(attemptA, []), + listOrgMembers: (organizationId: string) => + organizationId === orgB + ? Effect.fail(new WorkOSError({ status: 503 })) + : Effect.succeed(attemptA.get(organizationId) ?? []), + }; + const exit = await withMirror((mirror) => + Effect.exit( + backfillWorkOsMirror(failing, mirror, { + dryRun: false, + log: () => undefined, + }), + ), + ); + expect(Exit.isFailure(exit), "the run fails rather than skipping the org").toBe(true); + const boundary = await syncState(); + expect(boundary, "the failed attempt already fixed the replay boundary").not.toBeNull(); + expect(await backfilledAt(orgA), "the org it finished is marked").not.toBeNull(); + expect(await backfilledAt(orgB), "the org it did not reach is not").toBeNull(); + expect( + await completedAt(), + "no completion mark: the failed attempt did not cover every organization", + ).toBeNull(); + expect( + (await readMembers(orgA)).find((m) => m.accountId === deletedMeanwhile)?.email, + "the user's profile is mirrored", + ).toBe(`${deletedMeanwhile}@placeholder.test`); + + // WorkOS deletes `deletedMeanwhile` between the attempts. Its + // `user.deleted` event is stamped AFTER the boundary attempt A recorded. + const deletedAt = new Date(boundary!.getTime() + 1); + + // Retry B lists WorkOS without the deleted user and succeeds. + const attemptB = new Map([ + [orgA, [workosMembership(staying, orgA)]], + [orgB, [workosMembership(staying, orgB)]], + ]); + const retried = await runBackfill(attemptB, false); + expect(retried).toMatchObject({ + organizations: 2, + membershipsTombstoned: 1, + }); + expect( + await syncState(), + "the retry keeps the first attempt's boundary instead of taking a later one", + ).toEqual(boundary); + expect( + await backfilledAt(orgB), + "and finishes the org the first attempt did not", + ).not.toBeNull(); + expect( + await completedAt(), + "the retry is the first run to cover every organization, so it records the completion", + ).not.toBeNull(); + // The scan tombstoned the membership, but the account profile is not + // the scan's to clear: that is the `user.deleted` event's job, which is + // exactly why the boundary must not move past it. + const tombstoned = (await readMembers(orgA)).find((m) => m.accountId === deletedMeanwhile); + expect(tombstoned?.status).toBe("inactive"); + expect(tombstoned?.email, "the profile is still there for the event to clear").not.toBeNull(); + + // The reconciler's first run reads from the kept boundary, so the + // deletion (stamped after it) is inside the replay and clears the row. + expect(deletedAt.getTime()).toBeGreaterThan(boundary!.getTime()); + const cleared = await withMirror((mirror) => mirror.deleteUser(deletedMeanwhile, deletedAt)); + expect(cleared).toBe(true); + expect( + (await readMembers(orgA)).find((m) => m.accountId === deletedMeanwhile)?.email, + "the deleted user's profile is gone from the directory", + ).toBeNull(); + }); + + it("scans one organization on demand and marks only that one", async () => { + const org = freshId("org"); + const other = freshId("org"); + await seedOrganization(org); + await seedOrganization(other); + const member = freshId("user"); + const orgs = new Map([ + [org, [workosMembership(member, org)]], + [other, [workosMembership(member, other)]], + ]); + + const counts = await withMirror((mirror) => + backfillOrganization(source(orgs, []), mirror, org, { dryRun: false }), + ); + + expect(counts).toEqual({ + applied: true, + memberships: 1, + usersWritten: 1, + membershipsWritten: 1, + membershipsTombstoned: 0, + }); + expect((await readMembers(org)).map((m) => m.accountId)).toEqual([member]); + expect(await backfilledAt(org)).not.toBeNull(); + expect(await readMembers(other), "the other organization is not scanned").toEqual([]); + expect(await backfilledAt(other), "nor marked").toBeNull(); + }); + + it("refuses a scan that stalled while a later scan found a membership gone, so the revoked member stays revoked", async () => { + const org = freshId("org"); + await seedOrganization(org); + const staying = freshId("user"); + const leaving = freshId("user"); + const result = await withMirror((mirror) => + Effect.gen(function* () { + // Scan A lists the org while `leaving` is still a member, then + // stalls (its listing is held behind the latch)... + const listedByA = yield* Latch.make(false); + const stalled = { + ...source(new Map(), []), + listOrgMembers: () => + listedByA.await.pipe( + Effect.as([workosMembership(staying, org), workosMembership(leaving, org)]), + ), + }; + const scanA = yield* Effect.forkChild( + backfillOrganization(stalled, mirror, org, { dryRun: false }), + { startImmediately: true }, + ); + // ...WorkOS removes `leaving`, and scan B lists and applies the + // org without them — no tombstone, the row was never there... + const b = yield* backfillOrganization( + source(new Map([[org, [workosMembership(staying, org)]]]), []), + mirror, + org, + { dryRun: false }, + ); + // ...then A resumes with its older listing. + yield* listedByA.open; + const a = yield* Fiber.join(scanA); + return { a, b }; + }), + ); + expect(result.b).toMatchObject({ applied: true, membershipsWritten: 1 }); + expect(result.a, "the older listing is refused whole").toMatchObject({ + applied: false, + memberships: 2, + usersWritten: 0, + membershipsWritten: 0, + membershipsTombstoned: 0, + }); + expect( + new Map((await readMembers(org)).map((m) => [m.accountId, m.status])), + "the member the later listing no longer had was never inserted", + ).toEqual(new Map([[staying, "active"]])); + }); +}); diff --git a/apps/cloud/src/auth/mirror-feeders.ts b/apps/cloud/src/auth/mirror-feeders.ts new file mode 100644 index 0000000000..3b7ab3ec11 --- /dev/null +++ b/apps/cloud/src/auth/mirror-feeders.ts @@ -0,0 +1,186 @@ +// --------------------------------------------------------------------------- +// The membership mirror's request-path FEEDERS: the writes the login callback +// and the Executor-initiated membership changes make through `WorkOsMirror`. +// +// 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 — 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, + mirrorUserFromWorkOs, + 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, + * which is what lets the sign-in feeder mirror the org row without a + * `getOrganization` call. `OrganizationMembership` from the SDK satisfies it. + */ +export interface WorkOsSignInMembership extends WorkOsMembershipPayload { + readonly organizationName: string; +} + +/** + * Record a sign-in: the user's profile, then every organization WorkOS lists + * them in (the org row first, so the membership's foreign key holds) and the + * membership itself. Replays converge: every write is guarded on WorkOS + * `updatedAt`, so a second login with the same payload changes nothing. + * + * `fetchedAt` is the instant the membership list was requested — taken + * BEFORE the WorkOS read, so nothing that changed after it can be mistaken + * for older. A membership list names each organization but carries no + * organization timestamp, so `fetchedAt` is the stamp its name is written + * under: a list fetched before a rename (a login that stalled) cannot revert + * the rename after it landed. A membership of an organization the mirror + * holds as deleted is refused by the mirror, and the organization is neither + * re-minted nor renamed: a list fetched before a deletion cannot restore the + * organization after its purge. And a membership stamped before the + * organization's last full scan (`organizations.backfilled_at`) is refused + * too: a list fetched before a revocation and written after the scan that + * found the membership gone cannot reinstate it. + */ +export const mirrorSignIn = Effect.fn("workos_mirror.signIn")(function* ( + user: WorkOsUserPayload, + memberships: readonly WorkOsSignInMembership[], + fetchedAt: Date, +) { + const mirror = yield* WorkOsMirror; + const users = yield* UserStoreService; + yield* mirror.upsertUser(mirrorUserFromWorkOs(user)); + for (const membership of memberships) { + yield* users.use("upsertOrganization", (s) => + s.upsertOrganization({ + id: membership.organizationId, + name: membership.organizationName, + updatedAt: fetchedAt, + }), + ); + yield* mirror.upsertMembership(mirrorMembershipFromWorkOs(membership)); + } +}); + +/** + * Record one membership WorkOS just returned to a write (create, role + * change, invitation acceptance). The organization must already be mirrored; + * every caller has just upserted it or resolved it through the mirror. + */ +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..ccab8939a7 --- /dev/null +++ b/apps/cloud/src/auth/mirror-readiness-store.ts @@ -0,0 +1,113 @@ +// --------------------------------------------------------------------------- +// Mirror READINESS: whether the local membership mirror has ever been fit to +// authorize from, per the ORIGINAL cutover rule. The request path +// (`organization.ts`) no longer consults this — it authorizes from the +// mirror unconditionally, because the one-off backfill is complete and +// permanent and a pre-mirror organization is covered by the on-demand scan +// (`ensureOrganizationBackfilled`). What remains is the deploy gate +// (`scripts/ensure-workos-mirror-ready.ts`), which still refuses to ship a +// build that trusts the mirror while it is unready, and the reconciler's own +// staleness alert (`workos-events-runner.ts`), which reads `drainedAt` after +// each run and raises a Sentry error when the drain has fallen behind the lag +// budget below — a stalled reconciler is now an operational page, not a +// per-request fallback. +// +// 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. +// +// The rule and the row read live here, free of `cloudflare:workers`, so the +// deploy gate applies the SAME rule over a plain postgres.js connection under +// bun before a build goes live, and the reconciler's alert applies the SAME +// `drainedAt` age check the gate does. +// --------------------------------------------------------------------------- + +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/org-api-key-auth.node.test.ts b/apps/cloud/src/auth/org-api-key-auth.node.test.ts index ec87511c3b..5b19386a2e 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,12 @@ 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 { 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 @@ -13,6 +16,19 @@ import { isPlatformAuth, resolveApiKeyPrincipal, resolveBearerAuth } from "./wor const createdAt = new Date("2026-01-01T00:00:00.000Z"); +// 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, +}); + const stubApiKeys = Layer.succeed(ApiKeyService)({ validate: (value: string) => { if (value === "valid_org_key") { @@ -45,51 +61,89 @@ 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 always read from the mirror, never +// from WorkOS. +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(() => fn({ - ensureAccount: async (id: string) => ({ id, createdAt }), - getAccount: async (id: string) => ({ id, createdAt }), + 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: null, + workosUpdatedAt: null, createdAt, }), getOrganization: async (id: string) => ({ id, name: `Org ${id}`, slug: `org-slug-${id}`, + backfilledAt: createdAt, + deletedAt: null, + workosUpdatedAt: null, createdAt, }), getOrganizationBySlug: async (slug: string) => ({ id: "org_by_slug", name: `Org ${slug}`, slug, + 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); const bearer = (token: string) => new Request("https://executor.test/api/tools", { @@ -115,12 +169,69 @@ 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), + ), + ), + ); + 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)); expect(isPlatformAuth(auth)).toBe(false); - expect(auth).toMatchObject({ accountId: "user_123", organizationId: "org_123" }); + expect(auth).toMatchObject({ + accountId: "user_123", + organizationId: "org_123", + }); }), ); @@ -148,10 +259,22 @@ 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), + ), + ); 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 ead56fb893..f972a2e757 100644 --- a/apps/cloud/src/auth/org-selector-auth.node.test.ts +++ b/apps/cloud/src/auth/org-selector-auth.node.test.ts @@ -1,26 +1,81 @@ import { describe, expect, it } from "@effect/vitest"; import { Effect, Layer } from "effect"; +import type * as Tracer from "effect/Tracer"; + +import { MemberDirectory, type DirectoryMember } from "@executor-js/api/server"; import { ApiKeyService } from "./api-keys"; import { UserStoreService } from "./context"; +import { AUTHORIZE_ORGANIZATION_SPAN } from "./organization"; 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"); +// 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, +}); + // 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" })], +]); + +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 @@ -46,18 +101,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`); }, }), @@ -67,38 +112,110 @@ const stubUsers = Layer.succeed(UserStoreService)({ use: (_op, fn) => Effect.promise(() => fn({ - ensureAccount: async (id: string) => ({ id, createdAt }), - getAccount: async (id: string) => ({ id, createdAt }), + ensureAccount: async (id: string) => bareAccount(id), + getAccount: async (id: string) => bareAccount(id), // Slug is minted at insert now — the stub returns a slugged row. 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, }), - // 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: 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) => 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)), ); +/** + * A tracer that keeps every span's attributes, so the authorization span + * itself — {@link AUTHORIZE_ORGANIZATION_SPAN} — is assertable. + */ +const makeRecordingTracer = () => { + const spans: { + readonly name: string; + readonly attributes: Map; + }[] = []; + const tracer: Tracer.Tracer = { + span: (options) => { + const attributes = new Map(); + spans.push({ name: options.name, attributes }); + let status: Tracer.SpanStatus = { + _tag: "Started", + startTime: options.startTime, + }; + return { + _tag: "Span", + name: options.name, + spanId: `span-${spans.length}`, + traceId: "trace-1", + parent: options.parent, + annotations: options.annotations, + get status() { + return status; + }, + attributes, + links: options.links, + sampled: options.sampled, + kind: options.kind, + end: (endTime, exit) => { + status = { + _tag: "Ended", + startTime: options.startTime, + endTime, + exit, + }; + }, + attribute: (key, value) => { + attributes.set(key, value); + }, + event: () => undefined, + addLinks: () => undefined, + }; + }, + }; + const attributesOf = (name: string) => spans.find((span) => span.name === name)?.attributes; + return { tracer, attributesOf }; +}; + describe("resolveSessionPrincipal · URL org selector", () => { it.effect("fails closed when no selector header is sent", () => Effect.gen(function* () { @@ -119,6 +236,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" }); }), ); @@ -146,3 +280,22 @@ describe("resolveSessionPrincipal · URL org selector", () => { }), ); }); + +// Membership is read from the local mirror unconditionally — there is no +// readiness gate and no per-request WorkOS fallback (`auth/organization.ts`). +// The span every authorization runs under is still pinned here; a stalled +// reconciler is now an operational alert (`workos-events-runner.ts`), not a +// request-path branch, so it has no span attribute left to assert on. +describe("resolveSessionPrincipal · authorization span", () => { + it.effect("runs the authorization under its span", () => + Effect.gen(function* () { + const recorder = makeRecordingTracer(); + const principal = yield* run({ + cookie: "wos-session=x", + "x-executor-organization": URL_SLUG, + }).pipe(Effect.withTracer(recorder.tracer)); + expect(principal.organizationId).toBe(URL_ORG); + expect(recorder.attributesOf(AUTHORIZE_ORGANIZATION_SPAN)).toBeDefined(); + }), + ); +}); diff --git a/apps/cloud/src/auth/organization.ts b/apps/cloud/src/auth/organization.ts index 5aceb2ab1a..e46560c52e 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,13 @@ // 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 type { Organization } from "./user-store"; import { WorkOSClient } from "./workos"; // --------------------------------------------------------------------------- @@ -44,60 +48,197 @@ export const resolveOrganization = (organizationId: string) => const workos = yield* WorkOSClient; const fresh = yield* workos.getOrganization(organizationId); return yield* users.use("upsertOrganization", (s) => - s.upsertOrganization({ id: fresh.id, name: fresh.name }), + s.upsertOrganization({ + id: fresh.id, + name: fresh.name, + updatedAt: new Date(fresh.updatedAt), + }), ); }); // --------------------------------------------------------------------------- -// 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 we verify membership live on every protected request. -// `listUserMemberships` is one WorkOS call per request. +// To close that gap, membership is verified on every protected request +// against the LOCAL mirror of WorkOS memberships (`memberships` join +// `accounts`, read through the shared `MemberDirectory`) — never against +// WorkOS itself, and unconditionally: there is no readiness gate and no +// per-request WorkOS fallback. 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. // -// 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. +// The one-off backfill is complete and permanent, and an organization that +// predates it is covered on demand (below), so there is nothing left for a +// per-request readiness check to gate. What can still go wrong is the events +// reconciler falling behind — a member revoked in the WorkOS dashboard would +// keep a stale active row until it catches up. That is now an OPERATIONAL +// concern, not a request-path fallback: the reconciler itself +// (`workos-events-runner.ts`) checks its own drain lag after every run and +// raises a Sentry error when it has stalled, so it is fixed by paging someone, +// not by asking WorkOS on every request. The deploy gate +// (`scripts/ensure-workos-mirror-ready.ts`) separately refuses to ship a build +// that trusts the mirror while it is unready, using the same rule +// (`mirror-readiness-store.ts`). // -// 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). +// 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 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"; +} -export const authorizeOrganization = (userId: string, organizationId: string) => +/** 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; + }); + +// 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 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 = 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); }); +/** The span every membership authorization runs under. */ +export const AUTHORIZE_ORGANIZATION_SPAN = "auth.authorize_organization"; + +export const authorizeOrganization = ( + userId: string, + organizationId: string, + options: AuthorizeOrganizationOptions = {}, +) => + Effect.gen(function* () { + 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. A marked-deleted organization + // is never scanned, so the deletion retry (`deleted: "allow"`) reaches + // `authorized()` below with the membership row the purge has not removed + // yet. + 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); + }).pipe(Effect.withSpan(AUTHORIZE_ORGANIZATION_SPAN)); + // --------------------------------------------------------------------------- // Org SELECTOR — the URL is the scope authority, not the session. // --------------------------------------------------------------------------- @@ -107,8 +248,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 @@ -126,15 +267,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 997d6ebc86..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 { eq } from "drizzle-orm"; +import { and, eq, isNull, lte, or, sql } from "drizzle-orm"; import { generateOrgSlug } from "@executor-js/api"; @@ -18,12 +18,56 @@ import { purgeOrganizationData } from "../db/org-deletion"; export type Account = typeof accounts.$inferSelect; export type Organization = typeof organizations.$inferSelect; -export const makeUserStore = (db: DrizzleDb) => { - const getOrganization = async (id: string) => { - const rows = await db.select().from(organizations).where(eq(organizations.id, id)); - return rows[0] ?? null; - }; +/** + * An organization as a feeder hands it to the mirror. `updatedAt` is when + * `name` is known to have been the organization's name in WorkOS: the WorkOS + * `updatedAt` of an organization payload, or the instant a membership list + * naming the organization was fetched (a list carries the name but no + * organization timestamp). + */ +export interface OrganizationPayload { + readonly id: string; + readonly name: string; + readonly updatedAt: Date; +} + +/** + * Which stored organization rows a name stamped `updatedAt` may rename: a + * row with no stamp (predating the stamp), or one stamped at or before + * `updatedAt` — feeders replay the same payload and must converge. Every + * writer of `organizations.name` applies this, so a name fetched before a + * rename can never revert the rename after it landed. + */ +export const organizationAcceptsName = (updatedAt: Date) => + or(isNull(organizations.workosUpdatedAt), lte(organizations.workosUpdatedAt, updatedAt)); + +const readOrganization = async (db: DrizzleDb, id: string) => { + const rows = await db.select().from(organizations).where(eq(organizations.id, id)); + return rows[0] ?? null; +}; +/** + * Insert the organization row for `row.id` with a freshly minted URL slug, + * and return the row now held for that id: the one inserted, or the one a + * concurrent writer minted first. THE single mint point for slugs: every + * organization row is born with one, so there is no nullable window and no + * self-healing. With `deletedAt` set this mints a TOMBSTONE — the row an + * organization deleted in WorkOS before the mirror ever saw it leaves + * behind, so a feeder still holding a membership of it cannot mint it live + * (`upsertOrganization` returns a marked row untouched). + * + * `ON CONFLICT DO NOTHING` (no target) absorbs BOTH unique violations + * without throwing: an id collision (the org was mirrored concurrently), + * which resolves to the row now held, and a slug collision (the candidate + * was claimed by a different org), which retries with a fresh candidate. + * + * @throws when slug minting exhausts its retries — `isTaken` is broken; + * surfacing loudly beats a silently unslugged organization. + */ +export const insertOrganization = async ( + db: DrizzleDb, + row: Pick, +): Promise => { const slugTaken = async (slug: string) => { const rows = await db .select({ id: organizations.id }) @@ -31,47 +75,53 @@ export const makeUserStore = (db: DrizzleDb) => { .where(eq(organizations.slug, slug)); return rows.length > 0; }; - - // Insert a brand-new org row carrying a freshly-minted slug. `ON CONFLICT DO - // NOTHING` (no target) absorbs BOTH unique violations without throwing: an - // id collision (the org was mirrored concurrently) and a slug collision (the - // candidate was claimed by a different org). Returns the inserted row, or - // null when either conflict swallowed the insert — the caller decides whether - // to re-read (id race) or retry with a new candidate (slug race). - const tryInsertOrg = async (id: string, name: string, slug: string) => { - const [row] = await db + for (let attempt = 0; attempt < 4; attempt++) { + const slug = await generateOrgSlug(row.name, slugTaken); + const [inserted] = await db .insert(organizations) - .values({ id, name, slug }) + .values({ ...row, slug }) .onConflictDoNothing() .returning(); - return row ?? null; - }; + if (inserted) return inserted; + // The insert was swallowed by a conflict. If the id now exists, a + // concurrent writer mirrored it — return that row. Otherwise the slug + // candidate collided; loop and mint a fresh one. + const held = await readOrganization(db, row.id); + if (held) return held; + } + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: slug minting exhausted retries; surfacing loudly beats a silently unslugged org + throw new Error(`unable to mint a slug for organization ${row.id}`); +}; - // Every new org row is born with a slug — there is no nullable window and no - // self-healing. Existing rows keep their slug (stable across renames, so org - // URLs survive) and only refresh their name. - const upsertOrganization = async (org: { id: string; name: string }) => { +export const makeUserStore = (db: DrizzleDb) => { + const getOrganization = (id: string) => readOrganization(db, id); + + // Existing rows keep their slug (stable across renames, so org URLs + // survive) and only refresh their name — and only from a payload at least + // as new as the one that last named it (`organizationAcceptsName`): a + // sign-in whose membership list was fetched before a rename would + // otherwise revert the rename after it landed. A row marked deleted is + // returned as it is: the organization is gone, and nothing a feeder still + // holds about it (a name, a membership fetched before the deletion) is + // written — never re-minted live, never renamed. A row the mirror does not + // hold is minted live (`insertOrganization`). + const upsertOrganization = async (org: OrganizationPayload) => { const existing = await getOrganization(org.id); if (existing) { + if (existing.deletedAt !== null) return existing; const [updated] = await db .update(organizations) - .set({ name: org.name }) - .where(eq(organizations.id, org.id)) + .set({ name: org.name, workosUpdatedAt: org.updatedAt }) + .where(and(eq(organizations.id, org.id), organizationAcceptsName(org.updatedAt))) .returning(); return updated ?? existing; } - for (let attempt = 0; attempt < 4; attempt++) { - const slug = await generateOrgSlug(org.name, slugTaken); - const inserted = await tryInsertOrg(org.id, org.name, slug); - if (inserted) return inserted; - // The insert was swallowed by a conflict. If the id now exists, a - // concurrent request mirrored it — return that row. Otherwise the slug - // candidate collided; loop and mint a fresh one. - const fresh = await getOrganization(org.id); - if (fresh) return fresh; - } - // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: slug minting exhausted retries; surfacing loudly beats a silently unslugged org - throw new Error(`unable to mint a slug for organization ${org.id}`); + return insertOrganization(db, { + id: org.id, + name: org.name, + workosUpdatedAt: org.updatedAt, + deletedAt: null, + }); }; return { @@ -98,9 +148,29 @@ export const makeUserStore = (db: DrizzleDb) => { return rows[0] ?? null; }, - // Permanently delete an org and everything it owns (tenant data, secrets, - // identity mirror + cascaded memberships) in a single transaction. Callers - // sequence the external WorkOS/Autumn deletions around this. - deleteOrganizationCascade: (id: string) => purgeOrganizationData(db, id), + // 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). + // Callers sequence the external WorkOS/Autumn deletions around this. + deleteOrganizationCascade: (id: string, deletedAt: Date) => + purgeOrganizationData(db, id, deletedAt), }; }; diff --git a/apps/cloud/src/auth/workos-auth-provider.ts b/apps/cloud/src/auth/workos-auth-provider.ts index 6abbffb7cb..7165a93042 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`) 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` + `WorkOsMirror` +// (read by the org-resolution path: the org row, 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,7 @@ import type { import { ApiKeyService } from "./api-keys"; import { workosApiJwtBearerConfig } from "./api-jwt-bearer"; +import { WorkOsMirror } from "./workos-mirror"; import { BEARER_PREFIX } from "./bearer"; import { authorizeOrganization, @@ -57,7 +64,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 +73,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 +125,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 +198,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 +207,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 | WorkOsMirror > => Effect.gen(function* () { const authHeader = request.headers.get("authorization"); @@ -229,6 +242,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 +293,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 | WorkOsMirror > => Effect.gen(function* () { const auth = yield* resolveBearerAuth(request, jwt); @@ -306,8 +328,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 +363,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 +375,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 | WorkOsMirror > => Effect.gen(function* () { const bearerPrincipal = yield* resolveApiKeyPrincipal(request, jwt); @@ -362,22 +392,24 @@ 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 | WorkOsMirror > = Layer.effect( IdentityProvider, Effect.gen(function* () { - const context = yield* Effect.context(); + const context = yield* Effect.context< + WorkOSClient | ApiKeyService | UserStoreService | MemberDirectory | WorkOsMirror + >(); return IdentityProvider.of({ authenticate: (request) => resolveProtectedPrincipal(request, workosApiJwtBearerConfig).pipe( @@ -390,6 +422,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: membership-mirror 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 4c1799428b..d7e56cd672 100644 --- a/apps/cloud/src/auth/workos-callback-state.node.test.ts +++ b/apps/cloud/src/auth/workos-callback-state.node.test.ts @@ -1,9 +1,8 @@ // --------------------------------------------------------------------------- // Focused tests — the WorkOS login callback's CSRF gate. // -// The callback's CSRF check must be unconditional: no state ⇒ 400 before any -// WorkOS call; a replayed (already consumed) state ⇒ 400; a fresh state -// matching the cookie ⇒ 302 + session. +// Codes without state restart login without a WorkOS exchange; a replayed +// state still fails with 400; a fresh state matching the cookie issues a session. // // Test seams follow repo conventions: @effect/vitest, Layer.succeed stubs // (see org-selector-auth.node.test.ts), and HttpRouter.toWebHandler for the @@ -17,10 +16,13 @@ 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"; import { WorkOSClient, type WorkOSClientService } from "./workos"; +import { WorkOsMirror } from "./workos-mirror"; import { encodeLoginState } from "./login-state"; import { AutumnService } from "../extensions/billing/service"; @@ -38,52 +40,117 @@ const stubWorkOS = Layer.succeed( new Proxy({} as WorkOSClientService, { get: (_t, prop) => { if (prop === "authenticateWithCode") { - return () => - Effect.succeed({ - user: { id: STUB_USER_ID, email: "u@test" }, - organizationId: STUB_ORG_ID, - sealedSession: STUB_SESSION, - }); + return (code: string) => + code === "unbound-code" + ? Effect.die("An unbound authorization code must never be exchanged") + : Effect.succeed({ + user: { id: STUB_USER_ID, email: "u@test" }, + organizationId: STUB_ORG_ID, + sealedSession: STUB_SESSION, + }); } if (prop === "listUserMemberships") { return () => Effect.succeed({ data: [] }); } - if (prop === "listOrgMembers") { - return () => Effect.succeed({ data: [{ status: "active" }] }); - } return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`); }, }), ); +// A bare account row, as `ensureAccount` mints it before any WorkOS profile +// has been mirrored onto it. +const bareAccount = (id: string) => ({ + id, + email: null, + firstName: null, + lastName: null, + avatarUrl: null, + workosUpdatedAt: null, + lastSignInAt: null, + createdAt: new Date(), +}); + const stubUsers = Layer.succeed(UserStoreService)({ use: (_op, fn) => Effect.promise(() => fn({ - ensureAccount: async (id: string) => ({ id, createdAt: new Date() }), - getAccount: async (id: string) => ({ id, createdAt: new Date() }), + ensureAccount: async (id: string) => bareAccount(id), + getAccount: async (id: string) => bareAccount(id), upsertOrganization: async (org: { id: string; name: string }) => ({ ...org, slug: org.id, + backfilledAt: null, + deletedAt: null, + workosUpdatedAt: null, createdAt: new Date(), }), getOrganization: async (id: string) => ({ id, name: "Org " + id, slug: id, + backfilledAt: null, + deletedAt: null, + workosUpdatedAt: null, createdAt: new Date(), }), getOrganizationBySlug: async (slug: string) => ({ id: slug, name: slug, slug, + backfilledAt: null, + deletedAt: null, + workosUpdatedAt: null, createdAt: new Date(), }), + markOrganizationDeleted: async () => null, deleteOrganizationCascade: async () => {}, }), ), }); +// The callback records the sign-in (user + memberships) in the membership +// 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), + deleteMembership: () => Effect.die("the callback does not delete memberships"), + deleteUser: () => Effect.die("the callback does not delete users"), + getCursor: () => Effect.die("the callback does not read the events cursor"), + applyPage: () => Effect.die("the callback does not move the events cursor"), + applyOrganizationScan: () => Effect.die("the callback does not run the backfill"), + replayBoundary: () => Effect.die("the callback does not run the reconciler"), + setReplayBoundary: () => Effect.die("the callback does not run the backfill"), + backfillCompletedAt: () => Effect.die("the callback does not check mirror readiness"), + 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.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 // middleware, which needs a live DB) is out of scope — the callback route lives // in CloudAuthPublicApi and requires no middleware. @@ -93,6 +160,8 @@ const App = HttpApiBuilder.layer(PublicApi).pipe( Layer.provide(CloudAuthPublicHandlers), Layer.provide(stubWorkOS), Layer.provide(stubUsers), + Layer.provide(stubMirror), + Layer.provide(stubDirectory), Layer.provide(AutumnService.Default), Layer.provide(HttpServer.layerServices), ); @@ -128,20 +197,30 @@ describe("workos callback · CSRF state hardening", () => { }); } - it("rejects a callback with NO state (the former bypass) before any WorkOS call", async () => { - const res = await run(new Request(callbackUrl(undefined), { redirect: "manual" })); - expect(res.status).toBe(400); - expect(await res.text()).toContain("Invalid login state"); + it("restarts login without exchanging a code that has no state", async () => { + const res = await run( + new Request(callbackUrl(undefined, "unbound-code"), { redirect: "manual" }), + ); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("/api/auth/login"); expect(res.headers.get("set-cookie") ?? "").not.toContain(SESSION_COOKIE); }); - it("rejects missing state even when the browser has a login cookie", async () => { + it("discards an existing login cookie when restarting a callback without state", async () => { const res = await run( - new Request(callbackUrl(undefined), { + new Request(callbackUrl(undefined, "unbound-code"), { headers: { cookie: `${STATE_COOKIE}=victim-login-state` }, redirect: "manual", }), ); + expect(res.status).toBe(302); + expect(res.headers.get("location")).toBe("/api/auth/login"); + expect(res.headers.get("set-cookie")).toContain(`${STATE_COOKIE}=; Max-Age=0`); + expect(res.headers.get("set-cookie") ?? "").not.toContain(SESSION_COOKIE); + }); + + it("rejects empty state instead of treating it as a provider-initiated login", async () => { + const res = await run(new Request(`${callbackUrl(undefined, "unbound-code")}&state=`)); expect(res.status).toBe(400); expect(await res.text()).toBe("Invalid login state"); expect(res.headers.get("set-cookie") ?? "").not.toContain(SESSION_COOKIE); diff --git a/apps/cloud/src/auth/workos-events-replay.ts b/apps/cloud/src/auth/workos-events-replay.ts new file mode 100644 index 0000000000..52032d7480 --- /dev/null +++ b/apps/cloud/src/auth/workos-events-replay.ts @@ -0,0 +1,556 @@ +// --------------------------------------------------------------------------- +// The membership mirror's RECONCILER, as a pure function over its ports: +// replays the WorkOS Events API into the mirror store so changes made +// outside Executor — a member removed in the WorkOS dashboard, a role edited +// there, a profile updated, an SSO just-in-time join — land in the mirror +// without anyone signing in. +// +// Kept free of `cloudflare:workers` (no `DbService`, no `env`, no +// `WorkOSClient`), like `workos-mirror-store.ts` and +// `workos-mirror-backfill.ts`, so the SAME replay runs in three places: the +// Worker's every-minute cron and the signed webhook poke +// (`workos-events-sync.ts` binds the ports to the request-scoped services), +// and the deploy gate (`scripts/ensure-workos-mirror-ready.ts`, through +// `scripts/drain-workos-events.ts`) that must bring the mirror up to date +// BEFORE the build that authorizes from it goes live — and cannot wait on a +// cron that may not be deployed yet. The ports are the WorkOS reads a replay +// makes (`WorkOsEventsSource`), the organization and account rows it +// consults (`WorkOsEventsStore`), and the mirror store it writes. +// +// The Events API is the ONLY source this applies. It is ordered and +// replayable from an event id, so the mirror persists the id of the last +// event it applied (`workos_sync.cursor`) and resumes from there; a webhook +// delivery only pokes a run (`workos-webhook.ts`), it is never applied +// itself, because a webhook is unordered and at-least-once. Two runs may +// overlap (the every-minute cron, a webhook poke, the deploy gate), so a +// page is applied and its cursor advanced in ONE transaction that +// compare-and-sets the cursor first (`WorkOsMirrorShape.applyPage`): the run +// that lost the stream writes nothing. The `updatedAt` guard on upserts is +// not enough on its own — a lagging run replaying `membership.updated` after +// the leading run applied that membership's `deleted` would re-insert the +// revoked row. There is no first-run history replay: the one-off backfill +// (`scripts/backfill-workos-mirror.ts`) covers history, and its first run +// records the instant it began reading WorkOS as the REPLAY BOUNDARY +// (`workos_sync.range_start`) before it lists anything. A run with no cursor +// reads the stream from that boundary — never from a wall-clock guess, which +// would silently drop every revocation older than the guess — and with no +// boundary either it does nothing but warn: the backfill has not run, and +// there is no honest place to start. The boundary never moves: a backfill +// retry or re-run refreshes memberships only, so the organization renames +// and user deletions after the first boundary are this stream's alone to +// apply. +// +// A deletion event tombstones its row as of the event's own `createdAt`, +// not the payload's `updatedAt` (which predates the delete): the tombstone +// must be newer than every payload a feeder could have fetched before the +// delete, so none of them can reinstate the row. +// +// A page is PLANNED before its transaction opens: every event becomes a +// mirror write, and that planning is where the only WorkOS reads happen — +// resolving an organization the mirror has never seen, and reading the +// profile of a member the mirror has never seen. A membership event carries +// no profile, and the `user.created` that would have carried it may predate +// the replay boundary: a user who existed before the mirror shipped and +// joins an organization the backfill has already scanned gets a bare +// account row from the membership write, and nothing in the stream would +// ever fill it — the member would be unsearchable by name or email until an +// unrelated profile update or sign-in. So a membership created or updated +// for an account the mirror holds no profile for (no row, or the bare row a +// membership write mints) is planned WITH the profile (`UpsertMember`), one +// `getUser` per such member, never per event. A deterministic answer to +// either read ("WorkOS no longer has this organization / user") does not +// fail the run — a failed run re-reads the same page from the same cursor +// next tick, so one such event would freeze the whole mirror, including +// revocations in every other org — but it is not dropped either: a gone +// organization MARKS the organization deleted (below), the same write its +// own `organization.deleted` further down the stream makes; a gone user is +// mirrored without a profile, and their own `user.deleted` follows. +// +// `organization.deleted` MARKS the organization deleted +// (`organizations.deleted_at`, the same mark cloud's own deletion flow sets +// and its purge keeps as a tombstone): the mirror is the membership read +// path, so an org deleted in the WorkOS dashboard must stop authorizing its +// members' sessions here, and this event is the only way that reaches the +// mirror. The mark is written HERE, in the first build that consumes the +// event, so no `organization.deleted` is ever drained from the stream +// without effect — an event consumed before the mark existed could never be +// replayed. For the same reason an organization the mirror has never seen +// gets a TOMBSTONE row minted: with no row, a login that fetched its +// memberships before the deletion and stalled would mint the organization +// live afterwards, and nothing left in the stream would ever revoke it. It +// never PURGES: deleting tenant data and secrets is cloud's own flow +// (`db/org-deletion.ts`), sequenced with billing and confirmed by an admin, +// and an event must not do it. An org already marked (by cloud's own flow, +// or a replay) is `absent` and nothing changes. `organization.updated` renames an +// organization the mirror already holds — never inserts one, so a rename +// replayed after cloud purged the org cannot resurrect it with a fresh slug +// — under the same name guard every feeder applies, so a rename event and +// a sign-in's name order each other by their stamps however they arrive. +// --------------------------------------------------------------------------- + +import { Clock, Effect, Match, Option } from "effect"; +import type { Event as WorkOSEvent } from "@workos-inc/node/worker"; + +import type { Account, Organization, OrganizationPayload } from "./user-store"; +import type { WorkOSListEventsOptions } from "./workos"; +import { + WorkOsMirrorWrite, + mirrorMembershipFromWorkOs, + mirrorUserFromWorkOs, + type WorkOsMembershipPayload, + type WorkOsMirrorShape, + type WorkOsMirrorUser, + type WorkOsMirrorWriteOutcome, + type WorkOsUserPayload, +} from "./workos-mirror-store"; + +/** + * The event types the mirror follows. Invitations are not mirrored (they + * stay a live WorkOS read), and `organization.created` is not needed: an org + * is mirrored lazily the first time a membership or a session names it. + */ +export const MIRRORED_EVENT_NAMES = [ + "user.created", + "user.updated", + "user.deleted", + "organization_membership.created", + "organization_membership.updated", + "organization_membership.deleted", + "organization.updated", + "organization.deleted", +] as const; + +export type WorkOsMirroredEventName = (typeof MIRRORED_EVENT_NAMES)[number]; + +/** The SDK events the reconciler applies, narrowed to the followed types. */ +export type WorkOsMirroredEvent = Extract; + +const mirroredEventNames: ReadonlySet = new Set(MIRRORED_EVENT_NAMES); + +/** Whether an event from the stream is one the mirror follows. */ +export const isMirroredEvent = (event: WorkOSEvent): event is WorkOsMirroredEvent => + mirroredEventNames.has(event.event); + +/** + * What one event did to the mirror: + * - `applied`: a row was written, marked, or tombstoned; + * - `stale`: the `updatedAt` guard refused an older payload (a replay or a + * late event behind a fresher write); + * - `absent`: a delete found its row already tombstoned or superseded by a + * newer membership (a replayed delete), a rename found no live + * organization row — the mirror has never seen it, or it is marked + * deleted — or a deletion mark found the organization already marked. + */ +export type WorkOsEventOutcome = WorkOsMirrorWriteOutcome; + +/** One page of the Events API stream, as the source hands it to the replay. */ +export interface WorkOsEventsPage { + readonly data: readonly WorkOSEvent[]; + /** The id to resume after, or `null` at the end of the stream. */ + readonly after: string | null; +} + +/** The WorkOS organization fields the replay reads to mint an org row. */ +export interface WorkOsOrganizationPayload { + readonly id: string; + readonly name: string; + readonly updatedAt: string; +} + +/** + * The WorkOS reads one replay makes, over whatever client the caller wires: + * the Events API page, and — only for a membership event whose organization + * or member the mirror has never seen — the organization or user resource. + * The two lookups answer `None` when WorkOS no longer has the resource (a + * 404): that is a deterministic answer the replay acts on, not a failure. + * Every other failure (401/403, 429, 5xx, no answer) is `E` and fails the + * run, so the event is retried once the cause is fixed rather than skipped. + */ +export interface WorkOsEventsSource { + readonly listEvents: (options: WorkOSListEventsOptions) => Effect.Effect; + readonly getOrganization: ( + organizationId: string, + ) => Effect.Effect, E>; + readonly getUser: (userId: string) => Effect.Effect, E>; +} + +/** + * The organization and account rows a replay consults while planning a + * page: the org row a membership's foreign key needs (minted from WorkOS + * through `upsertOrganization` when the mirror has never seen it — the one + * slug mint point) and the account row that says whether the member's + * profile is already held. + */ +export interface WorkOsEventsStore { + readonly getOrganization: (organizationId: string) => Effect.Effect; + readonly upsertOrganization: ( + organization: OrganizationPayload, + ) => Effect.Effect; + readonly getAccount: (accountId: string) => Effect.Effect; +} + +/** Everything one replay reads and writes. */ +export interface WorkOsEventsReplayDeps { + readonly source: WorkOsEventsSource; + readonly store: WorkOsEventsStore; + readonly mirror: WorkOsMirrorShape; +} + +/** A membership event's payload: the SDK's `OrganizationMembership`, which names its organization. */ +interface WorkOsMembershipEventPayload extends WorkOsMembershipPayload { + readonly organizationName: string; +} + +// The organization row a membership event needs: the mirror's, or — for an +// org the mirror has never seen (created and populated in the WorkOS +// dashboard before anyone signed in) — minted from the WorkOS organization +// so the membership's foreign key holds. `None` when WorkOS no longer has +// the organization. +const resolveOrganization = ( + deps: WorkOsEventsReplayDeps, + organizationId: string, +): Effect.Effect, E> => + Effect.gen(function* () { + const existing = yield* deps.store.getOrganization(organizationId); + if (existing) return Option.some(existing); + const fresh = yield* deps.source.getOrganization(organizationId); + if (Option.isNone(fresh)) return Option.none(); + const minted = yield* deps.store.upsertOrganization({ + id: fresh.value.id, + name: fresh.value.name, + updatedAt: new Date(fresh.value.updatedAt), + }); + return Option.some(minted); + }); + +// A membership event carries only the organization's id, so an org the +// mirror has never seen is mirrored first (`resolveOrganization`) so the +// membership's foreign key holds. That goes for a DELETE too: it leaves a +// tombstone behind even when the mirror has never seen the membership (so +// the backfill's older payload cannot insert it live), and the tombstone row +// needs the org as much as a live one. An org WorkOS no longer has (deleted +// there, or through Executor, after this event was emitted) is MARKED +// deleted instead — minting its tombstone row when the mirror has never +// seen it — so a login still holding a membership of it cannot mint it +// live: its own `organization.deleted` follows in the stream and finds the +// mark already there, and the membership itself is not written, there is +// nothing live to hold it. +const planMembershipWrite = ( + deps: WorkOsEventsReplayDeps, + membership: WorkOsMembershipEventPayload, + event: { readonly id: string; readonly createdAt: string }, + write: () => Effect.Effect, +): Effect.Effect => + Effect.gen(function* () { + const organization = yield* resolveOrganization(deps, membership.organizationId); + if (Option.isNone(organization)) { + yield* Effect.logWarning( + "workos_events: membership for an organization WorkOS no longer has; marking the org deleted instead", + { organizationId: membership.organizationId, eventId: event.id }, + ); + return WorkOsMirrorWrite.MarkOrganizationDeleted({ + organizationId: membership.organizationId, + name: membership.organizationName, + deletedAt: new Date(event.createdAt), + }); + } + return yield* write(); + }); + +// Whether the mirror holds a profile for the account: none at all, or only +// the bare row a membership write mints (`ensureAccount`: no email, no +// stamp), calls for a WorkOS read. A deletion tombstone (no email, stamped +// by `deleteUser`) does not: WorkOS never reuses a user id, and the +// membership write refuses the account anyway. +const holdsNoProfile = (account: Account | null): boolean => + account === null || (account.email === null && account.workosUpdatedAt === null); + +/** + * The user ids whose profile an earlier event of the SAME page has already + * planned a write for (`user.created` / `user.updated`, or a profile read + * for a membership). A page is planned in full before it is applied, so the + * mirror does not yet hold what the page's own earlier events carry; this + * is what keeps a `user.created` followed by that user's membership in one + * page from reading the profile WorkOS just streamed. + */ +export type PlannedProfiles = Set; + +// The member's profile from WorkOS, when neither the mirror nor an earlier +// event of the page holds one (see the header); `null` when one does, or +// when WorkOS no longer has the user (the user's own `user.deleted` follows +// in the stream, or has been applied). +const planMemberProfile = ( + deps: WorkOsEventsReplayDeps, + userId: string, + event: { readonly id: string }, + profiled: PlannedProfiles, +): Effect.Effect => + Effect.gen(function* () { + if (profiled.has(userId)) return null; + const account = yield* deps.store.getAccount(userId); + if (!holdsNoProfile(account)) return null; + const user = yield* deps.source.getUser(userId); + if (Option.isNone(user)) { + yield* Effect.logWarning( + "workos_events: membership for a user WorkOS no longer has; mirrored without a profile", + { eventId: event.id }, + ); + return null; + } + profiled.add(userId); + return mirrorUserFromWorkOs(user.value); + }); + +const planMembershipUpsert = ( + deps: WorkOsEventsReplayDeps, + membership: WorkOsMembershipEventPayload, + event: { readonly id: string; readonly createdAt: string }, + profiled: PlannedProfiles, +) => + planMembershipWrite(deps, membership, event, () => + Effect.map(planMemberProfile(deps, membership.userId, event, profiled), (user) => + user === null + ? WorkOsMirrorWrite.UpsertMembership({ + membership: mirrorMembershipFromWorkOs(membership), + }) + : WorkOsMirrorWrite.UpsertMember({ + user, + membership: mirrorMembershipFromWorkOs(membership), + }), + ), + ); + +/** + * Translate one event into the mirror write it calls for. Every followed + * event yields a write: none is drained from the stream without effect. + * This is the only step that may read WorkOS (an organization the mirror + * has never seen, a member it holds no profile for); it runs before the + * page's transaction opens. Fails on a store failure or a WorkOS failure + * that a retry could clear — the run stops before the page is applied, so + * the event is retried next run. `profiled` is the page's running set of + * users whose profile is already planned (see {@link PlannedProfiles}); one + * set per page. + */ +export const planWorkOsEvent = ( + deps: WorkOsEventsReplayDeps, + event: WorkOsMirroredEvent, + profiled: PlannedProfiles = new Set(), +): Effect.Effect => { + const userWrite = (data: WorkOsUserPayload) => + Effect.sync(() => { + profiled.add(data.id); + return WorkOsMirrorWrite.UpsertUser({ user: mirrorUserFromWorkOs(data) }); + }); + return Match.value(event).pipe( + Match.discriminatorsExhaustive("event")({ + "user.created": ({ data }) => userWrite(data), + "user.updated": ({ data }) => userWrite(data), + "user.deleted": ({ data }) => + Effect.succeed( + WorkOsMirrorWrite.DeleteUser({ + accountId: data.id, + deletedAt: new Date(event.createdAt), + }), + ), + "organization_membership.created": ({ data }) => + planMembershipUpsert(deps, data, event, profiled), + "organization_membership.updated": ({ data }) => + planMembershipUpsert(deps, data, event, profiled), + "organization_membership.deleted": ({ data }) => + planMembershipWrite(deps, data, event, () => + Effect.succeed( + WorkOsMirrorWrite.DeleteMembership({ + membership: { + id: data.id, + accountId: data.userId, + organizationId: data.organizationId, + }, + deletedAt: new Date(event.createdAt), + }), + ), + ), + "organization.updated": ({ data }) => + Effect.succeed( + WorkOsMirrorWrite.RenameOrganization({ + organizationId: data.id, + name: data.name, + updatedAt: new Date(data.updatedAt), + }), + ), + "organization.deleted": ({ data }) => + Effect.logWarning( + "workos_events: organization.deleted received; marking the org deleted locally — tenant data is kept (purging is cloud's own flow, db/org-deletion.ts)", + { organizationId: data.id, eventId: event.id }, + ).pipe( + Effect.as( + WorkOsMirrorWrite.MarkOrganizationDeleted({ + organizationId: data.id, + name: data.name, + deletedAt: new Date(event.createdAt), + }), + ), + ), + }), + ); +}; + +// One page is one WorkOS read and one cursor advance. 100 is the API's +// maximum; the page budget bounds a single run (a backlog after an outage +// drains over successive runs, each committing what it applied) so a cron +// invocation stays well inside the Worker's wall-clock limits. +const PAGE_SIZE = 100; +const MAX_PAGES_PER_RUN = 20; + +export interface WorkOsEventsSyncReport { + readonly pages: number; + readonly events: number; + readonly applied: number; + readonly stale: number; + readonly absent: number; + /** + * Why the run ended: the stream was read to its end (`drained`), another + * run moved the cursor first (`cursor_contended`), the page budget for one + * run was spent with more to read (`page_budget`), or there is neither a + * cursor nor a replay boundary to start from — the backfill has not run — + * so nothing was read (`awaiting_backfill`). + */ + readonly stopped: "drained" | "cursor_contended" | "page_budget" | "awaiting_backfill"; + /** The cursor this run left behind (the last event id it committed). */ + readonly cursor: string | null; +} + +/** + * One reconciler run: read the cursor (or, before the first page was ever + * committed, the backfill's replay boundary), page the Events API from it + * (oldest first), plan every event, and apply each page with its cursor + * advance in one transaction. Stops as soon as that transaction finds the + * cursor moved — another run owns the stream, and nothing from the page was + * written — and fails (before the page is applied) on the first source, + * store, or mirror failure, so nothing is skipped: the next run resumes + * from the last committed page. With neither cursor nor boundary it reads + * nothing and reports `awaiting_backfill`. A run that reads the stream to + * its end records the drain (`markDrained`) as of its own start. + */ +export const replayWorkOsEvents = (deps: WorkOsEventsReplayDeps) => + Effect.gen(function* () { + const { source, mirror } = deps; + + // Taken before the first read, so the drained mark below cannot + // post-date an event this run never saw. + const startedAt = new Date(yield* Clock.currentTimeMillis); + let cursor = yield* mirror.getCursor(); + const counts = { + pages: 0, + events: 0, + applied: 0, + stale: 0, + absent: 0, + }; + let stopped: WorkOsEventsSyncReport["stopped"] = "page_budget"; + + // Where the next page starts: after the last committed event id, or — + // for the very first read, which has no id to resume from — at the + // backfill's replay boundary, the only instant known to be covered. + let resume: { readonly after: string } | { readonly rangeStart: string }; + if (cursor === null) { + const boundary = yield* mirror.replayBoundary(); + if (boundary === null) { + yield* Effect.logWarning( + "workos_events: no cursor and no replay boundary — the mirror backfill has not run (db:backfill-workos-mirror:prod); nothing read", + ); + const report: WorkOsEventsSyncReport = { + ...counts, + stopped: "awaiting_backfill", + cursor, + }; + return report; + } + resume = { rangeStart: boundary.toISOString() }; + } else { + resume = { after: cursor }; + } + + while (counts.pages < MAX_PAGES_PER_RUN) { + const page = yield* source.listEvents({ + events: MIRRORED_EVENT_NAMES, + limit: PAGE_SIZE, + order: "asc", + ...resume, + }); + counts.pages += 1; + if (page.data.length === 0) { + stopped = "drained"; + break; + } + + // Plan first (the WorkOS reads), then apply under the cursor lock. + let lastEventId = cursor; + const profiled: PlannedProfiles = new Set(); + const planned: { + readonly event: WorkOsMirroredEvent; + readonly write: WorkOsMirrorWrite; + }[] = []; + for (const event of page.data) { + counts.events += 1; + lastEventId = event.id; + if (!isMirroredEvent(event)) { + // The request named the followed types; anything else is a WorkOS + // change of contract worth seeing, not a reason to stop the stream. + yield* Effect.logWarning("workos_events: unrequested event type skipped", { + event: event.event, + eventId: event.id, + }); + continue; + } + planned.push({ event, write: yield* planWorkOsEvent(deps, event, profiled) }); + } + + // `lastEventId` is an event id here: the page was non-empty. + if (lastEventId === null) break; + const outcomes = yield* mirror.applyPage( + cursor, + lastEventId, + planned.map((p) => p.write), + ); + if (Option.isNone(outcomes)) { + yield* Effect.logWarning("workos_events: cursor moved by another run; stopping", { + expected: cursor, + }); + stopped = "cursor_contended"; + break; + } + for (const [index, outcome] of outcomes.value.entries()) { + counts[outcome] += 1; + if (outcome === "absent") { + // Normal for a replayed delete; for a rename it means the org was + // never mirrored or is marked deleted, and for a deletion mark + // that it is already marked — either way nothing to do. + yield* Effect.logInfo("workos_events: event targets a row the mirror does not hold", { + event: planned[index]?.event.event, + eventId: planned[index]?.event.id, + }); + } + } + cursor = lastEventId; + resume = { after: cursor }; + if (page.after === null) { + stopped = "drained"; + break; + } + } + + if (stopped === "drained") { + // The stream was read to its end: everything WorkOS had emitted by + // the time this run began is now in the mirror. Recorded as of the + // run's START, not its end — an event emitted while the run was + // reading may still be ahead of the last page it saw — so the mark + // never claims more than was covered. This is what the authorization + // path reads to tell a caught-up mirror from one whose reconciler has + // stalled. + yield* mirror.markDrained(startedAt); + } + + const report: WorkOsEventsSyncReport = { ...counts, stopped, cursor }; + yield* Effect.logInfo("workos_events: sync run finished", report); + return report; + }).pipe(Effect.withSpan("workos_events.replay")); diff --git a/apps/cloud/src/auth/workos-events-runner.node.test.ts b/apps/cloud/src/auth/workos-events-runner.node.test.ts new file mode 100644 index 0000000000..66d53d3340 --- /dev/null +++ b/apps/cloud/src/auth/workos-events-runner.node.test.ts @@ -0,0 +1,81 @@ +// --------------------------------------------------------------------------- +// A reconciler run that does not end `"drained"` can still leave the mirror +// fresh (another run drained it moments ago) or leave it stale (nothing has +// drained inside the lag budget). `alertOnStaleReconciler` is the ONLY place +// that distinction is now reported — the request path no longer reads +// readiness at all — so this pins both branches directly against it: a +// fresh `drainedAt` logs nothing, and a stale or absent `drainedAt` logs a +// structured error. `captureCauseEffect` is not swapped out: it calls +// `Sentry.captureException` directly, is a no-op in this uninitialized test +// environment, and its call path is exercised for real rather than mocked. +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Cause, Duration, Effect, Layer, Logger } from "effect"; + +import { WorkOsMirror, type WorkOsMirrorShape } from "./workos-mirror"; +import { alertOnStaleReconciler } from "./workos-events-runner"; +import type { WorkOsEventsSyncReport } from "./workos-events-sync"; + +const capturingLogger = (sink: Array) => + Logger.make((options) => { + sink.push(String(options.message)); + sink.push(Cause.pretty(options.cause)); + }); + +const reportEndingWith = (stopped: WorkOsEventsSyncReport["stopped"]): WorkOsEventsSyncReport => ({ + pages: 1, + events: 0, + applied: 0, + stale: 0, + absent: 0, + stopped, + cursor: null, +}); + +const stubMirror = (drainedAt: Date | null) => + Layer.succeed( + WorkOsMirror, + new Proxy({} as WorkOsMirrorShape, { + get: (_target, prop) => { + if (prop === "drainedAt") return () => Effect.succeed(drainedAt); + return () => Effect.die(`unexpected WorkOsMirror.${String(prop)} call`); + }, + }), + ); + +const run = (report: WorkOsEventsSyncReport, drainedAt: Date | null) => { + const logged: string[] = []; + return Effect.runPromise( + alertOnStaleReconciler(report).pipe( + Effect.provide(stubMirror(drainedAt)), + Effect.provide(Logger.layer([capturingLogger(logged)])), + ), + ).then(() => logged); +}; + +describe("alertOnStaleReconciler", () => { + it("does not alert when the run itself drained the stream", async () => { + // A `"drained"` run just called `markDrained`, so it is healthy by + // definition and `drainedAt` is never read. + const logged = await run(reportEndingWith("drained"), null); + expect(logged).toEqual([]); + }); + + it("does not alert when the mirror drained inside the lag budget", async () => { + const fresh = new Date(Date.now() - Duration.toMillis(Duration.minutes(1))); + const logged = await run(reportEndingWith("page_budget"), fresh); + expect(logged).toEqual([]); + }); + + it("alerts when the mirror has not drained inside the lag budget", async () => { + const stale = new Date(Date.now() - Duration.toMillis(Duration.minutes(11))); + const logged = await run(reportEndingWith("page_budget"), stale); + expect(logged.some((line) => line.includes("workos_events: reconciler stale"))).toBe(true); + }); + + it("alerts when the mirror has never drained", () => + run(reportEndingWith("awaiting_backfill"), null).then((logged) => { + expect(logged.some((line) => line.includes("workos_events: reconciler stale"))).toBe(true); + })); +}); diff --git a/apps/cloud/src/auth/workos-events-runner.ts b/apps/cloud/src/auth/workos-events-runner.ts new file mode 100644 index 0000000000..e5979e48b4 --- /dev/null +++ b/apps/cloud/src/auth/workos-events-runner.ts @@ -0,0 +1,112 @@ +// --------------------------------------------------------------------------- +// Runs one reconciler pass (`syncWorkOsEvents`) from a Worker entry that is +// not an HTTP request handled by the Effect app: the every-minute cron +// (`scheduled` in server.ts) and the webhook poke (`workos-webhook.ts`, +// detached past the response with `waitUntil`). +// +// Both entries build the request-scoped services FRESH for the run — the +// same reason `mcp/auth.ts` does: a postgres socket belongs to one Workers +// invocation, and the webhook route's own per-request layer is closed the +// moment its response is returned, so a detached run cannot borrow it. The +// run is its own scope; the socket is released when it ends. +// +// A failing run is captured (Sentry + structured log) and swallowed here: +// neither entry has a caller to report to, and the run is retried by the +// next cron tick from the last committed cursor. +// +// A run that does not fail outright can still leave the mirror stale: one +// that stops short of `"drained"` (page budget, cursor contention) means the +// reconciler did not catch up this tick, and one stuck on +// `"awaiting_backfill"` past the budget means the boundary row the backfill +// was supposed to hand off was lost — both are read from `drainedAt` after +// the run and, past `MIRROR_RECONCILER_LAG_BUDGET`, reported the same way a +// thrown failure is: a structured error log plus a Sentry capture. This is +// the ONLY place a stale reconciler surfaces now; the request path +// (`auth/organization.ts`) no longer checks readiness or falls back. +// --------------------------------------------------------------------------- + +import { Clock, Data, Duration, Effect, Layer } from "effect"; + +import { captureCauseEffect } from "../observability"; +import { WorkerTelemetryLive } from "../observability/telemetry"; +import { makeDbLayer } from "../db/db"; +import { makeUserStoreLayer } from "./context"; +import { MIRROR_RECONCILER_LAG_BUDGET } from "./mirror-readiness-store"; +import { CoreSharedServices } from "./workos"; +import { syncWorkOsEvents, type WorkOsEventsSyncReport } from "./workos-events-sync"; +import { makeWorkOsMirrorLayer, WorkOsMirror } from "./workos-mirror"; + +const makeSyncServices = () => { + const dbLive = makeDbLayer(); + return Layer.mergeAll( + makeUserStoreLayer().pipe(Layer.provide(dbLive)), + makeWorkOsMirrorLayer().pipe(Layer.provide(dbLive)), + CoreSharedServices, + ); +}; + +const LAG_BUDGET_MS = Duration.toMillis(MIRROR_RECONCILER_LAG_BUDGET); + +/** + * The mirror has not drained the WorkOS events stream inside its lag budget. + * Its own tagged error so Sentry groups every occurrence under one issue, + * with the run's outcome and the heartbeat's age as the fields to read. + */ +export class WorkOsReconcilerStale extends Data.TaggedError("WorkOsReconcilerStale")<{ + readonly stopped: WorkOsEventsSyncReport["stopped"]; + readonly drainedAt: string | null; + readonly ageMs: number | null; +}> {} + +/** + * After a run that did not end `"drained"`, check whether the mirror has + * fallen behind its lag budget and, if so, report it the way a failed run is + * reported: a structured error log plus a Sentry capture. A run ending + * `"drained"` just wrote `markDrained` and is healthy by definition, so it is + * never checked. Cheap by design: one `drainedAt` read per run (about once a + * minute). + */ +export const alertOnStaleReconciler = Effect.fn("workos_events.alert_on_stale_reconciler")( + function* (report: WorkOsEventsSyncReport) { + if (report.stopped === "drained") return; + + const mirror = yield* WorkOsMirror; + const drainedAt = yield* mirror.drainedAt(); + const now = yield* Clock.currentTimeMillis; + const ageMs = drainedAt === null ? null : now - drainedAt.getTime(); + if (ageMs !== null && ageMs <= LAG_BUDGET_MS) return; + + const stale = new WorkOsReconcilerStale({ + stopped: report.stopped, + drainedAt: drainedAt === null ? null : drainedAt.toISOString(), + ageMs, + }); + yield* Effect.logError("workos_events: reconciler stale", stale); + yield* captureCauseEffect(stale); + }, +); + +/** + * One reconciler pass over fresh request-scoped services. Resolves when the + * pass ends, whether it drained the stream, stopped at the page budget, + * yielded to another run, or failed (a failure is reported, never thrown). + * A run that ends short of `"drained"` and leaves `drainedAt` past the lag + * budget is ALSO reported (see {@link alertOnStaleReconciler}) — a stalled + * reconciler is now an operational alert, not a per-request fallback. + */ +export const runWorkOsEventsSync = (): Promise => + Effect.runPromise( + syncWorkOsEvents().pipe( + Effect.tap((report) => alertOnStaleReconciler(report)), + Effect.asVoid, + Effect.provide(makeSyncServices()), + Effect.scoped, + Effect.catchCause((cause) => + Effect.gen(function* () { + yield* Effect.logError("workos_events: sync run failed", cause); + yield* captureCauseEffect(cause); + }), + ), + Effect.provide(WorkerTelemetryLive), + ), + ); diff --git a/apps/cloud/src/auth/workos-events-sync.node.test.ts b/apps/cloud/src/auth/workos-events-sync.node.test.ts new file mode 100644 index 0000000000..aff07942de --- /dev/null +++ b/apps/cloud/src/auth/workos-events-sync.node.test.ts @@ -0,0 +1,1313 @@ +// --------------------------------------------------------------------------- +// The membership mirror's RECONCILER (`workos-events-sync.ts`) and the +// webhook that pokes it (`workos-webhook.ts`), against the real PGlite +// Postgres every cloud unit test runs on (scripts/test-globalsetup.ts). +// WorkOS is a fake `WorkOSClient` for the Events API (the emulator has no +// events route); the signature check runs the REAL client's verifier over a +// locally computed HMAC, because that check is the webhook's only +// authentication. +// +// What this pins: +// - every followed event type lands in the mirror: user created/updated/ +// 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 (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 +// deletion cannot mint the org or the membership afterwards +// - a delete TOMBSTONES its row as of the event's `createdAt`, so an older +// payload replayed after it is `stale` and the row stays inactive — even +// when the delete arrives before the mirror has ever seen the membership +// (the reconciler ahead of the backfill): the tombstone is minted, with +// its organization, so the backfill cannot insert the row live +// - a membership created or updated for a member the mirror holds no +// profile for reads the profile from WorkOS (one `getUser`, only then), +// so a pre-boundary user who joins a scanned org is searchable by name +// and email; a member WorkOS no longer has is mirrored bare, and a +// transient failure fails the run +// - a membership for an organization the mirror has never seen mirrors +// the org first (one WorkOS read), so the foreign key holds; one whose +// org WorkOS no longer has marks the org deleted instead (minting the +// tombstone) and the cursor still advances, while a transient WorkOS +// failure still fails the run +// - `organization.updated` never inserts an org the mirror does not hold +// - a run with no cursor reads from the backfill's replay boundary, and +// with no boundary either reads nothing (the backfill has not run) +// - a run pages from the persisted cursor, commits after every page, and +// STOPS when another run moves the cursor under it — with NOTHING from +// the contended page written (a lagging run cannot resurrect a +// membership the leading run already deleted) +// - a run that reads the stream to its end records the drain as of its +// start (the authorization path's caught-up check); a run that read +// nothing, or yielded the stream, records none +// - the webhook accepts only a genuinely signed delivery, never applies +// it, and refuses everything when no signing secret is configured +// --------------------------------------------------------------------------- + +import { createHmac } from "node:crypto"; + +import { describe, expect, it } from "@effect/vitest"; +import { sql } from "drizzle-orm"; +import { Effect, Exit, Layer, Option } from "effect"; +import { HttpRouter, HttpServer } from "effect/unstable/http"; +import type { Organization, OrganizationMembership, User } from "@workos-inc/node/worker"; + +import { MemberDirectory, type MemberStatus } from "@executor-js/api/server"; + +import { DbService } from "../db/db"; +import { UserStoreService } from "./context"; +import { WorkOSError } from "./errors"; +import { cloudMemberDirectoryLayer } from "./member-directory"; +import { mirrorSignIn } from "./mirror-feeders"; +import { authorizeOrganization } from "./organization"; +import { WorkOSClient, type WorkOSClientService, type WorkOSListEventsOptions } from "./workos"; +import { + planEvent, + syncWorkOsEvents, + type WorkOsEventOutcome, + type WorkOsEventsSyncReport, + type WorkOsMirroredEvent, +} from "./workos-events-sync"; +import { WorkOsMirror, WorkOsMirrorWrite, mirrorMembershipFromWorkOs } from "./workos-mirror"; +import { WORKOS_WEBHOOK_PATH, makeWorkOsWebhookRoute } from "./workos-webhook"; + +const T1 = "2026-01-01T00:00:00.000Z"; +const T2 = "2026-01-02T00:00:00.000Z"; +const T3 = "2026-01-03T00:00:00.000Z"; + +// Synthetic identities only; every test mints its own ids so the shared test +// database never couples two tests. +const freshId = (prefix: string) => `${prefix}_${crypto.randomUUID().replaceAll("-", "")}`; + +const workosUser = (id: string, overrides: Partial = {}): User => ({ + object: "user", + id, + email: `${id}@placeholder.test`, + emailVerified: true, + firstName: "Ada", + lastName: "Placeholder", + profilePictureUrl: null, + lastSignInAt: T1, + locale: null, + createdAt: T1, + updatedAt: T1, + externalId: null, + metadata: {}, + ...overrides, +}); + +const workosMembership = ( + userId: string, + organizationId: string, + overrides: Partial = {}, +): OrganizationMembership => ({ + object: "organization_membership", + id: `om_${userId}_${organizationId}`, + userId, + organizationId, + organizationName: `Org ${organizationId}`, + status: "active", + directoryManaged: false, + createdAt: T1, + updatedAt: T1, + customAttributes: {}, + role: { slug: "member" }, + ...overrides, +}); + +const workosOrganization = (id: string, name: string, updatedAt = T1): Organization => ({ + object: "organization", + id, + name, + allowProfilesOutsideOrganization: false, + domains: [], + createdAt: T1, + updatedAt, + externalId: null, + metadata: {}, +}); + +const userEvent = ( + event: "user.created" | "user.updated" | "user.deleted", + data: User, + id = freshId("event"), + createdAt = T1, +): WorkOsMirroredEvent => ({ + id, + event, + data, + createdAt, + context: undefined, +}); + +const membershipEvent = ( + event: + | "organization_membership.created" + | "organization_membership.updated" + | "organization_membership.deleted", + data: OrganizationMembership, + id = freshId("event"), + createdAt = T1, +): WorkOsMirroredEvent => ({ + id, + event, + data, + createdAt, + context: undefined, +}); + +const organizationEvent = ( + event: "organization.updated" | "organization.deleted", + data: Organization, + id = freshId("event"), + createdAt = T1, +): WorkOsMirroredEvent => ({ + id, + event, + data, + createdAt, + context: undefined, +}); + +/** + * A `WorkOSClient` whose every method is one of `methods`; anything else is + * an unexpected call and dies, so a reconciler that silently adds a WorkOS + * read fails the test instead of passing on a fake. + */ +const stubWorkOS = (methods: Partial) => + Layer.succeed( + WorkOSClient, + new Proxy({} as WorkOSClientService, { + get: (_target, prop) => + (methods as Record)[prop] ?? + (() => Effect.die(`unexpected WorkOSClient.${String(prop)} call`)), + }), + ); + +/** + * `getUser` for every member a test's membership events name: the + * reconciler reads a profile for a member the mirror holds none for, and + * the strict stub above would die on it. Records each read in `reads`. + */ +const profiles = (reads: string[] = []): Partial => ({ + getUser: (userId) => + Effect.sync(() => { + reads.push(userId); + return workosUser(userId); + }), +}); + +const DbLive = DbService.Live; +// The authorization checks below always read the mirror, never WorkOS. +const MirrorServices = Layer.mergeAll( + WorkOsMirror.Live, + UserStoreService.Live, + cloudMemberDirectoryLayer, +).pipe(Layer.provideMerge(DbLive)); + +type Services = WorkOsMirror | UserStoreService | MemberDirectory | DbService | WorkOSClient; + +const run = ( + body: Effect.Effect, + workos: Layer.Layer = stubWorkOS({}), +) => + Effect.runPromise( + body.pipe(Effect.provide(Layer.mergeAll(MirrorServices, workos)), Effect.scoped), + ); + +const seedOrganization = (id: string) => + Effect.flatMap(UserStoreService.asEffect(), (users) => + users.use("upsertOrganization", (s) => + s.upsertOrganization({ id, name: `Org ${id}`, updatedAt: new Date(T1) }), + ), + ); + +const readOrganization = (id: string) => + Effect.flatMap(UserStoreService.asEffect(), (users) => + users.use("getOrganization", (s) => s.getOrganization(id)), + ); + +const readMembership = ( + accountId: string, + organizationId: string, + statuses?: readonly MemberStatus[], +) => + Effect.flatMap(MemberDirectory.asEffect(), (directory) => + directory.membership(accountId, organizationId, statuses), + ); + +/** + * Apply one event the way a run does — plan it, then apply it as a one-event + * page under the cursor CAS — and report the event's outcome. The cursor is + * instance-wide; each apply moves it to a fresh id, which is what a run does. + */ +const applyEvent = (event: WorkOsMirroredEvent) => + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const write = yield* planEvent(event); + const prev = yield* mirror.getCursor(); + const outcomes = yield* mirror.applyPage(prev, freshId("event"), [write]); + expect(Option.isSome(outcomes), "no other run contends in a single-event apply").toBe(true); + const outcome: WorkOsEventOutcome | undefined = Option.getOrElse(outcomes, () => [])[0]; + expect(outcome, "one write, one outcome").toBeDefined(); + return outcome ?? "absent"; + }); + +describe("applyEvent", () => { + it("mirrors a user, refreshes it, refuses an older update, and deletes it", async () => { + const org = freshId("org"); + const userId = freshId("user"); + const result = await run( + Effect.gen(function* () { + yield* seedOrganization(org); + yield* applyEvent( + membershipEvent("organization_membership.created", workosMembership(userId, org)), + ); + + const created = yield* applyEvent( + userEvent("user.created", workosUser(userId, { firstName: "Grace", updatedAt: T2 })), + ); + const afterCreate = yield* readMembership(userId, org); + const updated = yield* applyEvent( + userEvent("user.updated", workosUser(userId, { firstName: "Newer", updatedAt: T3 })), + ); + const afterUpdate = yield* readMembership(userId, org); + const stale = yield* applyEvent( + userEvent("user.updated", workosUser(userId, { firstName: "Stale", updatedAt: T1 })), + ); + const afterStale = yield* readMembership(userId, org); + // The delete is stamped with the EVENT's time (T3), after every + // payload above; the SDK payload's own `updatedAt` predates it. + const deleted = yield* applyEvent( + userEvent("user.deleted", workosUser(userId), freshId("event"), T3), + ); + const afterDelete = yield* readMembership(userId, org); + const tombstoned = yield* readMembership(userId, org, ["inactive"]); + // A profile update that happened before the delete but lands after it. + const lateUpdate = yield* applyEvent( + userEvent("user.updated", workosUser(userId, { firstName: "Late", updatedAt: T2 })), + ); + const afterLate = yield* readMembership(userId, org, ["inactive"]); + return { + created, + afterCreate, + updated, + afterUpdate, + stale, + afterStale, + deleted, + afterDelete, + tombstoned, + lateUpdate, + afterLate, + }; + }), + stubWorkOS(profiles()), + ); + expect(result.created).toBe("applied"); + expect(result.afterCreate?.name).toBe("Grace Placeholder"); + expect(result.updated).toBe("applied"); + expect(result.afterUpdate?.name).toBe("Newer Placeholder"); + expect(result.stale, "an event older than the stored row is reported stale").toBe("stale"); + expect(result.afterStale?.name, "and leaves the newer row untouched").toBe("Newer Placeholder"); + expect(result.deleted).toBe("applied"); + expect(result.afterDelete, "deleting the user tombstones its membership").toBeNull(); + expect(result.tombstoned, "the row stays, inactive, with the profile cleared").toMatchObject({ + status: "inactive", + name: null, + email: null, + }); + expect(result.lateUpdate, "a payload older than the deletion is refused").toBe("stale"); + expect(result.afterLate).toMatchObject({ status: "inactive", name: null }); + }); + + it("mirrors a membership, updates its role, refuses an older update, and deletes it", async () => { + const org = freshId("org"); + const userId = freshId("user"); + const result = await run( + Effect.gen(function* () { + yield* seedOrganization(org); + const created = yield* applyEvent( + membershipEvent( + "organization_membership.created", + workosMembership(userId, org, { status: "pending" }), + ), + ); + const afterCreate = yield* readMembership(userId, org); + const updated = yield* applyEvent( + membershipEvent( + "organization_membership.updated", + workosMembership(userId, org, { + role: { slug: "admin" }, + updatedAt: T3, + }), + ), + ); + const afterUpdate = yield* readMembership(userId, org); + const stale = yield* applyEvent( + membershipEvent( + "organization_membership.updated", + workosMembership(userId, org, { + role: { slug: "member" }, + status: "inactive", + updatedAt: T2, + }), + ), + ); + const afterStale = yield* readMembership(userId, org); + // The delete is stamped with the EVENT's time (T3), after every + // payload above; the SDK payload's own `updatedAt` predates it. + const deleted = yield* applyEvent( + membershipEvent( + "organization_membership.deleted", + workosMembership(userId, org), + freshId("event"), + T3, + ), + ); + const afterDelete = yield* readMembership(userId, org); + const deletedAgain = yield* applyEvent( + membershipEvent( + "organization_membership.deleted", + workosMembership(userId, org), + freshId("event"), + T3, + ), + ); + // A membership update that happened before the delete but lands + // after it (a lagging feeder, an out-of-order delivery): refused, the + // tombstone stands. + const lateUpdate = yield* applyEvent( + membershipEvent( + "organization_membership.updated", + workosMembership(userId, org, { + role: { slug: "admin" }, + updatedAt: T2, + }), + ), + ); + const afterLate = yield* readMembership(userId, org, ["inactive"]); + return { + created, + afterCreate, + updated, + afterUpdate, + stale, + afterStale, + deleted, + afterDelete, + deletedAgain, + lateUpdate, + afterLate, + }; + }), + stubWorkOS(profiles()), + ); + expect(result.created).toBe("applied"); + expect(result.afterCreate).toMatchObject({ + membershipId: `om_${userId}_${org}`, + status: "pending", + role: "member", + }); + expect(result.updated).toBe("applied"); + expect(result.afterUpdate).toMatchObject({ + status: "active", + role: "admin", + }); + expect(result.stale).toBe("stale"); + expect(result.afterStale).toMatchObject({ + status: "active", + role: "admin", + }); + expect(result.deleted).toBe("applied"); + expect(result.afterDelete, "a tombstone reads as no membership").toBeNull(); + expect(result.deletedAgain, "a replayed delete changes nothing").toBe("absent"); + expect(result.lateUpdate, "a payload older than the deletion is refused").toBe("stale"); + expect(result.afterLate).toMatchObject({ + status: "inactive", + role: "admin", + }); + }); + + it("tombstones a membership the mirror has never seen, mirroring its organization first, so the backfill cannot insert it live", async () => { + const org = freshId("org"); + const userId = freshId("user"); + const reads: string[] = []; + const result = await run( + Effect.gen(function* () { + // The deletion lands before any feeder wrote the membership or the + // org; the org is read from WorkOS so the tombstone row can exist. + const deleted = yield* applyEvent( + membershipEvent( + "organization_membership.deleted", + workosMembership(userId, org), + freshId("event"), + T2, + ), + ); + const organization = yield* readOrganization(org); + const tombstone = yield* readMembership(userId, org, ["inactive"]); + // The backfill, listing WorkOS as it was before the deletion, writes + // the membership afterwards: refused, the tombstone stands. + const mirror = yield* WorkOsMirror; + const backfilled = yield* mirror.upsertMembership( + mirrorMembershipFromWorkOs(workosMembership(userId, org)), + ); + const afterBackfill = yield* readMembership(userId, org); + return { deleted, organization, tombstone, backfilled, afterBackfill }; + }), + stubWorkOS({ + getOrganization: (id) => { + reads.push(id); + return Effect.succeed(workosOrganization(id, "Dashboard Org")); + }, + }), + ); + expect(reads, "exactly one WorkOS read, for the unknown org").toEqual([org]); + expect(result.deleted, "the delete leaves a tombstone behind").toBe("applied"); + expect(result.organization?.name).toBe("Dashboard Org"); + expect(result.tombstone).toMatchObject({ + membershipId: `om_${userId}_${org}`, + status: "inactive", + }); + expect(result.backfilled, "the pre-deletion payload is refused").toBe(false); + expect(result.afterBackfill, "and the member is not live").toBeNull(); + }); + + it("mirrors the organization first when a membership names one the mirror has never seen", async () => { + const org = freshId("org"); + const userId = freshId("user"); + const reads: string[] = []; + const result = await run( + Effect.gen(function* () { + const outcome = yield* applyEvent( + membershipEvent("organization_membership.created", workosMembership(userId, org)), + ); + const organization = yield* readOrganization(org); + const membership = yield* readMembership(userId, org); + return { outcome, organization, membership }; + }), + stubWorkOS({ + ...profiles(reads), + getOrganization: (id) => { + reads.push(id); + return Effect.succeed(workosOrganization(id, "Dashboard Org")); + }, + }), + ); + expect(reads, "one WorkOS read for the unknown org, one for the unknown member").toEqual([ + org, + userId, + ]); + expect(result.outcome).toBe("applied"); + expect(result.organization?.name).toBe("Dashboard Org"); + expect(result.membership?.membershipId).toBe(`om_${userId}_${org}`); + }); + + it("reads the member's profile from WorkOS when the mirror holds none, never when it does, and mirrors a member WorkOS no longer has bare", async () => { + const org = freshId("org"); + const joiner = freshId("user"); + const known = freshId("user"); + const gone = freshId("user"); + const reads: string[] = []; + const result = await run( + Effect.gen(function* () { + yield* seedOrganization(org); + // `known` signed in before: the stream's own `user.created` for them + // is behind the replay boundary, but the mirror holds their profile. + yield* applyEvent( + userEvent("user.created", workosUser(known, { firstName: "Known", updatedAt: T1 })), + ); + const knownJoins = yield* applyEvent( + membershipEvent("organization_membership.created", workosMembership(known, org)), + ); + const knownRow = yield* readMembership(known, org); + // `joiner` predates the mirror: no row, no profile event in the + // stream, the org already scanned. The membership event alone + // would leave them nameless. + const joins = yield* applyEvent( + membershipEvent("organization_membership.created", workosMembership(joiner, org)), + ); + const joinerRow = yield* readMembership(joiner, org); + // A later role change for the now-profiled member reads nothing. + const promoted = yield* applyEvent( + membershipEvent( + "organization_membership.updated", + workosMembership(joiner, org, { + role: { slug: "admin" }, + updatedAt: T2, + }), + ), + ); + // A member WorkOS no longer has (their `user.deleted` is further down + // the stream): mirrored without a profile, the run goes on. + const goneJoins = yield* applyEvent( + membershipEvent("organization_membership.created", workosMembership(gone, org)), + ); + const goneRow = yield* readMembership(gone, org); + return { + knownJoins, + knownRow, + joins, + joinerRow, + promoted, + goneJoins, + goneRow, + }; + }), + stubWorkOS({ + getUser: (userId) => + Effect.suspend(() => { + reads.push(userId); + return userId === gone + ? Effect.fail(new WorkOSError({ status: 404 })) + : Effect.succeed(workosUser(userId, { firstName: "Fetched" })); + }), + }), + ); + expect(result.knownJoins).toBe("applied"); + expect(result.knownRow?.name, "a profiled member keeps the profile the mirror holds").toBe( + "Known Placeholder", + ); + expect(result.joins).toBe("applied"); + expect(result.joinerRow?.name, "an unprofiled member is mirrored WITH the profile").toBe( + "Fetched Placeholder", + ); + expect(result.joinerRow?.email).toBe(`${joiner}@placeholder.test`); + expect(result.promoted).toBe("applied"); + expect(result.goneJoins, "a member WorkOS no longer has is still mirrored").toBe("applied"); + expect(result.goneRow).toMatchObject({ + membershipId: `om_${gone}_${org}`, + name: null, + }); + expect(reads, "one read per unprofiled member, none for a profiled one").toEqual([ + joiner, + gone, + ]); + + // A transient failure reading the profile fails the run (the event is + // retried), exactly as for the organization read. + const blip = await Effect.runPromiseExit( + Effect.exit( + planEvent( + membershipEvent( + "organization_membership.created", + workosMembership(freshId("user"), org), + ), + ), + ).pipe( + Effect.provide( + Layer.mergeAll( + MirrorServices, + stubWorkOS({ + getUser: () => Effect.fail(new WorkOSError({ status: 503 })), + }), + ), + ), + Effect.scoped, + ), + ); + expect(Exit.isSuccess(blip) && Exit.isFailure(blip.value), "a 5xx keeps the event").toBe(true); + }); + + it("marks the organization deleted for a membership whose organization WorkOS no longer has, but fails on a transient WorkOS failure", async () => { + const org = freshId("org"); + const userId = freshId("user"); + const event = membershipEvent( + "organization_membership.created", + workosMembership(userId, org, { organizationName: "Gone Org" }), + freshId("event"), + T2, + ); + const gone = await run( + Effect.gen(function* () { + const outcome = yield* applyEvent(event); + const organization = yield* readOrganization(org); + const membership = yield* readMembership(userId, org); + // The org's own deletion event, further down the stream, finds the + // mark already there. + const deleted = yield* applyEvent( + organizationEvent( + "organization.deleted", + workosOrganization(org, "Gone Org"), + freshId("event"), + T3, + ), + ); + return { outcome, organization, membership, deleted }; + }), + stubWorkOS({ + getOrganization: () => Effect.fail(new WorkOSError({ status: 404 })), + }), + ); + expect( + gone.outcome, + "an org WorkOS has deleted marks the org deleted, not a failed run and not a dropped event", + ).toBe("applied"); + expect(gone.organization, "a tombstone row is minted for it").toMatchObject({ + name: "Gone Org", + deletedAt: new Date(T2), + }); + expect(gone.membership, "and the membership is not written").toBeNull(); + expect(gone.deleted, "its own deletion event finds the mark").toBe("absent"); + + // A transient failure resolving an org the mirror does not hold (the + // tombstone above would answer the read locally). + const unresolved = membershipEvent( + "organization_membership.created", + workosMembership(userId, freshId("org")), + ); + const blip = await Effect.runPromiseExit( + Effect.exit(planEvent(unresolved)).pipe( + Effect.provide( + Layer.mergeAll( + MirrorServices, + stubWorkOS({ + getOrganization: () => Effect.fail(new WorkOSError({ status: 503 })), + }), + ), + ), + Effect.scoped, + ), + ); + const unreachable = await Effect.runPromiseExit( + Effect.exit(planEvent(unresolved)).pipe( + Effect.provide( + Layer.mergeAll( + MirrorServices, + stubWorkOS({ + getOrganization: () => Effect.fail(new WorkOSError({})), + }), + ), + ), + Effect.scoped, + ), + ); + expect( + Exit.isSuccess(blip) && Exit.isFailure(blip.value), + "a 5xx keeps the event for retry", + ).toBe(true); + expect( + Exit.isSuccess(unreachable) && Exit.isFailure(unreachable.value), + "a network failure keeps the event for retry", + ).toBe(true); + }); + + it("does not create an organization row from organization.updated for an org the mirror has never seen", async () => { + const org = freshId("org"); + const result = await run( + Effect.gen(function* () { + const outcome = yield* applyEvent( + organizationEvent("organization.updated", workosOrganization(org, "Purged Org")), + ); + const organization = yield* readOrganization(org); + return { outcome, organization }; + }), + ); + expect(result.outcome, "a rename of an unmirrored org is reported absent").toBe("absent"); + expect( + result.organization, + "and mints no row (no resurrection after cloud's purge)", + ).toBeNull(); + }); + + it("mints a tombstone on organization.deleted for an org the mirror has never seen, so a delayed login cannot create it", async () => { + const org = freshId("org"); + const userId = freshId("user"); + const result = await run( + Effect.gen(function* () { + // The org was created, populated, and deleted in the WorkOS + // dashboard before anyone signed in: the mirror has no row for it. + const deleted = yield* applyEvent( + organizationEvent( + "organization.deleted", + workosOrganization(org, "Never Mirrored"), + freshId("event"), + T2, + ), + ); + const tombstone = yield* readOrganization(org); + // A login that fetched its membership list at T1, before the + // deletion, and stalled past it now writes what it holds. + yield* mirrorSignIn( + workosUser(userId), + [ + workosMembership(userId, org, { + organizationName: "Never Mirrored", + }), + ], + new Date(T1), + ); + const afterLogin = yield* readOrganization(org); + const membership = yield* readMembership(userId, org, ["active", "pending", "inactive"]); + const replayed = yield* applyEvent( + organizationEvent( + "organization.deleted", + workosOrganization(org, "Never Mirrored"), + freshId("event"), + T3, + ), + ); + return { deleted, tombstone, afterLogin, membership, replayed }; + }), + ); + expect(result.deleted, "the deletion is applied, not dropped for want of a row").toBe( + "applied", + ); + expect(result.tombstone).toMatchObject({ + name: "Never Mirrored", + deletedAt: new Date(T2), + }); + expect(result.tombstone?.slug, "the tombstone is a slugged row like any other").toMatch( + /^never-mirrored/, + ); + expect(result.afterLogin?.deletedAt, "the delayed login does not revive the org").toEqual( + new Date(T2), + ); + expect(result.membership, "nor write the membership").toBeNull(); + expect(result.replayed, "a replayed deletion changes nothing").toBe("absent"); + }); + + it("renames the organization on organization.updated, refuses an older rename, and marks it deleted on organization.deleted, purging nothing", async () => { + const org = freshId("org"); + const userId = freshId("user"); + 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)), + ); + const renamed = yield* applyEvent( + organizationEvent("organization.updated", workosOrganization(org, "Renamed Org", T2)), + ); + const afterRename = yield* readOrganization(org); + // A rename event older than the name the row holds (replayed, or + // behind a sign-in that already carried the newer name). + const olderRename = yield* 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", + workosOrganization(org, "Renamed Org"), + freshId("event"), + T2, + ), + ); + const orgAfterDelete = yield* readOrganization(org); + const membershipAfterDelete = yield* readMembership(userId, org); + const authorizedAfter = yield* authorizeOrganization(userId, org); + const deletedAgain = yield* applyEvent( + organizationEvent( + "organization.deleted", + workosOrganization(org, "Renamed Org"), + freshId("event"), + T3, + ), + ); + const renamedAfterDelete = yield* applyEvent( + organizationEvent("organization.updated", workosOrganization(org, "Late Rename", T3)), + ); + const orgAfterReplay = yield* readOrganization(org); + return { + seeded, + renamed, + afterRename, + olderRename, + afterOlderRename, + authorizedBefore, + deleted, + orgAfterDelete, + membershipAfterDelete, + authorizedAfter, + deletedAgain, + renamedAfterDelete, + orgAfterReplay, + }; + }), + stubWorkOS(profiles()), + ); + expect(result.renamed).toBe("applied"); + expect(result.afterRename?.name).toBe("Renamed Org"); + 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)); + expect(result.orgAfterReplay?.name).toBe("Renamed Org"); + }); +}); + +describe("syncWorkOsEvents", () => { + /** Pin the instance-wide cursor to a fresh known value, whatever it was. */ + const pinCursor = (value: string) => + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const before = yield* mirror.getCursor(); + const moved = yield* mirror.applyPage(before, value, []); + expect(Option.isSome(moved)).toBe(true); + return value; + }); + + type Page = { + readonly data: readonly WorkOsMirroredEvent[]; + readonly after: string | null; + }; + + /** + * A fake Events API serving `pages` in order, recording every request's + * paging options; `onPage` runs before the nth page is returned (the CAS + * contention test moves the cursor from there). `methods` adds any other + * WorkOS call the run under test is allowed to make. + */ + const eventsApi = ( + pages: readonly Page[], + requests: WorkOSListEventsOptions[], + onPage: (index: number) => Effect.Effect = () => Effect.void, + methods: Partial = {}, + ) => + Effect.map(WorkOsMirror.asEffect(), (mirror) => + stubWorkOS({ + ...methods, + listEvents: (options) => + Effect.gen(function* () { + const index = requests.length; + requests.push(options); + yield* onPage(index).pipe(Effect.provideService(WorkOsMirror, mirror), Effect.orDie); + const page = pages[index] ?? { data: [], after: null }; + return { + object: "list" as const, + data: [...page.data], + listMetadata: { before: null, after: page.after }, + }; + }), + }), + ); + + const sync = ( + workos: Layer.Layer, + ): Effect.Effect => + syncWorkOsEvents().pipe(Effect.provide(workos)); + + it("pages from the persisted cursor, applies every event, and commits the last id of each page", async () => { + const org = freshId("org"); + const a = freshId("user"); + const b = freshId("user"); + const requests: WorkOSListEventsOptions[] = []; + const profileReads: string[] = []; + const result = await run( + Effect.gen(function* () { + yield* seedOrganization(org); + const start = yield* pinCursor(freshId("event")); + const startedAt = Date.now(); + const pages: Page[] = [ + { + data: [ + userEvent("user.created", workosUser(a), `${start}_1`), + membershipEvent( + "organization_membership.created", + workosMembership(a, org), + `${start}_2`, + ), + ], + after: `${start}_2`, + }, + { + data: [ + membershipEvent( + "organization_membership.created", + workosMembership(b, org), + `${start}_3`, + ), + organizationEvent( + "organization.deleted", + workosOrganization(org, "Gone"), + `${start}_4`, + ), + ], + after: null, + }, + ]; + const workos = yield* eventsApi(pages, requests, () => Effect.void, profiles(profileReads)); + const report = yield* sync(workos); + const mirror = yield* WorkOsMirror; + const cursor = yield* mirror.getCursor(); + const members = yield* Effect.flatMap(MemberDirectory.asEffect(), (d) => d.members(org)); + const drainedAt = yield* mirror.drainedAt(); + return { start, startedAt, report, cursor, members, drainedAt }; + }), + ); + expect(requests.map((r) => r.after)).toEqual([result.start, `${result.start}_2`]); + expect(requests[0]).toMatchObject({ order: "asc", limit: 100 }); + expect(requests[0]?.rangeStart, "a run with a cursor never sends rangeStart").toBeUndefined(); + expect(result.report).toMatchObject({ + pages: 2, + events: 4, + applied: 4, + stopped: "drained", + cursor: `${result.start}_4`, + }); + expect(result.cursor).toBe(`${result.start}_4`); + expect(result.members.map((m) => m.accountId).sort()).toEqual([a, b].sort()); + expect( + profileReads, + "only the member whose profile the stream did not carry is read from WorkOS", + ).toEqual([b]); + expect( + result.drainedAt, + "a run that reads the stream to its end records the drain", + ).not.toBeNull(); + expect( + result.drainedAt!.getTime(), + "as of the run's start, so it never post-dates an event the run did not see", + ).toBeGreaterThanOrEqual(result.startedAt - 1000); + expect(result.drainedAt!.getTime()).toBeLessThanOrEqual(Date.now()); + }); + + /** The sync row is instance-wide: clear it so the run under test is a first run. */ + const clearSyncRow = Effect.flatMap(DbService.asEffect(), ({ db }) => + Effect.promise(() => db.execute(sql`delete from workos_sync where id = 'events'`)), + ); + + it("starts from the backfill's replay boundary when no cursor exists", async () => { + const requests: WorkOSListEventsOptions[] = []; + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + yield* clearSyncRow; + yield* mirror.setReplayBoundary(new Date(T2)); + const start = freshId("event"); + const workos = yield* eventsApi( + [ + { + data: [userEvent("user.created", workosUser(freshId("user")), start)], + after: null, + }, + ], + requests, + ); + const report = yield* sync(workos); + const cursor = yield* mirror.getCursor(); + return { report, cursor, start }; + }), + ); + expect(requests).toHaveLength(1); + expect(requests[0]?.after).toBeUndefined(); + expect( + requests[0]?.rangeStart, + "the first read starts exactly where the backfill began reading WorkOS", + ).toBe(T2); + expect(result.report.stopped).toBe("drained"); + expect(result.cursor, "the first run mints the cursor").toBe(result.start); + }); + + it("reads nothing while neither a cursor nor a replay boundary exists", async () => { + const requests: WorkOSListEventsOptions[] = []; + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + yield* clearSyncRow; + const workos = yield* eventsApi( + [ + { + data: [userEvent("user.created", workosUser(freshId("user")))], + after: null, + }, + ], + requests, + ); + const report = yield* sync(workos); + const cursor = yield* mirror.getCursor(); + const drainedAt = yield* mirror.drainedAt(); + return { report, cursor, drainedAt }; + }), + ); + expect(requests, "no wall-clock guess is ever sent to WorkOS").toHaveLength(0); + expect(result.report).toMatchObject({ + pages: 0, + events: 0, + stopped: "awaiting_backfill", + }); + expect(result.cursor, "and no cursor is minted").toBeNull(); + expect(result.drainedAt, "nor is a drain recorded: nothing was read").toBeNull(); + }); + + it("stops when another run moves the cursor under it, writing nothing from the contended page", async () => { + const org = freshId("org"); + const userId = freshId("user"); + const requests: WorkOSListEventsOptions[] = []; + const result = await run( + Effect.gen(function* () { + yield* seedOrganization(org); + const mirror = yield* WorkOsMirror; + const start = yield* pinCursor(freshId("event")); + const intruder = `${start}_intruder`; + // This run's page carries a membership update that the leading run + // has already applied AND deleted (the member was revoked). If the + // lagging run's page landed, the revoked member would be back. + const pages: Page[] = [ + { + data: [ + membershipEvent( + "organization_membership.updated", + workosMembership(userId, org, { role: { slug: "admin" } }), + `${start}_1`, + ), + ], + after: `${start}_1`, + }, + { + data: [userEvent("user.created", workosUser(freshId("user")), `${start}_2`)], + after: null, + }, + ]; + // While this run is reading its first page, "another run" applies the + // same page, then the membership's deletion, and commits both. + const workos = yield* eventsApi( + pages, + requests, + (index) => + index === 0 + ? Effect.gen(function* () { + const leading = yield* WorkOsMirror; + yield* leading.applyPage(start, `${start}_1`, [ + WorkOsMirrorWrite.UpsertMembership({ + membership: mirrorMembershipFromWorkOs(workosMembership(userId, org)), + }), + ]); + yield* leading.applyPage(`${start}_1`, intruder, [ + WorkOsMirrorWrite.DeleteMembership({ + membership: { + id: `om_${userId}_${org}`, + accountId: userId, + organizationId: org, + }, + deletedAt: new Date(T2), + }), + ]); + }) + : Effect.void, + profiles(), + ); + const drainedBefore = yield* mirror.drainedAt(); + const report = yield* sync(workos); + const cursor = yield* mirror.getCursor(); + const membership = yield* readMembership(userId, org); + const drainedAfter = yield* mirror.drainedAt(); + return { + report, + cursor, + intruder, + membership, + drainedBefore, + drainedAfter, + }; + }), + ); + expect(requests, "the second page is never read").toHaveLength(1); + expect(result.report).toMatchObject({ + pages: 1, + events: 1, + applied: 0, + stopped: "cursor_contended", + }); + expect(result.cursor, "the other run's cursor stands").toBe(result.intruder); + expect(result.membership, "the revoked membership is not resurrected").toBeNull(); + expect( + result.drainedAfter, + "a run that yielded the stream drained nothing and records no drain", + ).toEqual(result.drainedBefore); + }); + + it("advances the cursor past a membership event whose organization WorkOS no longer has, marking the org deleted", async () => { + const org = freshId("org"); + const userId = freshId("user"); + const requests: WorkOSListEventsOptions[] = []; + const result = await run( + Effect.gen(function* () { + const start = yield* pinCursor(freshId("event")); + const workos = yield* eventsApi( + [ + { + data: [ + membershipEvent( + "organization_membership.created", + workosMembership(userId, org), + `${start}_1`, + ), + organizationEvent( + "organization.deleted", + workosOrganization(org, "Gone"), + `${start}_2`, + ), + ], + after: null, + }, + ], + requests, + () => Effect.void, + { + getOrganization: () => Effect.fail(new WorkOSError({ status: 404 })), + }, + ); + const report = yield* sync(workos); + const mirror = yield* WorkOsMirror; + const cursor = yield* mirror.getCursor(); + const organization = yield* readOrganization(org); + return { start, report, cursor, organization }; + }), + ); + expect(result.report).toMatchObject({ + pages: 1, + events: 2, + applied: 1, + absent: 1, + stopped: "drained", + cursor: `${result.start}_2`, + }); + expect(result.cursor, "the stream is not stalled on the gone org").toBe(`${result.start}_2`); + expect(result.organization?.deletedAt, "the org is left as a tombstone").not.toBeNull(); + }); +}); + +describe("workos webhook", () => { + const SECRET = "whsec_placeholder_signing_secret"; + + const handlerFor = (deps: { + readonly secret: string | undefined; + readonly detached: Promise[]; + readonly synced: number[]; + }) => + HttpRouter.toWebHandler( + makeWorkOsWebhookRoute({ + secret: deps.secret, + detach: (work) => { + deps.detached.push(work); + }, + sync: () => { + deps.synced.push(1); + return Promise.resolve(); + }, + }).pipe( + // The REAL client: its `webhooks.constructEvent` is the signature check + // under test. The api key / client id it reads are the vitest env's. + Layer.provideMerge(WorkOSClient.Default), + Layer.provideMerge(HttpServer.layerServices), + ), + { disableLogger: true }, + ).handler; + + const delivery = { + id: "event_placeholder", + event: "user.created", + created_at: T1, + context: {}, + data: { + object: "user", + id: "user_placeholder", + email: "member@placeholder.test", + email_verified: true, + first_name: "Ada", + last_name: "Placeholder", + profile_picture_url: null, + last_sign_in_at: T1, + locale: null, + created_at: T1, + updated_at: T1, + external_id: null, + metadata: {}, + }, + }; + + /** The `WorkOS-Signature` header WorkOS sends: `t=, v1=`. */ + const signature = (body: string, secret: string, timestamp = Date.now()) => + `t=${timestamp}, v1=${createHmac("sha256", secret).update(`${timestamp}.${body}`).digest("hex")}`; + + const post = (body: string, headers: Record) => + new Request(`http://test.local${WORKOS_WEBHOOK_PATH}`, { + method: "POST", + headers: { "content-type": "application/json", ...headers }, + body, + }); + + const deps = (secret: string | undefined) => ({ + secret, + detached: [] as Promise[], + synced: [] as number[], + }); + + it("accepts a genuinely signed delivery and pokes the reconciler past the response", async () => { + const d = deps(SECRET); + const body = JSON.stringify(delivery); + const response = await handlerFor(d)( + post(body, { "workos-signature": signature(body, SECRET) }), + ); + expect(response.status).toBe(200); + expect(d.synced, "one reconciler pass").toHaveLength(1); + expect(d.detached, "handed to the platform, not awaited in the response").toHaveLength(1); + }); + + it("rejects a delivery signed with another secret, a tampered body, and a missing header", async () => { + const d = deps(SECRET); + const handler = handlerFor(d); + const body = JSON.stringify(delivery); + + const wrongSecret = await handler( + post(body, { "workos-signature": signature(body, "whsec_other") }), + ); + const tampered = await handler( + post(body.replace("user_placeholder", "user_tampered"), { + "workos-signature": signature(body, SECRET), + }), + ); + const unsigned = await handler(post(body, {})); + const notJson = await handler( + post("not json", { "workos-signature": signature("not json", SECRET) }), + ); + const expired = await handler( + post(body, { + "workos-signature": signature(body, SECRET, Date.now() - 10 * 60 * 1000), + }), + ); + + expect([ + wrongSecret.status, + tampered.status, + unsigned.status, + notJson.status, + expired.status, + ]).toEqual([400, 400, 400, 400, 400]); + expect(d.synced, "nothing is poked").toEqual([]); + }); + + it("refuses every delivery while no signing secret is configured", async () => { + const d = deps(undefined); + const body = JSON.stringify(delivery); + const response = await handlerFor(d)( + post(body, { "workos-signature": signature(body, SECRET) }), + ); + expect(response.status).toBe(503); + expect(d.synced).toEqual([]); + }); +}); diff --git a/apps/cloud/src/auth/workos-events-sync.ts b/apps/cloud/src/auth/workos-events-sync.ts new file mode 100644 index 0000000000..d404d5fbfa --- /dev/null +++ b/apps/cloud/src/auth/workos-events-sync.ts @@ -0,0 +1,107 @@ +// --------------------------------------------------------------------------- +// The membership mirror's reconciler, bound to the Worker's services: the +// replay itself is `workos-events-replay.ts` (a pure function over its +// ports, so the deploy gate can run the same replay under bun); this file +// wires those ports to `WorkOSClient`, `UserStoreService`, and +// `WorkOsMirror` for the every-minute cron and the signed webhook poke +// (`workos-events-runner.ts`). +// +// The only translation here is the WorkOS answer "gone": the replay wants +// `None` for an organization or user WorkOS no longer has, and only a 404 +// says that. A 401/403 is a credentials or permissions problem with THIS +// deployment, and 429/5xx/no status is a blip: all of those stay failures +// so the run stops and the event is retried once fixed, not skipped and +// lost. +// --------------------------------------------------------------------------- + +import { Effect, Option } from "effect"; + +import { UserStoreService } from "./context"; +import type { UserStoreError, WorkOSError } from "./errors"; +import { WorkOSClient } from "./workos"; +import { + planWorkOsEvent, + replayWorkOsEvents, + type PlannedProfiles, + type WorkOsEventsReplayDeps, + type WorkOsMirroredEvent, +} from "./workos-events-replay"; +import { WorkOsMirror, type WorkOsMirrorError } from "./workos-mirror"; + +export { + MIRRORED_EVENT_NAMES, + isMirroredEvent, + type PlannedProfiles, + type WorkOsEventOutcome, + type WorkOsEventsSyncReport, + type WorkOsMirroredEvent, + type WorkOsMirroredEventName, +} from "./workos-events-replay"; + +type SyncFailure = WorkOSError | UserStoreError | WorkOsMirrorError; + +// A 404 is the deterministic "gone" the replay acts on; everything else +// fails the run (see the header). +const noneWhenGone = ( + read: Effect.Effect, +): Effect.Effect, WorkOSError> => + read.pipe( + Effect.map(Option.some), + Effect.catchTag("WorkOSError", (error) => + error.status === 404 ? Effect.succeed(Option.none()) : Effect.fail(error), + ), + ); + +const replayDeps: Effect.Effect< + WorkOsEventsReplayDeps, + never, + WorkOSClient | UserStoreService | WorkOsMirror +> = Effect.gen(function* () { + const workos = yield* WorkOSClient; + const users = yield* UserStoreService; + const mirror = yield* WorkOsMirror; + return { + source: { + listEvents: (options) => + Effect.map(workos.listEvents(options), (page) => ({ + data: page.data, + after: page.listMetadata.after ?? null, + })), + getOrganization: (organizationId) => noneWhenGone(workos.getOrganization(organizationId)), + getUser: (userId) => noneWhenGone(workos.getUser(userId)), + }, + store: { + getOrganization: (organizationId) => + users.use("getOrganization", (s) => s.getOrganization(organizationId)), + upsertOrganization: (organization) => + users.use("upsertOrganization", (s) => s.upsertOrganization(organization)), + getAccount: (accountId) => users.use("getAccount", (s) => s.getAccount(accountId)), + }, + mirror, + }; +}); + +/** + * Translate one event into the mirror write it calls for, over the Worker's + * services. See `planWorkOsEvent` for the contract. + */ +export const planEvent = Effect.fn("workos_events.plan")(function* ( + event: WorkOsMirroredEvent, + profiled: PlannedProfiles = new Set(), +) { + yield* Effect.annotateCurrentSpan({ + "workos.event": event.event, + "workos.event_id": event.id, + }); + const deps = yield* replayDeps; + return yield* planWorkOsEvent(deps, event, profiled); +}); + +/** + * One reconciler run over the Worker's services. See `replayWorkOsEvents` + * for the contract. + */ +export const syncWorkOsEvents = Effect.fn("workos_events.sync")(function* () { + const deps = yield* replayDeps; + return yield* replayWorkOsEvents(deps); +}); diff --git a/apps/cloud/src/auth/workos-mirror-backfill.ts b/apps/cloud/src/auth/workos-mirror-backfill.ts new file mode 100644 index 0000000000..d6875241b2 --- /dev/null +++ b/apps/cloud/src/auth/workos-mirror-backfill.ts @@ -0,0 +1,278 @@ +// --------------------------------------------------------------------------- +// Backfill of the membership mirror from WorkOS, one organization at a time: +// list EVERY membership WorkOS holds for it — active, pending, and inactive +// alike — fetch each member's user, write both through the mirror's guarded +// upserts, tombstone whatever the mirror still holds that WorkOS no longer +// lists, and mark the organization BACKFILLED as of the listing. Inactive +// memberships are listed on purpose: the scan tombstones every mirrored +// membership its listing lacks, and a tombstone is keyed to the membership +// id for good (`membershipAcceptsPayload`), so a listing that skipped the +// inactive ones would tombstone a membership WorkOS merely deactivated and +// refuse its reactivation — under the same id — forever. Listed with its +// real status it stays an ordinary `inactive` row that a newer payload +// reactivates. The core is a pure function over a `source` +// (WorkOS reads) and the mirror store, so the one-off script +// (`scripts/backfill-workos-mirror.ts`) can wire real clients, the request +// path can wire `WorkOSClient` (an organization whose mark is missing is +// scanned on demand before its seats are counted), and the test can wire +// fakes against the test database. +// +// Idempotent and repairing: the upserts are guarded on WorkOS `updatedAt`, +// so a re-scan over unchanged data writes nothing new, and every membership +// the mirror holds for the org that WorkOS no longer lists is TOMBSTONED +// (`inactive`, `deleted_at` = the time the listing was taken) — so a re-scan +// repairs a stale row instead of leaving it granting access. `dryRun` reads +// everything and writes nothing, so the printed counts are the plan. +// +// One scan is applied in ONE transaction (`WorkOsMirror.applyOrganizationScan`) +// that first moves the organization's `backfilled_at` forward to the +// listing's instant and writes nothing if a LATER listing already did. Two +// scans of the same organization can overlap (the one-off script and an +// on-demand scan from a request, or a stalled script run and its retry), and +// the `updatedAt` guard alone cannot order them: a scan that listed a +// membership, stalled, and resumed after a later scan had found it gone +// would insert it live — the later scan tombstoned nothing, because the row +// was not there to tombstone. Refusing the older listing whole is what keeps +// a membership revoked between the two listings revoked. +// +// Completeness is tracked PER ORGANIZATION (`organizations.backfilled_at`), +// never database-wide: the mark is written only by a scan that listed that +// organization's memberships in full, so an organization mirrored after a +// backfill ran (lazily by a request, or by a sign-in that records only the +// caller's own membership) starts unmarked and is scanned before any count +// read from the mirror is trusted. A run over every organization +// (`backfillWorkOsMirror`) additionally records the Events API replay +// boundary — the instant it began reading WorkOS — BEFORE anything is +// listed, and only if no boundary is recorded yet. Recording it first, not +// on completion, is what makes a failed run safe to retry: a run that +// fails part-way has already fixed the boundary at its start, and its +// retry reads that boundary back instead of taking a fresh, later one. A +// scan refreshes memberships and tombstones, not organization names or +// deleted users' profiles, so an `organization.updated` or `user.deleted` +// that lands between the attempts (or between two runs) is covered only by +// the events stream — a boundary taken by the retry would fall after it and +// skip it for good, leaving the deleted user's profile in the mirror. The +// reconciler's own cursor takes over from the boundary after its first +// page, so the boundary's only job is to name where that first page starts. +// +// The mark also orders every OTHER membership write against the scan: the +// mirror refuses a membership payload stamped before the organization's +// `backfilled_at` (`upsertMembership`). A login whose membership list was +// fetched before a revocation and written after the scan would otherwise +// reinstate the revoked membership — and that revocation predates the +// events replay boundary, so no event would ever tombstone it again. +// --------------------------------------------------------------------------- + +import { Clock, Effect, Option } from "effect"; + +import { + mirrorMembershipFromWorkOs, + mirrorUserFromWorkOs, + type WorkOsMembershipPayload, + type WorkOsMirrorShape, + type WorkOsOrganizationScanWrites, + type WorkOsUserPayload, +} from "./workos-mirror-store"; + +/** The WorkOS reads one organization's scan performs, over whatever client the caller wires. */ +export interface WorkOsOrganizationScanSource { + /** + * EVERY membership of one organization, all pages and all statuses + * (active, pending, inactive). A source that filtered by status would + * have the scan tombstone what it filtered out — see the header. + */ + readonly listOrgMembers: ( + organizationId: string, + ) => Effect.Effect; + readonly getUser: (userId: string) => Effect.Effect; +} + +/** The reads the full backfill performs: every organization, then each one's scan. */ +export interface WorkOsMirrorBackfillSource extends WorkOsOrganizationScanSource { + /** Every organization id the mirror knows (FK target of `memberships`). */ + readonly listOrganizationIds: () => Effect.Effect; +} + +export interface WorkOsMirrorBackfillOptions { + readonly dryRun: boolean; + /** One line per organization and one summary line; never a user's data. */ + readonly log: (line: string) => void; +} + +/** What one organization's scan did. */ +export interface WorkOsOrganizationScanCounts extends WorkOsOrganizationScanWrites { + /** Memberships WorkOS reported for the organization. */ + readonly memberships: number; + /** + * Whether the listing was written to the mirror. `false` on a dry run, and + * when the mirror refused the listing whole: a later listing of the + * organization had already been applied (an overlapping scan finished + * first), or the organization is marked deleted or not mirrored. Every + * write count is 0 then. + */ + readonly applied: boolean; +} + +const NOTHING_WRITTEN: WorkOsOrganizationScanWrites = { + usersWritten: 0, + membershipsWritten: 0, + membershipsTombstoned: 0, +}; + +export interface WorkOsMirrorBackfillCounts extends WorkOsOrganizationScanWrites { + readonly organizations: number; + /** Memberships WorkOS reported across every organization. */ + readonly memberships: number; +} + +// Bounded fan-out for the per-member `getUser` calls: enough to overlap the +// WorkOS round-trips, low enough to stay clear of its rate limit. +const USER_FETCH_CONCURRENCY = 5; + +const now = () => Effect.map(Clock.currentTimeMillis, (millis) => new Date(millis)); + +/** + * Scan one organization: list every membership WorkOS holds for it, fetch + * each member's user, and apply the listing to the mirror in one transaction + * — the listed users and memberships upserted, the rest tombstoned, the + * organization marked backfilled as of the listing — unless a later listing + * was applied first, in which case nothing is written (`applied: false`). + * The organization row must already be mirrored. Fails on the first source + * or mirror failure and writes nothing then — a failed scan is safe to + * repeat, so surfacing the failure beats a silent skip. A dry run reads + * everything, writes nothing, and marks nothing. + */ +export const backfillOrganization = ( + source: WorkOsOrganizationScanSource, + mirror: WorkOsMirrorShape, + organizationId: string, + options: { readonly dryRun: boolean }, +) => + Effect.gen(function* () { + // The listing's own instant, taken BEFORE the read so no change can fall + // between them: the tombstone time for whatever the listing no longer + // contains, the cut-off for what may be tombstoned at all (a row stamped + // at or after it was written after the listing and is not missing from + // it), and the organization's new `backfilled_at`. + const listedAt = yield* now(); + const listed = yield* source.listOrgMembers(organizationId); + const members = yield* Effect.forEach( + listed, + (membership) => + Effect.map(source.getUser(membership.userId), (user) => ({ + user: mirrorUserFromWorkOs(user), + membership: mirrorMembershipFromWorkOs(membership), + })), + { concurrency: USER_FETCH_CONCURRENCY }, + ); + if (options.dryRun) { + const counts: WorkOsOrganizationScanCounts = { + ...NOTHING_WRITTEN, + memberships: members.length, + applied: false, + }; + return counts; + } + const written = yield* mirror.applyOrganizationScan({ + organizationId, + listedAt, + members, + }); + if (Option.isNone(written)) { + yield* Effect.logWarning( + "workos_mirror: organization scan not applied — a later listing was already applied, or the organization is deleted or not mirrored", + { organizationId, listedAt: listedAt.toISOString() }, + ); + } + const counts: WorkOsOrganizationScanCounts = { + ...Option.getOrElse(written, () => NOTHING_WRITTEN), + memberships: members.length, + applied: Option.isSome(written), + }; + return counts; + }).pipe( + Effect.withSpan("workos_mirror.backfillOrganization", { + attributes: { organizationId }, + }), + ); + +/** + * Run the full backfill: record the Events API replay boundary if none is + * recorded yet, then scan every organization the mirror knows. Fails on the + * first source or mirror failure — the organizations scanned so far stay + * marked (each was covered in full), the boundary recorded at the start + * stands, and the run is safe to repeat: the retry keeps that boundary, so + * every change since the first attempt began is the reconciler's to replay. + */ +export const backfillWorkOsMirror = ( + source: WorkOsMirrorBackfillSource, + mirror: WorkOsMirrorShape, + options: WorkOsMirrorBackfillOptions, +) => + Effect.gen(function* () { + // The replay boundary: taken AND recorded before anything is listed, so + // every change from this instant on is the events stream's to apply — + // one that lands while this run is still listing, or between this run + // failing part-way and its retry. Kept only when none is recorded yet + // (`setReplayBoundary`): a retry or a later run reads the first one + // back instead of moving it. A dry run records nothing. + const boundary = yield* now(); + if (!options.dryRun) { + const recorded = yield* mirror.setReplayBoundary(boundary); + options.log( + recorded + ? `events replay boundary set to ${boundary.toISOString()}` + : "events replay boundary already recorded by an earlier run; kept (the events reconciler replays every change since it)", + ); + } + + const organizationIds = yield* source.listOrganizationIds(); + let memberships = 0; + let usersWritten = 0; + let membershipsWritten = 0; + let membershipsTombstoned = 0; + + for (const organizationId of organizationIds) { + const scanned = yield* backfillOrganization(source, mirror, organizationId, options); + memberships += scanned.memberships; + usersWritten += scanned.usersWritten; + membershipsWritten += scanned.membershipsWritten; + membershipsTombstoned += scanned.membershipsTombstoned; + options.log( + `${organizationId} ${scanned.memberships} membership(s)` + + (options.dryRun + ? "" + : scanned.applied + ? ` wrote ${scanned.usersWritten} user(s), ${scanned.membershipsWritten} membership(s), tombstoned ${scanned.membershipsTombstoned}` + : " not applied (a later listing was already applied, or the organization is deleted)"), + ); + } + + const counts: WorkOsMirrorBackfillCounts = { + organizations: organizationIds.length, + memberships, + usersWritten, + membershipsWritten, + membershipsTombstoned, + }; + options.log( + options.dryRun + ? `dry run — ${counts.organizations} organization(s), ${counts.memberships} membership(s) would be mirrored` + : `${counts.organizations} organization(s), ${counts.memberships} membership(s): wrote ${counts.usersWritten} user(s), ${counts.membershipsWritten} membership(s), tombstoned ${counts.membershipsTombstoned}`, + ); + if (!options.dryRun) { + // Every live organization is now covered (a failure above fails the + // whole run): the mirror is complete enough to authorize from, once + // the reconciler has caught up too — the first half of the readiness + // the authorization path checks. Once: a re-run keeps the first + // completion. + const completedAt = yield* now(); + const marked = yield* mirror.markBackfillCompleted(completedAt); + options.log( + marked + ? `backfill completion recorded at ${completedAt.toISOString()}` + : "backfill completion already recorded by an earlier run; kept", + ); + } + return counts; + }); diff --git a/apps/cloud/src/auth/workos-mirror-store.ts b/apps/cloud/src/auth/workos-mirror-store.ts new file mode 100644 index 0000000000..1a5886283a --- /dev/null +++ b/apps/cloud/src/auth/workos-mirror-store.ts @@ -0,0 +1,1232 @@ +// --------------------------------------------------------------------------- +// The membership mirror's WRITE store — the Drizzle queries behind +// `WorkOsMirror`, plus the converters from WorkOS SDK payloads to mirror rows. +// +// Kept free of `cloudflare:workers` (no `DbService`, no `env`) so the one-off +// backfill (`scripts/backfill-workos-mirror.ts`) can run the SAME upserts over +// a plain postgres.js connection under bun. The request-scoped service that +// wraps this store is `workos-mirror.ts`. +// +// Every write is idempotent and out-of-order safe. Both upserts carry the +// WorkOS `updatedAt` of their payload and refuse to overwrite a row whose +// stored `workos_updated_at` is newer, so a replayed or late-arriving event +// can never regress the mirror. A delete never drops the row: it TOMBSTONES +// it (`status = 'inactive'`, `deleted_at` set) — and INSERTS the tombstone +// when the row is not there yet — so a feeder that fetched the membership +// before the deletion and writes it after (a login, the backfill) finds the +// tombstone instead of reinstating access. The tombstone is protected by +// IDENTITY, not by time: WorkOS never reuses a deleted `om_…` id, so a +// payload naming the tombstoned id is refused whatever it is stamped — a +// role change issued before the removal and delivered after it is stamped +// NEWER than the row and would beat any timestamp guard — while a payload +// naming a DIFFERENT id for the same (account, organization) is the member +// re-added in WorkOS, a replacement, and takes the row over under the usual +// `updatedAt` rule (WorkOS creates it after the old one is deleted, so it is +// always the newer). A tombstone keeps the stamp the row holds, the deleted +// membership's last reported state — never a local clock: read after WorkOS +// answered, it could post-date a replacement created meanwhile and refuse +// it for good. `deleted_at` is the deletion instant when the caller holds +// one (an event's `createdAt`, a scan's listing time) and `now()` otherwise; +// it records WHEN, it orders nothing. A membership WorkOS merely +// deactivated (`status = 'inactive'`, no `deleted_at`) is not a tombstone: +// WorkOS can reactivate it under the same id, and the `updatedAt` rule +// orders that as any other update. The row tombstone can only speak for the +// id the row happens to hold, so every delete ALSO records the deleted id in +// `membership_tombstones`, a ledger keyed by the WorkOS id alone, and every +// membership write consults it first: that covers the case the row cannot — +// membership A replaced by B in WorkOS before the mirror saw either, B then +// deleted. The delete finds a row holding A (not B's to tombstone) and would +// otherwise leave nothing behind; a later scan that still lists B would then +// insert it live, stamped after A and under another id, exactly what the row +// guard lets through. With the ledger the delete is recorded whatever the +// row holds, and B's payload is refused by identity. A scan records the ids +// it tombstones the same way. A deleted USER is protected by identity +// too: `deleteUser` leaves the account row behind as a tombstone (profile +// cleared, stamped with the deletion), and no membership naming that account +// is written again, however the payload is stamped — WorkOS never reuses a +// user id, and a membership the mirror had not seen has no row of its own +// for a guard to refuse the insert against. The reconciler applies a page +// of events and advances the cursor in ONE transaction that +// compare-and-sets the cursor first (`applyPage`), so a run that has lost +// the stream to another run writes nothing — the `updatedAt` guard alone +// cannot stop it re-applying a page the leading run has moved past. +// +// A backfill SCAN of one organization (its full membership listing, taken +// at one instant) is applied the same way: ONE transaction that first +// compare-and-sets the organization's `backfilled_at` to the listing's +// instant (`applyOrganizationScan`), so the row stays locked until commit, +// two overlapping scans serialize on it, and the one whose listing is older +// than the recorded one writes NOTHING. The `updatedAt` guard alone cannot +// order two scans: a scan that listed a membership, stalled, and resumed +// after a later scan had found it gone would insert it live — the later scan +// left no tombstone, because the row was not there to tombstone. For the +// same reason every OTHER membership write is ordered against the scan too: +// a payload stamped before the organization's `backfilled_at` is refused +// unless the row already holds it — judged inside the write's own +// transaction, reading the organization row `FOR SHARE`, so a write that +// races a scan waits for the scan's commit and sees the mark it set, and +// never inserts a membership the scan has just proved revoked. A completed +// scan is the full listing as +// of that instant, so a membership it did not contain but an older payload +// still names (a login whose list was fetched before the revocation and +// written after the scan) was revoked before the scan — and that revocation +// predates the events replay boundary, so nothing would ever tombstone the +// reinstated row. The scan's own writes are the one exception: they are the +// listing that sets the mark. +// +// The events replay boundary (`workos_sync.range_start`) is written ONCE, by +// the first backfill run BEFORE its first listing, and never advanced: a +// scan refreshes memberships only, so an organization rename or user +// deletion after that instant — between two runs, or between a run that +// failed part-way and its retry — is covered by the events stream alone, +// and moving the boundary past it would skip it for good. +// --------------------------------------------------------------------------- + +import { and, eq, isNotNull, isNull, lt, ne, notInArray, or, sql } from "drizzle-orm"; +import type { AnyPgColumn } from "drizzle-orm/pg-core"; +import { Data, Effect, Option } from "effect"; + +import type { MemberStatus } from "@executor-js/api/server"; + +import { + accounts, + membershipTombstones, + memberships, + organizations, + workosSync, +} from "../db/schema"; +import type { DrizzleDb } from "../db/db"; +import { insertOrganization, organizationAcceptsName } from "./user-store"; +import { + WorkOsMirrorError, + tryPromiseService, + userStoreReasonFromCause, + withServiceLogging, +} from "./errors"; + +/** A WorkOS user, as the mirror stores it. `updatedAt` is WorkOS's own. */ +export interface WorkOsMirrorUser { + readonly id: string; + readonly email: string; + readonly firstName: string | null; + readonly lastName: string | null; + readonly avatarUrl: string | null; + readonly lastSignInAt: Date | null; + readonly updatedAt: Date; +} + +/** + * A WorkOS organization membership, as the mirror stores it. `id` is the + * WorkOS `om_…`; `accountId` the WorkOS user id; `updatedAt` is WorkOS's own. + * The organization row must already be mirrored (`upsertOrganization`) — a + * membership of an unknown org is a `query` failure, not a silent skip. + */ +export interface WorkOsMirrorMembership { + readonly id: string; + readonly accountId: string; + readonly organizationId: string; + readonly role: string; + readonly status: MemberStatus; + readonly updatedAt: Date; +} + +/** + * What identifies a membership to a delete: the WorkOS `om_…` id AND the + * (account, organization) pair it belongs to. The pair is the row's key, + * and a delete must be able to mint the row as a tombstone when the mirror + * has not seen the membership yet — an id alone cannot. + */ +export type WorkOsMirrorMembershipRef = Pick< + WorkOsMirrorMembership, + "id" | "accountId" | "organizationId" +>; + +/** One member as a backfill scan lists it: the membership and its user. */ +export interface WorkOsScannedMember { + readonly user: WorkOsMirrorUser; + readonly membership: WorkOsMirrorMembership; +} + +/** + * One organization's full membership listing, as `applyOrganizationScan` + * applies it. `listedAt` is the instant the listing was taken — BEFORE the + * WorkOS read, so no change can fall between the instant and the listing — + * and is the tombstone time for what the listing no longer contains, the + * cut-off for what may be tombstoned at all, and the organization's new + * `backfilled_at`. + */ +export interface WorkOsOrganizationScan { + readonly organizationId: string; + readonly listedAt: Date; + readonly members: readonly WorkOsScannedMember[]; +} + +/** What an applied scan wrote: the upserts the `updatedAt` guard let through, and the tombstones. */ +export interface WorkOsOrganizationScanWrites { + readonly usersWritten: number; + readonly membershipsWritten: number; + readonly membershipsTombstoned: number; +} + +/** + * One write of a reconciler page, applied by `applyPage` inside the page's + * transaction. The reconciler plans a page into these BEFORE the transaction + * opens, so every WorkOS read (resolving an organization the mirror has never + * seen) is done by then: the transaction holds the mirror's single connection + * and must not wait on the network. + */ +export type WorkOsMirrorWrite = Data.TaggedEnum<{ + readonly UpsertUser: { readonly user: WorkOsMirrorUser }; + readonly UpsertMembership: { readonly membership: WorkOsMirrorMembership }; + /** + * A membership together with its member's profile, read from WorkOS at + * plan time because the mirror held no profile for the account + * (`workos-events-sync.ts`): the user is upserted first, under the usual + * guard, then the membership. The outcome is the membership's. + */ + readonly UpsertMember: { + readonly user: WorkOsMirrorUser; + readonly membership: WorkOsMirrorMembership; + }; + /** Tombstone a membership as of `deletedAt` (the event's `createdAt`). */ + readonly DeleteMembership: { + readonly membership: WorkOsMirrorMembershipRef; + readonly deletedAt: Date; + }; + /** Tombstone a user and their memberships as of `deletedAt`. */ + readonly DeleteUser: { readonly accountId: string; readonly deletedAt: Date }; + /** + * Rename an organization the mirror already holds — never inserts one — + * from a payload stamped `updatedAt` (the WorkOS organization's own), under + * the same name guard every feeder applies (`organizationAcceptsName`). + */ + readonly RenameOrganization: { + readonly organizationId: string; + readonly name: string; + readonly updatedAt: Date; + }; + /** + * Mark an organization deleted as of `deletedAt` (the event's + * `createdAt`) — minting the row as a TOMBSTONE, named `name`, when the + * mirror has never seen the organization, so a feeder still holding a + * membership of it (a login that stalled across the deletion) finds the + * tombstone and cannot mint the organization live. Never purges tenant + * data (that is cloud's own flow, `db/org-deletion.ts`). An earlier mark + * stands (`absent`). + */ + readonly MarkOrganizationDeleted: { + readonly organizationId: string; + readonly name: string; + readonly deletedAt: Date; + }; +}>; +export const WorkOsMirrorWrite = Data.taggedEnum(); + +/** + * What one write did: a row was written, renamed, or tombstoned (`applied`); + * the `updatedAt` guard refused an older payload (`stale`); or a delete found + * its row already tombstoned or superseded by a newer membership (a + * replayed delete), or a rename found no live organization row to change — + * the mirror has never seen it, or it is marked deleted — or a deletion + * mark found the organization already marked (`absent`). + */ +export type WorkOsMirrorWriteOutcome = "applied" | "stale" | "absent"; + +export interface WorkOsMirrorShape { + /** + * Insert or refresh a user row. `false` when the payload was refused and + * the row left untouched: the stored row is newer than `updatedAt`, or is + * a deletion tombstone stamped at `updatedAt` or later. + */ + readonly upsertUser: (user: WorkOsMirrorUser) => Effect.Effect; + /** + * Insert or refresh a membership row, minting the bare account row first so + * the foreign key holds when the membership arrives before its user. `false` + * when the payload was refused: the stored row is newer than `updatedAt`, + * or is a deletion tombstone of THIS membership id, or THIS membership id + * is in the deletion ledger (`membership_tombstones`, written by every + * delete and every scan tombstone, whatever row it found) — a deleted + * WorkOS id never returns, however the payload is stamped; a payload under + * another id is a replacement and is ordered by `updatedAt` against the + * row's stamp — or the account is a deletion tombstone (`deleteUser`), whatever + * the payload is stamped: WorkOS never reuses a user id, so a deleted user + * has no memberships to mirror — or the organization is marked deleted + * (`organizations.deleted_at`), in which case nothing is written at all: + * no membership of a deleted organization is ever mirrored — or the + * payload is stamped BEFORE the organization's last full scan + * (`organizations.backfilled_at`): the scan listed everything WorkOS held + * at that instant, so a membership it wrote already carries a stamp at + * least this new (the write would change nothing) and one it did not + * write was gone by then and must not come back from a list fetched + * earlier. Only the scan itself writes past that mark + * (`applyOrganizationScan`). The organization checks and the write run in + * ONE transaction that holds the organization row `FOR SHARE`, so a scan + * claiming the row at the same time is waited for and its mark seen — and + * the account row `FOR SHARE` too, so a `deleteUser` tombstoning it at the + * same time is waited for and its tombstone seen. + */ + readonly upsertMembership: ( + membership: WorkOsMirrorMembership, + ) => Effect.Effect; + /** + * Tombstone the membership as deleted: the row stays (or is minted, when + * the mirror has not seen the membership yet), `inactive`, carrying the + * deleted WorkOS id with `deleted_at` set. The tombstone is protected by + * that id: `upsertMembership` refuses every later payload naming it, + * however stamped, and accepts only a REPLACEMENT under another id. The + * row's `workos_updated_at` is left as it is — the deleted membership's + * last reported state, which a replacement is always stamped after; a + * local clock read after WorkOS answered could post-date a replacement + * created meanwhile and must never become the row's stamp. `deletedAt` + * is the deletion instant when the caller holds one (a deletion event's + * `createdAt`, a scan's listing time) or `null` when it holds none + * (WorkOS answers a delete with no time): `deleted_at` then takes the + * current time. It records when the membership was deleted; it orders + * nothing. Matches the row by IDENTITY: `false` when the row is already + * tombstoned (a replayed delete), or holds ANOTHER membership id — the + * member re-added in WorkOS under a new id, which stands whether the + * replacement was mirrored before or after this delete. In EVERY case the + * deleted id is recorded in the deletion ledger (`membership_tombstones`), + * so a membership the mirror never held under its own id (replaced and + * deleted in WorkOS before the mirror saw it) cannot be inserted live by a + * scan that listed it before the deletion: `true` when the row was + * tombstoned OR the id was newly recorded. The organization row must + * already be mirrored, as for `upsertMembership`. + */ + readonly deleteMembership: ( + membership: WorkOsMirrorMembershipRef, + deletedAt: Date | null, + ) => Effect.Effect; + /** + * Tombstone a deleted WorkOS user: every membership of the account is + * tombstoned as by `deleteMembership` (as of `deletedAt`; a deleted user's + * membership ids never return), and the account row is kept (it anchors + * foreign keys) — or minted, when the mirror has not seen the user yet — + * with its profile cleared and stamped `deletedAt`, so a stale user + * payload cannot restore it. `false` when the account already carries + * this tombstone or a later one (a replayed delete). ONE transaction that + * locks the account row first (`FOR NO KEY UPDATE`) and holds it until + * the memberships are tombstoned: `upsertMembership` reads that row `FOR + * SHARE` before it inserts, so a membership write racing the deletion + * waits for it and sees the tombstone, or committed first and is + * tombstoned here — never a live membership left behind for a deleted + * user. + */ + readonly deleteUser: ( + accountId: string, + deletedAt: Date, + ) => Effect.Effect; + /** The id of the last WorkOS event applied, or `null` before the first run. */ + readonly getCursor: () => Effect.Effect; + /** + * Apply one reconciler page atomically: in a single transaction, + * compare-and-set the cursor from `prev` (`null` = no cursor yet) to + * `next`, and only if that succeeded apply `writes` in order. The cursor + * row stays locked until commit, so two runs applying pages serialize on + * it and the one whose `prev` is stale sees the moved cursor and writes + * nothing: `None` means another run owns the stream and the caller must + * stop. `Some` carries one outcome per write, in order. An empty `writes` + * is a bare cursor advance. + */ + readonly applyPage: ( + prev: string | null, + next: string, + writes: readonly WorkOsMirrorWrite[], + ) => Effect.Effect, WorkOsMirrorError>; + /** + * Apply one organization's backfill scan — the memberships (with their + * users) a WorkOS listing taken at `listedAt` contained — atomically: in a + * single transaction, compare-and-set the organization's `backfilled_at` + * forward to `listedAt`, and only if that succeeded upsert every listed + * user and membership and tombstone (as `deleteMembership` does, at + * `listedAt`) every membership of the organization the listing did NOT + * contain: the rows whose WorkOS id is not among the listed ones AND whose + * stamp is older than the listing. A row stamped at or after `listedAt` + * was written after the listing was taken (it could not be in it) and is + * left alone — its own event lands through the reconciler; a tombstone + * here would beat that event. Rows already tombstoned are left alone. + * + * The organization row stays locked until commit, so two overlapping + * scans serialize on it and the one whose listing is older than (or the + * same instant as) the recorded one writes nothing: `None` — the mirror + * already holds the organization as of a later listing, and a membership + * that listing no longer contained must not be inserted from this one. + * `None` too for an organization the mirror does not hold or has marked + * deleted: there is nothing to scan into. `Some` carries what was written. + */ + readonly applyOrganizationScan: ( + scan: WorkOsOrganizationScan, + ) => Effect.Effect, WorkOsMirrorError>; + /** + * The Events API replay boundary: the instant the FIRST one-off backfill + * run began reading WorkOS, or `null` if none has started. The + * reconciler's first run (no cursor yet) reads the stream from here — the + * backfill covers everything before it — and without a boundary it must + * not guess. + */ + readonly replayBoundary: () => Effect.Effect; + /** + * Record the replay boundary, ONCE: `at` is the instant a backfill run + * began reading WorkOS, and it is kept only when no boundary is recorded + * yet — `true` when this call recorded it. A later run never moves it: the + * backfill refreshes memberships and tombstones only, not organization + * names or deleted users' profiles, so a change after the first boundary + * is covered only by the events stream, which must still be read from + * there. Written BEFORE the run's first listing, so a run that fails + * part-way leaves the boundary standing and its retry keeps it — a + * `user.deleted` between the attempts stays inside the replay. Never + * touches the cursor: a stream already being followed keeps its position, + * and the boundary is then unused. + */ + readonly setReplayBoundary: (at: Date) => Effect.Effect; + /** + * When a backfill run first wrote EVERY live organization + * (`workos_sync.backfill_completed_at`), or `null` while none has. The + * first half of the mirror's READINESS for authorization: until a run has + * covered every organization, the mirror may lack members who have not + * signed in since it shipped, and a membership check read from it would + * deny them. The other half is the events reconciler being caught up, + * which its cursor row reports. + */ + readonly backfillCompletedAt: () => Effect.Effect; + /** + * Record that a backfill run has written every live organization, as of + * `at` — ONCE: a later completed run keeps the first mark (`false`), so + * readiness never flips back. Written only after the last organization + * was applied (or refused in favour of a later listing), so a run that + * fails part-way records nothing here. Mints the events row when absent, + * as `setReplayBoundary` does, and touches neither the cursor nor the + * boundary. + */ + readonly markBackfillCompleted: (at: Date) => Effect.Effect; + /** + * When a reconciler run last read the events stream to its end + * (`workos_sync.drained_at`), or `null` if none has. The second half of + * the mirror's readiness for authorization: a mirror whose reconciler + * has not caught up within the lag budget may still hold a membership + * WorkOS has since revoked. + */ + readonly drainedAt: () => Effect.Effect; + /** + * Record that a reconciler run read the stream to its end at `at`. Moves + * the mark forward only — a run that finished after a later one keeps the + * later mark — and only on the row a run already owns: the events row is + * minted by the boundary or the first cursor advance, so a missing row + * means nothing was drained and nothing is written (`false`). + */ + readonly markDrained: (at: Date) => Effect.Effect; + /** + * When the organization's membership list was last FULLY scanned from + * WorkOS (`backfillOrganization` in workos-mirror-backfill.ts), or `null` + * if it never was — or the organization is not mirrored. Until it has been, + * the mirror may hold only the members login and write-through happened to + * record, so a member count read from it is PARTIAL: every seat gate reads + * this first and scans the organization when it is `null`. Per + * organization, never database-wide, so an organization mirrored after a + * backfill ran (lazily, or by a sign-in) is never mistaken for a scanned + * one. + */ + readonly organizationBackfilledAt: ( + organizationId: string, + ) => Effect.Effect; +} + +// --------------------------------------------------------------------------- +// SDK payload → mirror row. The feeders (login callback, write-through, the +// backfill, the Events reconciler) all hand the mirror WorkOS objects; this is +// the one place their field names and ISO timestamps are translated. Typed +// structurally (the fields actually read) so the SDK's `User` / +// `OrganizationMembership`, an event payload, and a test fixture all fit. +// --------------------------------------------------------------------------- + +/** The WorkOS user fields the mirror reads. `User` from the SDK satisfies it. */ +export interface WorkOsUserPayload { + readonly id: string; + readonly email: string; + readonly firstName: string | null; + readonly lastName: string | null; + readonly profilePictureUrl: string | null; + readonly lastSignInAt: string | null; + readonly updatedAt: string; +} + +/** + * The WorkOS membership fields the mirror reads. `OrganizationMembership` from + * the SDK satisfies it; its `status` is exactly the mirror's `MemberStatus`. + */ +export interface WorkOsMembershipPayload { + readonly id: string; + readonly userId: string; + readonly organizationId: string; + readonly role: { readonly slug: string }; + readonly status: MemberStatus; + readonly updatedAt: string; +} + +/** Translate a WorkOS user payload to the row `upsertUser` stores. */ +export const mirrorUserFromWorkOs = (user: WorkOsUserPayload): WorkOsMirrorUser => ({ + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + avatarUrl: user.profilePictureUrl, + lastSignInAt: user.lastSignInAt === null ? null : new Date(user.lastSignInAt), + updatedAt: new Date(user.updatedAt), +}); + +/** Translate a WorkOS membership payload to the row `upsertMembership` stores. */ +export const mirrorMembershipFromWorkOs = ( + membership: WorkOsMembershipPayload, +): WorkOsMirrorMembership => ({ + id: membership.id, + accountId: membership.userId, + organizationId: membership.organizationId, + role: membership.role.slug, + status: membership.status, + updatedAt: new Date(membership.updatedAt), +}); + +/** + * The `workos_sync` row of the one WorkOS events stream the reconciler + * follows. A row id rather than a singleton table so a second stream + * (another WorkOS environment, a replay) can be added without a schema + * change; the same row carries the backfill's replay boundary and + * completion mark. Not a `db/schema.ts` export: that module's exports are + * enumerated as tables by the purge-coverage test. + */ +export const WORKOS_EVENTS_STREAM_ID = "events"; + +// A raw `sql` fragment binds a Date without the column's driver mapping, so +// every instant below is passed as ISO text and cast. +const instant = (at: Date) => sql`${at.toISOString()}::timestamptz`; + +// An account tombstone moves the row's timestamp to the deletion time — +// never backwards, so a row that somehow carries a newer WorkOS timestamp +// keeps it and the upsert guard stays at least as strict. +const noEarlierThan = (column: AnyPgColumn, at: Date) => sql`greatest(${column}, ${instant(at)})`; + +// A membership tombstone keeps the row, marks it `inactive`, and records the +// deletion in `deleted_at`: the instant the caller holds, or `now()` when it +// holds none (see `deleteMembership`). `workos_updated_at` is left as it is +// — the tombstone is protected by the deleted id, not by its stamp. +const tombstone = (deletedAt: Date | null) => ({ + status: "inactive" as const, + deletedAt: deletedAt === null ? sql`now()` : instant(deletedAt), +}); + +// Which stored membership rows a payload for membership `id` stamped +// `updatedAt` may overwrite. A row WorkOS still holds (`deleted_at` null) is +// ordered by time: a row with no stamp (predating the mirror), any row +// stamped earlier, and a LIVE row stamped the same instant — feeders replay +// the same payload and must converge, not stall. An `inactive` row stamped +// the same instant is not overwritten: a deactivation at T beats an active +// payload at T. A deletion tombstone is ordered by IDENTITY: never +// overwritten by a payload naming the deleted id — a deleted WorkOS +// membership id never returns, however the payload is stamped — and taken +// over by a payload naming another id, a replacement membership WorkOS +// created after the deletion and so stamped after the row's last state, +// under the plain timestamp rule. A tombstone with no id at all (a +// pre-mirror row of a deleted user) is never taken over: the user is gone. +const membershipAcceptsPayload = (id: string, updatedAt: Date) => + or( + and( + isNull(memberships.deletedAt), + or( + isNull(memberships.workosUpdatedAt), + lt(memberships.workosUpdatedAt, updatedAt), + and(eq(memberships.workosUpdatedAt, updatedAt), ne(memberships.status, "inactive")), + ), + ), + and( + isNotNull(memberships.deletedAt), + ne(memberships.membershipId, id), + or(isNull(memberships.workosUpdatedAt), lt(memberships.workosUpdatedAt, updatedAt)), + ), + ); + +// A deleted user's account row, as `deleteUser` leaves it: the profile is +// cleared (every WorkOS user payload carries an email, so a stamped row with +// none was written by `deleteUser`) and the stamp is the deletion time. A row +// minted bare by `ensureAccount` has no stamp either, and is not a tombstone. +// Judged in code, over a row read under a lock (`writeMembership`), not as a +// predicate in the read: a `SELECT ... FOR SHARE` locks only the rows it +// returns, so a read filtered to tombstones would lock nothing for a live +// row — the one case the lock exists for. +const isAccountTombstone = (account: { + readonly email: string | null; + readonly workosUpdatedAt: Date | null; +}): boolean => account.email === null && account.workosUpdatedAt !== null; + +// The same rule as for a membership row, for an account row. A tombstone +// takes no payload stamped at or before the deletion; a bare row takes any. +const accountAcceptsPayload = (updatedAt: Date) => + or( + isNull(accounts.workosUpdatedAt), + lt(accounts.workosUpdatedAt, updatedAt), + and(eq(accounts.workosUpdatedAt, updatedAt), isNotNull(accounts.email)), + ); + +// A delete is applied only to a row not yet tombstoned: a replayed deletion +// changes nothing and reports so. +const notDeleted = isNull(memberships.deletedAt); + +// Whether membership `id` is in the deletion ledger: a WorkOS id a delete or +// a scan has named as gone, which never returns. Identity alone — the +// ledger records WHEN for the record, not for ordering. +const membershipIdDeleted = async (db: DrizzleDb, id: string): Promise => { + const rows = await db + .select({ membershipId: membershipTombstones.membershipId }) + .from(membershipTombstones) + .where(eq(membershipTombstones.membershipId, id)); + return rows.length > 0; +}; + +// Which (account, organization) row a delete of membership `id` may +// tombstone: the row carrying THIS id — whatever its stamp, a deleted id is +// never reused — or a row with no id at all (written before the mirror +// recorded WorkOS ids; the delete fills the id in). Never a row under +// ANOTHER id: that is a different membership of the same account and +// organization, the member re-added in WorkOS after this one was removed, +// and it stands however the two are stamped. Identity orders them, +// timestamps do not. A row already tombstoned is left alone (`notDeleted`) +// so a replayed delete reports `false`. +const membershipDeletableBy = (id: string) => + and(or(isNull(memberships.membershipId), eq(memberships.membershipId, id)), notDeleted); + +// The write queries, over `db` or over a transaction handle (drizzle's is a +// `PgDatabase` too): the one `applyPage` or `applyOrganizationScan` opens, +// or the one the store opens per `upsertMembership`. Each answers whether +// it wrote a row; the public shape and the transactions translate that. +const makeWrites = (db: DrizzleDb) => { + const ensureAccount = (id: string) => + db.insert(accounts).values({ id }).onConflictDoNothing({ target: accounts.id }); + + // The membership upsert under the row guard (`membershipAcceptsPayload`) + // and the account tombstone, never the organization mark. + const writeMembership = async (membership: WorkOsMirrorMembership): Promise => { + await ensureAccount(membership.accountId); + // Never for a DELETED user. The row guard below orders a payload against + // the membership row it would overwrite; a membership the mirror has not + // seen yet has no row, so the guard cannot refuse the INSERT — and a + // feeder that fetched the membership before the user was deleted and + // writes it after (a stalled login list, the backfill's older listing) + // would insert it live. The account tombstone is the one row a deleted + // user always leaves behind, so it is consulted first, by identity: + // WorkOS never reuses a user id, so no payload naming a tombstoned + // account is ever current. + // + // Read FOR SHARE, held until the transaction `db` belongs to commits: a + // `deleteUser` applying the deletion at this moment holds the row FOR NO + // KEY UPDATE until ITS commit, so this read waits for it and sees the + // tombstone — and a deletion that arrives after this read waits for this + // transaction, then tombstones the membership it inserted. Unlocked, a + // deletion could land between this read and the insert below and leave + // a live membership for a deleted user. + const locked = await db + .select({ email: accounts.email, workosUpdatedAt: accounts.workosUpdatedAt }) + .from(accounts) + .where(eq(accounts.id, membership.accountId)) + .for("share"); + const account = locked[0]; + // `ensureAccount` guarantees the row; accounts are tombstoned, never + // deleted, so a missing one is refused like a tombstone, not written for. + if (account === undefined || isAccountTombstone(account)) return false; + // Never under a DELETED membership id. The row guard below can only + // refuse against the id the row holds; a delete of THIS id that found + // the row under another id (see the header) left only the ledger entry + // behind, and that is what refuses the payload here. + if (await membershipIdDeleted(db, membership.id)) return false; + const written = await db + .insert(memberships) + .values({ + accountId: membership.accountId, + organizationId: membership.organizationId, + membershipId: membership.id, + role: membership.role, + status: membership.status, + workosUpdatedAt: membership.updatedAt, + }) + .onConflictDoUpdate({ + target: [memberships.accountId, memberships.organizationId], + set: { + membershipId: membership.id, + role: membership.role, + status: membership.status, + workosUpdatedAt: membership.updatedAt, + // A replacement taking over a tombstone is live again; a row that + // was not tombstoned had nothing here. + deletedAt: null, + }, + setWhere: membershipAcceptsPayload(membership.id, membership.updatedAt), + }) + .returning({ accountId: memberships.accountId }); + return written.length > 0; + }; + + return { + upsertUser: async (user: WorkOsMirrorUser): Promise => { + const written = await db + .insert(accounts) + .values({ + id: user.id, + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + avatarUrl: user.avatarUrl, + lastSignInAt: user.lastSignInAt, + workosUpdatedAt: user.updatedAt, + }) + .onConflictDoUpdate({ + target: accounts.id, + set: { + email: user.email, + firstName: user.firstName, + lastName: user.lastName, + avatarUrl: user.avatarUrl, + lastSignInAt: user.lastSignInAt, + workosUpdatedAt: user.updatedAt, + }, + setWhere: accountAcceptsPayload(user.updatedAt), + }) + .returning({ id: accounts.id }); + return written.length > 0; + }, + + upsertMembership: async (membership: WorkOsMirrorMembership): Promise => { + // Never into an organization the mirror holds as DELETED: its + // memberships are gone from WorkOS and purged (or being purged) here, + // so a payload that still names it was fetched before the deletion — + // a login that stalled across the purge, a stale event — and writing + // it would grant access to a deleted organization. The tombstone row + // outlives the purge for exactly this check. And never from a payload + // stamped before the organization's last full scan: the scan is the + // complete listing as of `backfilled_at`, so an older payload either + // repeats a row the scan wrote (nothing to change) or names a + // membership the scan found gone — revoked before the mirror's events + // replay begins, so no event would ever tombstone it again. The row + // is read FOR SHARE, so a scan claiming it at this moment + // (`claimOrganizationScan`, an UPDATE that holds the row until its + // transaction commits) and the purge marking it deleted are waited + // for, and the mark read is the committed one: a scan cannot slip + // between this read and the write below and leave a membership it + // proved revoked inserted after it. The lock is held until the + // transaction `db` belongs to ends — `makeWorkOsMirrorStore` opens one + // per call, `applyPage` runs this on the page's own — which is what + // orders the write after the scan. An organization the mirror does + // not hold at all is still a foreign-key failure in the write. + const organization = await db + .select({ + deletedAt: organizations.deletedAt, + backfilledAt: organizations.backfilledAt, + }) + .from(organizations) + .where(eq(organizations.id, membership.organizationId)) + .for("share"); + const row = organization[0]; + if (row?.deletedAt != null) return false; + if (row?.backfilledAt != null && membership.updatedAt < row.backfilledAt) return false; + return writeMembership(membership); + }, + + // A scan's own membership writes: the scan has just claimed the + // organization as of its listing, so its payloads are exactly what the + // mark stands for and are not ordered against it. + writeScannedMembership: writeMembership, + + // An upsert, like the membership write it guards against: a delete the + // mirror sees before the membership itself (the reconciler ahead of the + // backfill) must leave the tombstone behind, or the later, older payload + // would insert the row live. + deleteMembership: async ( + membership: WorkOsMirrorMembershipRef, + deletedAt: Date | null, + ): Promise => { + await ensureAccount(membership.accountId); + // Lock the account row FOR NO KEY UPDATE, as `deleteUser` does: a + // membership write of this user reads the row FOR SHARE before it + // consults the ledger (`writeMembership`), so a write racing this + // delete either waits here and then finds the ledger entry, or + // committed first and is tombstoned by the row upsert below. Without + // the lock a write could pass the ledger check before this entry + // lands and insert the deleted membership live after it. + await db + .select({ id: accounts.id }) + .from(accounts) + .where(eq(accounts.id, membership.accountId)) + .for("no key update"); + // The ledger entry FIRST, whatever the row holds: the one record of + // the deletion that does not depend on the row carrying the deleted + // id. A replayed delete finds it there and records nothing new. + const recorded = await db + .insert(membershipTombstones) + .values({ + membershipId: membership.id, + accountId: membership.accountId, + organizationId: membership.organizationId, + ...(deletedAt === null ? {} : { deletedAt }), + }) + .onConflictDoNothing({ target: membershipTombstones.membershipId }) + .returning({ membershipId: membershipTombstones.membershipId }); + const tombstoned = await db + .insert(memberships) + .values({ + accountId: membership.accountId, + organizationId: membership.organizationId, + membershipId: membership.id, + ...tombstone(deletedAt), + }) + .onConflictDoUpdate({ + target: [memberships.accountId, memberships.organizationId], + set: { membershipId: membership.id, ...tombstone(deletedAt) }, + setWhere: membershipDeletableBy(membership.id), + }) + .returning({ accountId: memberships.accountId }); + return recorded.length > 0 || tombstoned.length > 0; + }, + + // Only inside a transaction: the account row lock taken first is what + // orders this against a membership write of the same user, and it lasts + // exactly as long as the transaction `db` belongs to — the one the store + // opens per call. + deleteUser: async (accountId: string, deletedAt: Date): Promise => { + // Lock the account row FIRST — minted bare when absent, so there is a + // row to lock (the tombstone is minted for the same reason as the + // membership one: a later, older payload must find it) — and hold it + // FOR NO KEY UPDATE until commit. A membership write reads the row FOR + // SHARE before it inserts (`writeMembership`), so a write racing this + // delete either waits here and then sees the tombstone, or committed + // before this lock was granted and is caught by the membership + // tombstoning below. Without the lock a write could read the row live + // and insert its membership after the tombstoning had run. + await ensureAccount(accountId); + await db + .select({ id: accounts.id }) + .from(accounts) + .where(eq(accounts.id, accountId)) + .for("no key update"); + // The account tombstone: profile cleared, stamped with the deletion. + // Applied unless the row already carries this tombstone or a later one + // (a replayed delete). + const cleared = await db + .update(accounts) + .set({ + email: null, + firstName: null, + lastName: null, + avatarUrl: null, + workosUpdatedAt: noEarlierThan(accounts.workosUpdatedAt, deletedAt), + }) + .where( + and( + eq(accounts.id, accountId), + or( + isNotNull(accounts.email), + isNull(accounts.workosUpdatedAt), + lt(accounts.workosUpdatedAt, deletedAt), + ), + ), + ) + .returning({ id: accounts.id }); + await db + .update(memberships) + .set(tombstone(deletedAt)) + .where(and(eq(memberships.accountId, accountId), notDeleted)); + return cleared.length > 0; + }, + + // An UPDATE, never an insert: the slug is minted only by + // `upsertOrganization` (auth/user-store.ts), and an org purged by cloud's + // own deletion flow must not come back — with a fresh slug and no members + // — because a rename that preceded the deletion is replayed after it. + // Only of a LIVE row, and only from a payload at least as new as the one + // that last named it: the same guard `upsertOrganization` applies, so + // an event rename and a sign-in's name (stamped by its fetch) order + // each other however they arrive. + renameOrganization: async ( + organizationId: string, + name: string, + updatedAt: Date, + ): Promise => { + const renamed = await db + .update(organizations) + .set({ name, workosUpdatedAt: updatedAt }) + .where( + and( + eq(organizations.id, organizationId), + isNull(organizations.deletedAt), + organizationAcceptsName(updatedAt), + ), + ) + .returning({ id: organizations.id }); + if (renamed.length > 0) return "applied"; + // Refused: tell a live row the guard held back (`stale`) from a row + // the mirror does not hold or holds as deleted (`absent`). + const live = await db + .select({ id: organizations.id }) + .from(organizations) + .where(and(eq(organizations.id, organizationId), isNull(organizations.deletedAt))); + return live.length > 0 ? "stale" : "absent"; + }, + + // Marks a live row, or MINTS a tombstone row when the mirror has never + // seen the organization: an org created, populated, and deleted in the + // WorkOS dashboard before anyone signed in leaves no row behind + // otherwise, and a login that fetched its memberships before the + // deletion (and stalled) would then mint the org live, with nothing + // left in the stream to revoke it — this event is consumed. A row + // already marked is left alone: cloud's own deletion flow marks the org + // before deleting it in WorkOS, so the event that follows finds the + // mark already there and changes nothing — `false`, as for a replayed + // event. Minted through the one slug mint point (`insertOrganization`), + // so a tombstone is a routable, unique-slugged row like any other; the + // mark alone is what refuses it. + markOrganizationDeleted: async ( + organizationId: string, + name: string, + deletedAt: Date, + ): Promise => { + const mark = async () => { + const marked = await db + .update(organizations) + .set({ deletedAt }) + .where(and(eq(organizations.id, organizationId), isNull(organizations.deletedAt))) + .returning({ id: organizations.id }); + return marked.length > 0; + }; + if (await mark()) return true; + const held = await db + .select({ id: organizations.id }) + .from(organizations) + .where(eq(organizations.id, organizationId)); + if (held.length > 0) return false; + const minted = await insertOrganization(db, { + id: organizationId, + name, + workosUpdatedAt: null, + deletedAt, + }); + // A concurrent feeder may have minted the row LIVE between the read + // and the insert (the insert then yields its row): mark that one. + return minted.deletedAt !== null || mark(); + }, + + // Tombstone (at `listedAt`) every membership of the organization that a + // listing taken at `listedAt` did not contain. Only inside a scan's + // transaction, after its `backfilled_at` CAS: on its own this could + // tombstone what a LATER scan just wrote. + tombstoneMembershipsExcept: async ( + organizationId: string, + membershipIds: readonly string[], + listedAt: Date, + ): Promise => { + const tombstoned = await db + .update(memberships) + .set(tombstone(listedAt)) + .where( + and( + eq(memberships.organizationId, organizationId), + // A row with no WorkOS id predates the mirror and is not in + // any listing; if WorkOS still holds the membership, the + // scan's upsert has just filled the id in. + or( + isNull(memberships.membershipId), + notInArray(memberships.membershipId, [...membershipIds]), + ), + // Only rows the listing could have contained: stamped before + // it was taken (or never stamped). Anything newer was written + // after the listing and is not missing from it. + or(isNull(memberships.workosUpdatedAt), lt(memberships.workosUpdatedAt, listedAt)), + notDeleted, + ), + ) + .returning({ + accountId: memberships.accountId, + membershipId: memberships.membershipId, + }); + // The ids the listing proved gone go into the ledger too, as any other + // delete's: they never return. A row with no id has none to record. + const gone = tombstoned.flatMap((row) => + row.membershipId === null + ? [] + : [ + { + membershipId: row.membershipId, + accountId: row.accountId, + organizationId, + deletedAt: listedAt, + }, + ], + ); + if (gone.length > 0) { + await db + .insert(membershipTombstones) + .values(gone) + .onConflictDoNothing({ target: membershipTombstones.membershipId }); + } + return tombstoned.length; + }, + }; +}; + +type Writes = ReturnType; + +const applyWrite = (writes: Writes, write: WorkOsMirrorWrite): Promise => + WorkOsMirrorWrite.$match(write, { + UpsertUser: async ({ user }) => ((await writes.upsertUser(user)) ? "applied" : "stale"), + UpsertMembership: async ({ membership }) => + (await writes.upsertMembership(membership)) ? "applied" : "stale", + UpsertMember: async ({ user, membership }) => { + await writes.upsertUser(user); + return (await writes.upsertMembership(membership)) ? "applied" : "stale"; + }, + DeleteMembership: async ({ membership, deletedAt }) => + (await writes.deleteMembership(membership, deletedAt)) ? "applied" : "absent", + DeleteUser: async ({ accountId, deletedAt }) => + (await writes.deleteUser(accountId, deletedAt)) ? "applied" : "absent", + RenameOrganization: ({ organizationId, name, updatedAt }) => + writes.renameOrganization(organizationId, name, updatedAt), + MarkOrganizationDeleted: async ({ organizationId, name, deletedAt }) => + (await writes.markOrganizationDeleted(organizationId, name, deletedAt)) + ? "applied" + : "absent", + }); + +// Compare-and-set the events cursor. Run inside a transaction this also +// LOCKS the cursor row until commit: a concurrent run's CAS waits here, then +// re-reads the moved cursor and matches nothing. +const advanceCursor = async (db: DrizzleDb, prev: string | null, next: string) => { + const now = new Date(); + if (prev === null) { + // First advance: mint the row, or claim an existing row that still has + // no cursor. A row that already carries one belongs to another run and + // is left alone. + const written = await db + .insert(workosSync) + .values({ id: WORKOS_EVENTS_STREAM_ID, cursor: next, updatedAt: now }) + .onConflictDoUpdate({ + target: workosSync.id, + set: { cursor: next, updatedAt: now }, + setWhere: isNull(workosSync.cursor), + }) + .returning({ id: workosSync.id }); + return written.length > 0; + } + const written = await db + .update(workosSync) + .set({ cursor: next, updatedAt: now }) + .where(and(eq(workosSync.id, WORKOS_EVENTS_STREAM_ID), eq(workosSync.cursor, prev))) + .returning({ id: workosSync.id }); + return written.length > 0; +}; + +// Claim the organization for a scan listed at `listedAt`: move its +// `backfilled_at` forward to `listedAt` if the recorded mark is older (or +// missing). Run inside a transaction this also LOCKS the organization row +// until commit, so an overlapping scan's claim waits here, then reads the +// moved mark and matches nothing. An organization marked deleted is never +// claimed: its memberships are being (or have been) purged, and a scan +// that listed them before the deletion must not write them back. +const claimOrganizationScan = async (db: DrizzleDb, organizationId: string, listedAt: Date) => { + const claimed = await db + .update(organizations) + .set({ backfilledAt: listedAt }) + .where( + and( + eq(organizations.id, organizationId), + isNull(organizations.deletedAt), + or(isNull(organizations.backfilledAt), lt(organizations.backfilledAt, listedAt)), + ), + ) + .returning({ id: organizations.id }); + return claimed.length > 0; +}; + +/** + * The mirror's write operations over `db`. Failures are `WorkOsMirrorError` + * naming the operation and the classified driver reason; the full cause is + * logged at the boundary. + */ +export const makeWorkOsMirrorStore = (db: DrizzleDb): WorkOsMirrorShape => { + const run = (op: string, fn: () => Promise) => + withServiceLogging( + `workos_mirror.${op}`, + (failure) => + new WorkOsMirrorError({ + operation: op, + reason: userStoreReasonFromCause(failure), + }), + tryPromiseService(fn), + ); + + const writes = makeWrites(db); + + return { + upsertUser: (user) => run("upsertUser", () => writes.upsertUser(user)), + + // One transaction per call: the organization guard inside locks the org + // row until the write has landed (see `makeWrites`). + upsertMembership: (membership) => + run("upsertMembership", () => + db.transaction((tx) => makeWrites(tx).upsertMembership(membership)), + ), + + // One transaction per call: the ledger entry and the row tombstone land + // together or not at all. + deleteMembership: (membership, deletedAt) => + run("deleteMembership", () => + db.transaction((tx) => makeWrites(tx).deleteMembership(membership, deletedAt)), + ), + + // One transaction per call: the account row lock inside is held until + // the memberships are tombstoned (see `makeWrites`). + deleteUser: (accountId, deletedAt) => + run("deleteUser", () => + db.transaction((tx) => makeWrites(tx).deleteUser(accountId, deletedAt)), + ), + + getCursor: () => + run("getCursor", async () => { + const rows = await db + .select({ cursor: workosSync.cursor }) + .from(workosSync) + .where(eq(workosSync.id, WORKOS_EVENTS_STREAM_ID)); + // No row yet is the same state as a row with no cursor: nothing applied. + return rows[0]?.cursor ?? null; + }), + + applyPage: (prev, next, pageWrites) => + run("applyPage", () => + db.transaction(async (tx) => { + // The CAS comes FIRST so the lock is held for every write below; + // a run that lost the stream commits an empty transaction. + const owned = await advanceCursor(tx, prev, next); + if (!owned) return Option.none(); + const txWrites = makeWrites(tx); + const outcomes: WorkOsMirrorWriteOutcome[] = []; + for (const write of pageWrites) { + outcomes.push(await applyWrite(txWrites, write)); + } + return Option.some(outcomes); + }), + ), + + applyOrganizationScan: (scan) => + run("applyOrganizationScan", () => + db.transaction(async (tx) => { + // The claim comes FIRST so the lock is held for every write below; + // a scan that lost to a later listing commits an empty transaction. + const claimed = await claimOrganizationScan(tx, scan.organizationId, scan.listedAt); + if (!claimed) return Option.none(); + const txWrites = makeWrites(tx); + let usersWritten = 0; + let membershipsWritten = 0; + for (const member of scan.members) { + if (await txWrites.upsertUser(member.user)) usersWritten += 1; + if (await txWrites.writeScannedMembership(member.membership)) membershipsWritten += 1; + } + const membershipsTombstoned = await txWrites.tombstoneMembershipsExcept( + scan.organizationId, + scan.members.map((member) => member.membership.id), + scan.listedAt, + ); + const written: WorkOsOrganizationScanWrites = { + usersWritten, + membershipsWritten, + membershipsTombstoned, + }; + return Option.some(written); + }), + ), + + replayBoundary: () => + run("replayBoundary", async () => { + const rows = await db + .select({ rangeStart: workosSync.rangeStart }) + .from(workosSync) + .where(eq(workosSync.id, WORKOS_EVENTS_STREAM_ID)); + return rows[0]?.rangeStart ?? null; + }), + + setReplayBoundary: (at) => + run("setReplayBoundary", async () => { + const recorded = await db + .insert(workosSync) + .values({ + id: WORKOS_EVENTS_STREAM_ID, + cursor: null, + rangeStart: at, + updatedAt: at, + }) + .onConflictDoUpdate({ + target: workosSync.id, + set: { rangeStart: at }, + // A boundary already recorded stands, whatever this run's is. + setWhere: isNull(workosSync.rangeStart), + }) + .returning({ id: workosSync.id }); + return recorded.length > 0; + }), + + backfillCompletedAt: () => + run("backfillCompletedAt", async () => { + const rows = await db + .select({ backfillCompletedAt: workosSync.backfillCompletedAt }) + .from(workosSync) + .where(eq(workosSync.id, WORKOS_EVENTS_STREAM_ID)); + return rows[0]?.backfillCompletedAt ?? null; + }), + + markBackfillCompleted: (at) => + run("markBackfillCompleted", async () => { + const recorded = await db + .insert(workosSync) + .values({ + id: WORKOS_EVENTS_STREAM_ID, + cursor: null, + backfillCompletedAt: at, + updatedAt: at, + }) + .onConflictDoUpdate({ + target: workosSync.id, + set: { backfillCompletedAt: at }, + // The first completion stands, whatever this run's is. + setWhere: isNull(workosSync.backfillCompletedAt), + }) + .returning({ id: workosSync.id }); + return recorded.length > 0; + }), + + drainedAt: () => + run("drainedAt", async () => { + const rows = await db + .select({ drainedAt: workosSync.drainedAt }) + .from(workosSync) + .where(eq(workosSync.id, WORKOS_EVENTS_STREAM_ID)); + return rows[0]?.drainedAt ?? null; + }), + + markDrained: (at) => + run("markDrained", async () => { + const moved = await db + .update(workosSync) + .set({ drainedAt: at }) + .where( + and( + eq(workosSync.id, WORKOS_EVENTS_STREAM_ID), + or(isNull(workosSync.drainedAt), lt(workosSync.drainedAt, at)), + ), + ) + .returning({ id: workosSync.id }); + return moved.length > 0; + }), + + organizationBackfilledAt: (organizationId) => + run("organizationBackfilledAt", async () => { + const rows = await db + .select({ backfilledAt: organizations.backfilledAt }) + .from(organizations) + .where(eq(organizations.id, organizationId)); + return rows[0]?.backfilledAt ?? null; + }), + }; +}; diff --git a/apps/cloud/src/auth/workos-mirror.node.test.ts b/apps/cloud/src/auth/workos-mirror.node.test.ts new file mode 100644 index 0000000000..2ac33a9338 --- /dev/null +++ b/apps/cloud/src/auth/workos-mirror.node.test.ts @@ -0,0 +1,1415 @@ +// --------------------------------------------------------------------------- +// The cloud membership mirror: `WorkOsMirror` (writes) + the cloud +// `MemberDirectory` (reads), against the real PGlite Postgres every cloud +// unit test runs on (scripts/test-globalsetup.ts), through the same +// `DbService.Live` the request path uses. +// +// What this pins: +// - an older WorkOS payload never overwrites a newer row (replay-safe) +// - a delete tombstones the row (inactive, `deleted_at` set) by IDENTITY: +// no payload naming the deleted membership id ever reactivates it, +// however it is stamped — not a stale one, not one stamped after the +// removal — while a replacement under a NEW id takes the row over live; +// every default read treats the tombstone as no membership +// - a membership WorkOS merely deactivated (inactive, no `deleted_at`) +// reactivates under the same id like any other update +// - a delete of a membership or user the mirror has not seen yet leaves +// the tombstone behind, so the backfill's older payload cannot insert +// the row live afterwards +// - a delete of a membership id the row does NOT hold (the member's row +// still carries the id it was replaced from) is recorded all the same, +// so a later, newer payload of the deleted id cannot take the row over +// - a deleted user takes no membership at all, however the payload is +// stamped: the account tombstone refuses the insert by identity — and a +// membership write racing the deletion waits for its commit and sees +// the tombstone, never inserting a live membership for a deleted user +// - a delete with no WorkOS instant keeps the row's own WorkOS stamp, so +// a replacement membership WorkOS created meanwhile is not refused, +// while the removed membership's own payload still is +// - the cursor advances only by compare-and-set (one owner per stream), +// and a page that loses the CAS writes nothing +// - a backfill scan is applied only if its listing is newer than the one +// already applied to the organization (one owner per listing instant), +// so an older listing cannot insert a membership the newer one lacked; +// a deleted organization takes no scan +// - a membership payload stamped before the organization's last scan is +// refused (a login list fetched before a revocation the scan already +// applied cannot reinstate it); one stamped at or after it is written — +// and a write racing a scan waits for the scan's commit and sees its +// mark, so it cannot insert a membership the scan just proved revoked +// - 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, Duration, Effect, Fiber, Layer, Option } from "effect"; + +import { MemberDirectory } from "@executor-js/api/server"; + +import { DbService, makeDbLayer } from "../db/db"; +import { accounts, organizations } from "../db/schema"; +import { cloudMemberDirectoryLayer } from "./member-directory"; +import { UserStoreService } from "./context"; +import { + WorkOsMirror, + WorkOsMirrorWrite, + type WorkOsMirrorMembership, + type WorkOsMirrorUser, +} from "./workos-mirror"; +import { makeWorkOsMirrorStore } from "./workos-mirror-store"; +import { + MIRROR_RECONCILER_LAG_BUDGET, + MirrorReadinessState, + mirrorReadinessFrom, + readMirrorReadiness, +} from "./mirror-readiness-store"; + +const DbLive = DbService.Live; +const Services = Layer.mergeAll( + WorkOsMirror.Live, + cloudMemberDirectoryLayer, + UserStoreService.Live, +).pipe(Layer.provideMerge(DbLive)); + +const run = ( + body: Effect.Effect, +) => Effect.runPromise(body.pipe(Effect.provide(Services), Effect.scoped)); + +/** The events row is instance-wide: drop it so a test starts as a never-backfilled database. */ +const clearEventsRow = Effect.flatMap(DbService.asEffect(), ({ db }) => + Effect.promise(() => db.execute(sql`delete from workos_sync where id = 'events'`)), +); + +const at = (iso: string) => new Date(iso); +const T1 = at("2026-01-01T00:00:00.000Z"); +const T2 = at("2026-01-02T00:00:00.000Z"); +const T3 = at("2026-01-03T00:00:00.000Z"); +const T4 = at("2026-01-04T00:00:00.000Z"); + +// Every test mints its own org so the shared test database never couples +// them; ids are synthetic placeholders, never real identities. +const freshOrg = () => + Effect.gen(function* () { + const id = `org_${crypto.randomUUID().replaceAll("-", "")}`; + const store = yield* UserStoreService; + yield* store.use("upsertOrganization", (s) => + s.upsertOrganization({ id, name: "Mirror Org", updatedAt: T1 }), + ); + return id; + }); + +const user = (id: string, overrides: Partial = {}): WorkOsMirrorUser => ({ + id, + email: `${id}@placeholder.test`, + firstName: null, + lastName: null, + avatarUrl: null, + lastSignInAt: null, + updatedAt: T1, + ...overrides, +}); + +const membership = ( + organizationId: string, + accountId: string, + overrides: Partial = {}, +): WorkOsMirrorMembership => ({ + id: `om_${accountId}_${organizationId}`, + accountId, + organizationId, + role: "member", + status: "active", + updatedAt: T1, + ...overrides, +}); + +describe("WorkOsMirror upserts", () => { + it("ignores a user payload older than the stored row, accepts a newer one", async () => { + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const directory = yield* MemberDirectory; + const org = yield* freshOrg(); + const id = `user_${crypto.randomUUID()}`; + yield* mirror.upsertMembership(membership(org, id)); + + const first = yield* mirror.upsertUser(user(id, { firstName: "Ada", updatedAt: T2 })); + const stale = yield* mirror.upsertUser(user(id, { firstName: "Stale", updatedAt: T1 })); + const afterStale = yield* directory.membership(id, org); + const newer = yield* mirror.upsertUser(user(id, { firstName: "Newer", updatedAt: T3 })); + const afterNewer = yield* directory.membership(id, org); + return { first, stale, newer, afterStale, afterNewer }; + }), + ); + expect(result.first).toBe(true); + expect(result.stale, "an older payload is reported as not written").toBe(false); + expect(result.afterStale?.name, "and left the newer row untouched").toBe("Ada"); + expect(result.newer).toBe(true); + expect(result.afterNewer?.name).toBe("Newer"); + }); + + it("ignores a membership payload older than the stored row", async () => { + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const directory = yield* MemberDirectory; + const org = yield* freshOrg(); + const id = `user_${crypto.randomUUID()}`; + yield* mirror.upsertUser(user(id)); + yield* mirror.upsertMembership(membership(org, id, { role: "admin", updatedAt: T2 })); + const stale = yield* mirror.upsertMembership( + membership(org, id, { + role: "member", + status: "inactive", + updatedAt: T1, + }), + ); + const row = yield* directory.membership(id, org); + const equal = yield* mirror.upsertMembership( + membership(org, id, { role: "member", updatedAt: T2 }), + ); + const afterEqual = yield* directory.membership(id, org); + return { stale, row, equal, afterEqual }; + }), + ); + expect(result.stale).toBe(false); + expect(result.row?.role).toBe("admin"); + expect(result.row?.status).toBe("active"); + // Equal timestamps are accepted: the feeders replay the same payload and + // must converge, not stall. + expect(result.equal).toBe(true); + expect(result.afterEqual?.role).toBe("member"); + }); + + it("mints the account row when a membership arrives before its user", async () => { + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const directory = yield* MemberDirectory; + const org = yield* freshOrg(); + const id = `user_${crypto.randomUUID()}`; + const written = yield* mirror.upsertMembership(membership(org, id)); + const bare = yield* directory.membership(id, org); + // The bare row has no timestamp, so the first user payload — even an + // "old" one — fills it. + yield* mirror.upsertUser(user(id, { firstName: "Late", updatedAt: T1 })); + const filled = yield* directory.membership(id, org); + return { written, bare, filled }; + }), + ); + expect(result.written).toBe(true); + expect(result.bare).not.toBeNull(); + expect(result.bare?.email).toBeNull(); + expect(result.filled?.name).toBe("Late"); + }); + + it("tombstones a deleted membership by identity: no payload of that id resurrects it, a replacement under a new id does", async () => { + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const directory = yield* MemberDirectory; + const org = yield* freshOrg(); + const id = `user_${crypto.randomUUID()}`; + const membershipId = `om_${id}_${org}`; + yield* mirror.upsertUser(user(id)); + yield* mirror.upsertMembership(membership(org, id, { id: membershipId, updatedAt: T1 })); + + const ref = { id: membershipId, accountId: id, organizationId: org }; + const removed = yield* mirror.deleteMembership(ref, T2); + const removedAgain = yield* mirror.deleteMembership(ref, T2); + const byDefault = yield* directory.membership(id, org); + const listed = yield* directory.members(org); + const asInactive = yield* directory.membership(id, org, ["inactive"]); + + // A feeder that fetched the membership BEFORE the deletion (login, + // backfill) writes it after: the guard refuses it. + const stale = yield* mirror.upsertMembership( + membership(org, id, { id: membershipId, updatedAt: T1 }), + ); + const afterStale = yield* directory.membership(id, org, ["inactive"]); + // An active payload stamped the SAME instant as the deletion: the + // tombstone wins, a deletion at T is never undone by a payload at T. + const equal = yield* mirror.upsertMembership( + membership(org, id, { id: membershipId, updatedAt: T2 }), + ); + const afterEqual = yield* directory.membership(id, org, ["inactive"]); + // A role change issued before the removal and delivered after it (a + // stalled login list, a lagging feeder), stamped NEWER than anything + // the row holds — the payload a timestamp guard would let through. + // Same id: the membership is deleted, it never returns. + const newerSameId = yield* mirror.upsertMembership( + membership(org, id, { + id: membershipId, + role: "admin", + updatedAt: T3, + }), + ); + const afterNewerSameId = yield* directory.membership(id, org, ["inactive"]); + // The member re-added in WorkOS: a payload newer than the deletion, + // under a new membership id (a deleted id is never reused). + const readded = yield* mirror.upsertMembership( + membership(org, id, { id: `${membershipId}_2`, updatedAt: T3 }), + ); + const afterReadd = yield* directory.membership(id, org); + // The OLD membership's deletion, replayed after the re-add: the + // newer row, under its new id, stands. + const lateDelete = yield* mirror.deleteMembership(ref, T2); + const afterLateDelete = yield* directory.membership(id, org); + // The same deletion stamped AFTER the replacement (a removal Executor + // made whose clock was read once WorkOS had answered, by which time + // the member had been re-added): the row is another membership, so + // the timestamp does not make it deletable. + const lateDeleteNewerStamp = yield* mirror.deleteMembership(ref, T4); + const afterLateDeleteNewerStamp = yield* directory.membership(id, org); + return { + removed, + removedAgain, + byDefault, + listed, + asInactive, + stale, + afterStale, + equal, + afterEqual, + newerSameId, + afterNewerSameId, + readded, + afterReadd, + lateDelete, + afterLateDelete, + lateDeleteNewerStamp, + afterLateDeleteNewerStamp, + }; + }), + ); + expect(result.removed).toBe(true); + expect(result.removedAgain, "a replayed delete changes nothing").toBe(false); + expect(result.byDefault, "a tombstone reads as no membership").toBeNull(); + expect(result.listed, "and is not listed").toEqual([]); + expect(result.asInactive, "but is still there when asked for").toMatchObject({ + status: "inactive", + lastActiveAt: null, + }); + expect(result.stale, "an upsert older than the deletion is refused").toBe(false); + expect(result.afterStale?.status).toBe("inactive"); + expect(result.equal, "an upsert stamped AT the deletion is refused too").toBe(false); + expect(result.afterEqual?.status).toBe("inactive"); + expect( + result.newerSameId, + "a payload of the deleted id stamped AFTER the removal is refused: identity, not time", + ).toBe(false); + expect(result.afterNewerSameId).toMatchObject({ + status: "inactive", + role: "member", + }); + expect(result.readded, "a replacement under a new id reactivates").toBe(true); + expect(result.afterReadd?.status).toBe("active"); + expect(result.lateDelete, "a replayed deletion of the OLD id is refused").toBe(false); + expect(result.afterLateDelete?.status).toBe("active"); + expect( + result.lateDeleteNewerStamp, + "a deletion of the OLD id stamped after the replacement is refused too: identity, not time", + ).toBe(false); + expect(result.afterLateDeleteNewerStamp).toMatchObject({ + status: "active", + membershipId: `om_${result.afterLateDeleteNewerStamp?.accountId}_${result.afterLateDeleteNewerStamp?.organizationId}_2`, + }); + }); + + it("keeps the row's own WorkOS stamp on a delete with no instant, so a replacement created meanwhile is accepted and the removed payload is not", async () => { + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const directory = yield* MemberDirectory; + const org = yield* freshOrg(); + const id = `user_${crypto.randomUUID()}`; + const membershipId = `om_${id}_${org}`; + const ref = { id: membershipId, accountId: id, organizationId: org }; + yield* mirror.upsertUser(user(id)); + yield* mirror.upsertMembership(membership(org, id, { id: membershipId, updatedAt: T1 })); + + // Executor removes the member holding no WorkOS instant for it, so + // the tombstone keeps T1, the last state WorkOS reported for it — + // never the local clock, which is long past T2 here. + const removed = yield* mirror.deleteMembership(ref, null); + const removedAgain = yield* mirror.deleteMembership(ref, null); + const tombstone = yield* directory.membership(id, org, ["inactive"]); + // A login that fetched the membership before the removal: refused. + const stale = yield* mirror.upsertMembership( + membership(org, id, { id: membershipId, updatedAt: T1 }), + ); + // The member re-added in WorkOS while the removal was in flight, + // under a new id and stamped before any local clock could have + // stamped the tombstone: accepted. + const replaced = yield* mirror.upsertMembership( + membership(org, id, { id: `${membershipId}_2`, updatedAt: T2 }), + ); + const afterReplace = yield* directory.membership(id, org); + // A tombstone minted for a row the mirror never held has no stamp + // to keep; it is still a tombstone. + const other = `user_${crypto.randomUUID()}`; + const minted = yield* mirror.deleteMembership( + { id: `om_${other}_${org}`, accountId: other, organizationId: org }, + null, + ); + const mintedRow = yield* directory.membership(other, org, ["inactive"]); + return { + membershipId, + removed, + removedAgain, + tombstone, + stale, + replaced, + afterReplace, + minted, + mintedRow, + }; + }), + ); + expect(result.removed).toBe(true); + expect(result.removedAgain, "a repeated removal changes nothing").toBe(false); + expect(result.tombstone?.status).toBe("inactive"); + expect(result.stale, "the pre-removal payload is refused").toBe(false); + expect(result.replaced, "a replacement newer than the row's stamp is accepted").toBe(true); + expect(result.afterReplace).toMatchObject({ + status: "active", + membershipId: `${result.membershipId}_2`, + }); + expect(result.minted, "a row the mirror never held is still tombstoned").toBe(true); + expect(result.mintedRow?.status).toBe("inactive"); + }); + + it("tombstones a membership the mirror has not seen, so a later older payload cannot insert it live", async () => { + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const directory = yield* MemberDirectory; + const org = yield* freshOrg(); + const id = `user_${crypto.randomUUID()}`; + const membershipId = `om_${id}_${org}`; + const readdedId = `${membershipId}_2`; + const ref = { id: membershipId, accountId: id, organizationId: org }; + + // The reconciler applies the deletion before the backfill has + // inserted the row (the user is unknown too). + const removed = yield* mirror.deleteMembership(ref, T2); + const removedAgain = yield* mirror.deleteMembership(ref, T2); + const tombstone = yield* directory.membership(id, org, ["inactive"]); + // The backfill, listing WorkOS as it was before the deletion, now + // writes the membership: refused, the tombstone stands. + const backfilled = yield* mirror.upsertMembership( + membership(org, id, { id: membershipId, updatedAt: T1 }), + ); + const afterBackfill = yield* directory.membership(id, org); + // The user payload still fills the bare account row the tombstone + // minted, so the inactive row reads with its profile. + yield* mirror.upsertUser(user(id, { firstName: "Late", updatedAt: T1 })); + const profiled = yield* directory.membership(id, org, ["inactive"]); + // Re-added in WorkOS later under a NEW membership id. + const readded = yield* mirror.upsertMembership( + membership(org, id, { id: readdedId, updatedAt: T3 }), + ); + const afterReadd = yield* directory.membership(id, org); + return { + readdedId, + removed, + removedAgain, + tombstone, + backfilled, + afterBackfill, + profiled, + readded, + afterReadd, + }; + }), + ); + expect(result.removed, "the delete leaves a tombstone behind").toBe(true); + expect(result.removedAgain).toBe(false); + expect(result.tombstone).toMatchObject({ status: "inactive", email: null }); + expect(result.backfilled, "the pre-deletion payload is refused").toBe(false); + expect(result.afterBackfill, "and the member is not live").toBeNull(); + expect(result.profiled?.name).toBe("Late"); + expect(result.readded).toBe(true); + expect(result.afterReadd).toMatchObject({ + status: "active", + membershipId: result.readdedId, + }); + }); + + it("records a delete whose id the row does not hold, so a newer payload of that id cannot take the row over", async () => { + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const directory = yield* MemberDirectory; + const org = yield* freshOrg(); + const id = `user_${crypto.randomUUID()}`; + const membershipA = `om_${id}_${org}_a`; + const membershipB = `om_${id}_${org}_b`; + yield* mirror.upsertUser(user(id)); + // The mirror holds A (a stale listing). In WorkOS, A was already + // replaced by B (stamped T2), and B is then deleted (T3) before any + // feeder wrote B here. + yield* mirror.upsertMembership(membership(org, id, { id: membershipA, updatedAt: T1 })); + const refB = { id: membershipB, accountId: id, organizationId: org }; + const deletedB = yield* mirror.deleteMembership(refB, T3); + const deletedBAgain = yield* mirror.deleteMembership(refB, T3); + const rowAfterDelete = yield* directory.membership(id, org); + // A delayed scan, listed before B's deletion, now writes B: stamped + // after A and under another id, exactly what the row guard lets + // through — the ledger refuses it. + const lateB = yield* mirror.upsertMembership( + membership(org, id, { id: membershipB, updatedAt: T2 }), + ); + const afterLateB = yield* directory.membership(id, org); + // A's own deletion, applied later, tombstones the row it holds. + const deletedA = yield* mirror.deleteMembership( + { id: membershipA, accountId: id, organizationId: org }, + T3, + ); + const afterDeleteA = yield* directory.membership(id, org); + // The member re-added in WorkOS under a third id: accepted. + const readded = yield* mirror.upsertMembership( + membership(org, id, { id: `${membershipB}_c`, updatedAt: T4 }), + ); + const afterReadd = yield* directory.membership(id, org); + return { + deletedB, + deletedBAgain, + rowAfterDelete, + lateB, + afterLateB, + deletedA, + afterDeleteA, + readded, + afterReadd, + membershipA, + }; + }), + ); + expect(result.deletedB, "the delete is recorded even though the row holds another id").toBe( + true, + ); + expect(result.deletedBAgain, "a replayed delete records nothing new").toBe(false); + expect( + result.rowAfterDelete?.membershipId, + "the row under A is not B's to tombstone and stands", + ).toBe(result.membershipA); + expect(result.lateB, "the newer payload of the deleted id is refused: identity, not time").toBe( + false, + ); + expect(result.afterLateB?.membershipId).toBe(result.membershipA); + expect(result.deletedA).toBe(true); + expect(result.afterDeleteA, "A's deletion tombstones the row").toBeNull(); + expect(result.readded, "a replacement under a fresh id reactivates").toBe(true); + expect(result.afterReadd?.status).toBe("active"); + }); + + it("reactivates a membership WorkOS deactivated under the same id: a deactivation is not a deletion", async () => { + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const directory = yield* MemberDirectory; + const org = yield* freshOrg(); + const id = `user_${crypto.randomUUID()}`; + const membershipId = `om_${id}_${org}`; + yield* mirror.upsertUser(user(id)); + yield* mirror.upsertMembership(membership(org, id, { id: membershipId, updatedAt: T1 })); + // WorkOS deactivates the membership (an `organization_membership.updated` + // with status inactive): the row is inactive but still WorkOS's, with + // no `deleted_at` to protect it. + const deactivated = yield* mirror.upsertMembership( + membership(org, id, { + id: membershipId, + status: "inactive", + updatedAt: T2, + }), + ); + const whileInactive = yield* directory.membership(id, org); + // A payload older than the deactivation cannot undo it, nor one + // stamped at the same instant. + const older = yield* mirror.upsertMembership( + membership(org, id, { id: membershipId, updatedAt: T1 }), + ); + const equal = yield* mirror.upsertMembership( + membership(org, id, { id: membershipId, updatedAt: T2 }), + ); + // WorkOS reactivates it, same id, newer stamp: live again. + const reactivated = yield* mirror.upsertMembership( + membership(org, id, { + id: membershipId, + role: "admin", + updatedAt: T3, + }), + ); + const afterReactivate = yield* directory.membership(id, org); + return { + membershipId, + deactivated, + whileInactive, + older, + equal, + reactivated, + afterReactivate, + }; + }), + ); + expect(result.deactivated).toBe(true); + expect(result.whileInactive, "an inactive membership reads as no membership").toBeNull(); + expect(result.older, "an older payload cannot undo the deactivation").toBe(false); + expect(result.equal, "nor one stamped at the deactivation").toBe(false); + expect(result.reactivated, "a newer payload under the same id reactivates it").toBe(true); + expect(result.afterReactivate).toMatchObject({ + status: "active", + role: "admin", + membershipId: result.membershipId, + }); + }); + + it("tombstones every membership of a deleted user and clears the profile, keeping the account row", async () => { + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const directory = yield* MemberDirectory; + const orgA = yield* freshOrg(); + const orgB = yield* freshOrg(); + const id = `user_${crypto.randomUUID()}`; + yield* mirror.upsertUser(user(id, { firstName: "Gone", updatedAt: T1 })); + yield* mirror.upsertMembership(membership(orgA, id)); + yield* mirror.upsertMembership(membership(orgB, id)); + + const deleted = yield* mirror.deleteUser(id, T2); + const deletedAgain = yield* mirror.deleteUser(id, T2); + const inA = yield* directory.membership(id, orgA); + const inB = yield* directory.membership(id, orgB, ["inactive"]); + // A stale user payload cannot restore the profile, nor can one + // stamped at the deletion itself. No membership of the deleted user + // is written again — not the deleted id (a deleted membership id + // never returns), and not one stamped after the deletion under a + // NEW id, the payload a timestamp guard would let through: the user + // is gone, and WorkOS never reuses the id. + const staleUser = yield* mirror.upsertUser(user(id, { firstName: "Back", updatedAt: T1 })); + const equalUser = yield* mirror.upsertUser(user(id, { firstName: "Same", updatedAt: T2 })); + const sameId = yield* mirror.upsertMembership(membership(orgA, id, { updatedAt: T3 })); + const rejoined = yield* mirror.upsertMembership( + membership(orgA, id, { id: `om_${id}_${orgA}_2`, updatedAt: T3 }), + ); + const afterRejoin = yield* directory.membership(id, orgA, ["inactive"]); + // A user the mirror has never seen: the delete mints the account + // tombstone, the backfill's older profile cannot fill it afterwards, + // and a membership of the user the mirror has never seen — no row + // for the membership guard to judge — is refused by the account + // tombstone alone, whatever it is stamped. + const unseen = `user_${crypto.randomUUID()}`; + const unknown = yield* mirror.deleteUser(unseen, T2); + const unseenProfile = yield* mirror.upsertUser( + user(unseen, { firstName: "Ghost", updatedAt: T1 }), + ); + const unseenMembership = yield* mirror.upsertMembership( + membership(orgA, unseen, { updatedAt: T3 }), + ); + const unseenRow = yield* directory.membership(unseen, orgA, [ + "active", + "pending", + "inactive", + ]); + return { + deleted, + deletedAgain, + inA, + inB, + staleUser, + equalUser, + sameId, + rejoined, + afterRejoin, + unknown, + unseenProfile, + unseenMembership, + unseenRow, + }; + }), + ); + expect(result.deleted).toBe(true); + expect(result.deletedAgain, "a replayed delete changes nothing").toBe(false); + expect(result.inA, "the user's memberships are tombstoned").toBeNull(); + expect(result.inB).toMatchObject({ + status: "inactive", + email: null, + name: null, + }); + expect(result.staleUser).toBe(false); + expect(result.equalUser, "a profile stamped AT the deletion is refused").toBe(false); + expect(result.sameId, "a deleted membership id never returns").toBe(false); + expect( + result.rejoined, + "a membership of a deleted user is refused however it is stamped: identity, not time", + ).toBe(false); + expect(result.afterRejoin).toMatchObject({ + status: "inactive", + name: null, + }); + expect(result.unknown, "deleting an unseen user leaves a tombstone").toBe(true); + expect(result.unseenProfile, "which the older profile cannot fill").toBe(false); + expect( + result.unseenMembership, + "and a membership the mirror never held is not inserted for the deleted user", + ).toBe(false); + expect(result.unseenRow).toBeNull(); + }); + + it("waits for a user deletion holding the account row before judging a membership write against its tombstone", async () => { + // A feeder (a stalled login list, the backfill's older listing) writes a + // membership of a user whose `user.deleted` the reconciler is applying + // at this moment. The deletion locks the account row for the length of + // its transaction; the feeder's write must wait for that commit, see the + // tombstone, and refuse the payload — never insert a live membership + // for a deleted user. The deletion runs on the test's shared connection; + // the feeder runs the same store over a second one, so the two + // transactions are real peers on the server. + const result = await run( + Effect.scoped( + Effect.gen(function* () { + const org = yield* freshOrg(); + const gone = `user_${crypto.randomUUID()}`; + const mirror = yield* WorkOsMirror; + const { db: deleterDb } = yield* DbService; + // The second connection is owned by this test body's scope. + const feederDb = Context.get(yield* Layer.build(makeDbLayer()), DbService).db; + const feeder = makeWorkOsMirrorStore(feederDb); + const directory = yield* MemberDirectory; + yield* mirror.upsertUser(user(gone, { firstName: "Gone" })); + + // The deletion's transaction: the account row is locked as + // `deleteUser` locks it, and the transaction is held open until + // `release` — the window a feeder can race. The tombstone itself + // is written after the feeder has started waiting. + const locked = yield* Deferred.make(); + let release: () => void = () => undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + const deletion = yield* Effect.forkChild( + Effect.promise(() => + deleterDb.transaction(async (tx) => { + await tx + .select({ id: accounts.id }) + .from(accounts) + .where(eq(accounts.id, gone)) + .for("no key update"); + await Effect.runPromise(Deferred.succeed(locked, undefined)); + await held; + await makeWorkOsMirrorStore(tx).deleteUser(gone, T2).pipe(Effect.runPromise); + }), + ), + { startImmediately: true }, + ); + yield* Deferred.await(locked); + + // The feeder's write starts while the deletion holds the row. + const written = yield* Deferred.make(); + yield* Effect.forkChild( + feeder + .upsertMembership(membership(org, gone, { updatedAt: T3 })) + .pipe(Effect.flatMap((wrote) => Deferred.succeed(written, wrote))), + { startImmediately: true }, + ); + const beforeCommit = yield* Effect.timeoutOption(Deferred.await(written), "250 millis"); + release(); + yield* Fiber.join(deletion); + const afterCommit = yield* Deferred.await(written); + const row = yield* directory.membership(gone, org, ["active", "pending", "inactive"]); + return { beforeCommit, afterCommit, row }; + }), + ), + ); + expect( + Option.isNone(result.beforeCommit), + "the write waits while the deletion holds the account row", + ).toBe(true); + expect( + result.afterCommit, + "once the deletion has committed, its tombstone refuses the payload", + ).toBe(false); + expect(result.row, "so no membership was inserted for the deleted user").toBeNull(); + }); + + it("waits for a membership deletion holding the account row before judging a write of that id against the ledger", async () => { + // Same race for a single membership: a feeder writes membership `om` + // while the reconciler is applying its `organization_membership.deleted`. + // The delete locks the account row for its transaction, so the feeder's + // FOR SHARE read waits, then finds the ledger entry and refuses. Without + // the lock the feeder could pass the ledger check first and insert the + // deleted membership live after the delete committed. + const result = await run( + Effect.scoped( + Effect.gen(function* () { + const org = yield* freshOrg(); + const id = `user_${crypto.randomUUID()}`; + const mirror = yield* WorkOsMirror; + const { db: deleterDb } = yield* DbService; + const feederDb = Context.get(yield* Layer.build(makeDbLayer()), DbService).db; + const feeder = makeWorkOsMirrorStore(feederDb); + const directory = yield* MemberDirectory; + yield* mirror.upsertUser(user(id)); + const gone = membership(org, id, { updatedAt: T3 }); + + const locked = yield* Deferred.make(); + let release: () => void = () => undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + const deletion = yield* Effect.forkChild( + Effect.promise(() => + deleterDb.transaction(async (tx) => { + await tx + .select({ id: accounts.id }) + .from(accounts) + .where(eq(accounts.id, id)) + .for("no key update"); + await Effect.runPromise(Deferred.succeed(locked, undefined)); + await held; + await makeWorkOsMirrorStore(tx).deleteMembership(gone, T2).pipe(Effect.runPromise); + }), + ), + { startImmediately: true }, + ); + yield* Deferred.await(locked); + + const written = yield* Deferred.make(); + yield* Effect.forkChild( + feeder + .upsertMembership(gone) + .pipe(Effect.flatMap((wrote) => Deferred.succeed(written, wrote))), + { startImmediately: true }, + ); + const beforeCommit = yield* Effect.timeoutOption(Deferred.await(written), "250 millis"); + release(); + yield* Fiber.join(deletion); + const afterCommit = yield* Deferred.await(written); + const row = yield* directory.membership(id, org, ["active", "pending", "inactive"]); + return { beforeCommit, afterCommit, status: row?.status ?? null }; + }), + ), + ); + expect( + Option.isNone(result.beforeCommit), + "the write waits while the deletion holds the account row", + ).toBe(true); + expect(result.afterCommit, "once the deletion has committed, the ledger refuses the id").toBe( + false, + ); + expect(result.status, "and the row is the tombstone, never live").toBe("inactive"); + }); +}); + +describe("WorkOsMirror cursor", () => { + it("advances only by compare-and-set, and a page that loses the CAS writes nothing", async () => { + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const directory = yield* MemberDirectory; + const org = yield* freshOrg(); + const id = `user_${crypto.randomUUID()}`; + // The cursor is instance-wide; read whatever a previous test left so + // this test's expectations are relative, not absolute. + const before = yield* mirror.getCursor(); + const first = yield* mirror.applyPage(before, "event_1", []); + const wrongPrev = yield* mirror.applyPage(before === null ? "event_0" : null, "event_x", [ + WorkOsMirrorWrite.UpsertUser({ user: user(id) }), + WorkOsMirrorWrite.UpsertMembership({ + membership: membership(org, id), + }), + ]); + const afterWrong = yield* mirror.getCursor(); + const notWritten = yield* directory.membership(id, org); + const right = yield* mirror.applyPage("event_1", "event_2", [ + WorkOsMirrorWrite.UpsertUser({ user: user(id) }), + WorkOsMirrorWrite.UpsertMembership({ + membership: membership(org, id), + }), + // A rename of an org the mirror has never seen: nothing to write. + WorkOsMirrorWrite.RenameOrganization({ + organizationId: "org_nobody", + name: "Nobody", + updatedAt: T1, + }), + ]); + const after = yield* mirror.getCursor(); + const written = yield* directory.membership(id, org); + return { + id, + org, + first, + wrongPrev, + afterWrong, + notWritten, + right, + after, + written, + }; + }), + ); + expect(Option.isSome(result.first)).toBe(true); + expect( + Option.isNone(result.wrongPrev), + "a run holding a stale prev cannot move the cursor", + ).toBe(true); + expect(result.afterWrong).toBe("event_1"); + expect(result.notWritten, "and none of its page's writes land").toBeNull(); + expect(result.right).toEqual(Option.some(["applied", "applied", "absent"])); + expect(result.after).toBe("event_2"); + expect(result.written?.membershipId).toBe(`om_${result.id}_${result.org}`); + }); +}); + +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 { db } = yield* DbService; + const readiness = () => Effect.promise(() => readMirrorReadiness(db, new Date())); + yield* clearEventsRow; + const noRow = yield* readiness(); + yield* mirror.setReplayBoundary(T1); + yield* mirror.markBackfillCompleted(T1); + const backfilledOnly = yield* readiness(); + // 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(); + return { noRow, backfilledOnly, ready }; + }), + ); + 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( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + // The events row is instance-wide (migration 0019 seeds it on the + // empty test database, other tests may have written it): start from + // no row, as a database that has never been backfilled has. + yield* clearEventsRow; + // No events row yet: nothing has been drained, and nothing is minted. + const drainedWithoutRow = yield* mirror.markDrained(T1); + const first = yield* mirror.setReplayBoundary(T2); + const boundary = yield* mirror.replayBoundary(); + const cursorAfterBoundary = yield* mirror.getCursor(); + // A later completed backfill: its boundary is not recorded. + const again = yield* mirror.setReplayBoundary(T3); + const afterAgain = yield* mirror.replayBoundary(); + // Nor once the stream is being followed. + const cursorBefore = yield* mirror.getCursor(); + yield* mirror.applyPage(cursorBefore, "event_boundary", []); + const afterCursor = yield* mirror.setReplayBoundary(T1); + const boundaryWithCursor = yield* mirror.replayBoundary(); + const cursor = yield* mirror.getCursor(); + // The completion mark: absent until a run covers every organization, + // then written once, beside the boundary and the cursor. + const notCompleted = yield* mirror.backfillCompletedAt(); + const completed = yield* mirror.markBackfillCompleted(T3); + const completedAgain = yield* mirror.markBackfillCompleted(T4); + const completedAt = yield* mirror.backfillCompletedAt(); + const boundaryAfterCompletion = yield* mirror.replayBoundary(); + const cursorAfterCompletion = yield* mirror.getCursor(); + // The drained mark moves forward only, on the row the stream owns. + const notDrained = yield* mirror.drainedAt(); + const drainedFirst = yield* mirror.markDrained(T3); + const drainedBackwards = yield* mirror.markDrained(T2); + const drainedForward = yield* mirror.markDrained(T4); + const drainedAt = yield* mirror.drainedAt(); + return { + drainedWithoutRow, + notDrained, + drainedFirst, + drainedBackwards, + drainedForward, + drainedAt, + first, + boundary, + cursorAfterBoundary, + again, + afterAgain, + afterCursor, + boundaryWithCursor, + cursor, + notCompleted, + completed, + completedAgain, + completedAt, + boundaryAfterCompletion, + cursorAfterCompletion, + }; + }), + ); + expect(result.first, "the first boundary is recorded").toBe(true); + expect(result.boundary, "and reads back as written").toEqual(T2); + expect(result.cursorAfterBoundary, "writing the boundary mints no cursor").toBeNull(); + expect(result.again, "a later run's boundary is refused").toBe(false); + expect(result.afterAgain, "the first stands").toEqual(T2); + expect(result.afterCursor).toBe(false); + expect(result.boundaryWithCursor).toEqual(T2); + expect(result.cursor, "and the cursor is untouched").toBe("event_boundary"); + expect(result.notCompleted, "no completion until a run covers every org").toBeNull(); + expect(result.completed, "the first completion is recorded").toBe(true); + expect(result.completedAgain, "a later one is refused").toBe(false); + expect(result.completedAt, "the first stands").toEqual(T3); + expect(result.boundaryAfterCompletion, "the boundary is untouched").toEqual(T2); + expect(result.cursorAfterCompletion, "and so is the cursor").toBe("event_boundary"); + expect(result.drainedWithoutRow, "no row, nothing drained: nothing written").toBe(false); + expect(result.notDrained, "no drain recorded until a run drains").toBeNull(); + expect(result.drainedFirst).toBe(true); + expect(result.drainedBackwards, "an earlier run finishing later cannot move it back").toBe( + false, + ); + expect(result.drainedForward).toBe(true); + expect(result.drainedAt).toEqual(T4); + }); + + it("refuses a membership payload stamped before the organization's last scan, and accepts one stamped at or after it", async () => { + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const directory = yield* MemberDirectory; + const org = yield* freshOrg(); + const revoked = `user_${crypto.randomUUID()}`; + const kept = `user_${crypto.randomUUID()}`; + const joined = `user_${crypto.randomUUID()}`; + // A login fetched its membership list at T1, while `revoked` was a + // member, then stalled. WorkOS revoked them, and the backfill scanned + // the org at T2 without them — nothing to tombstone, the row was + // never there. + yield* mirror.applyOrganizationScan({ + organizationId: org, + listedAt: T2, + members: [ + { + user: user(kept), + membership: membership(org, kept, { updatedAt: T1 }), + }, + ], + }); + // The stalled login resumes and writes what it holds: refused, the + // revocation predates the scan and nothing would ever undo the row. + const stale = yield* mirror.upsertMembership(membership(org, revoked, { updatedAt: T1 })); + const staleRow = yield* directory.membership(revoked, org, [ + "active", + "pending", + "inactive", + ]); + // The same list's payload for a member the scan kept: refused too — + // it changes nothing, the scan already wrote that state. + const repeated = yield* mirror.upsertMembership(membership(org, kept, { updatedAt: T1 })); + const keptRow = yield* directory.membership(kept, org); + // A membership WorkOS created after the scan (its event, or a login + // after it): stamped past the mark, written. + const later = yield* mirror.upsertMembership(membership(org, joined, { updatedAt: T3 })); + const atMark = yield* mirror.upsertMembership( + membership(org, kept, { role: "admin", updatedAt: T2 }), + ); + const keptAfter = yield* directory.membership(kept, org); + return { stale, staleRow, repeated, keptRow, later, atMark, keptAfter }; + }), + ); + expect(result.stale, "a payload older than the scan is refused").toBe(false); + expect(result.staleRow, "and no row is minted for the revoked member").toBeNull(); + expect(result.repeated, "even for a member the scan kept").toBe(false); + expect(result.keptRow?.status).toBe("active"); + expect(result.later, "a payload newer than the scan is written").toBe(true); + expect(result.atMark, "as is one stamped at the scan's instant").toBe(true); + expect(result.keptAfter?.role).toBe("admin"); + }); + + it("waits for a scan holding the organization row before judging a payload against the scan's mark", async () => { + // A feeder (a login) writes a membership it fetched at T1 while a scan + // listed at T2 — which no longer contains that membership — is being + // applied. The scan claims the organization row for the length of its + // transaction; the feeder's write must wait for that commit, observe + // the mark, and refuse the payload — never insert the membership the + // scan proved revoked. The scan holds the test's shared connection; the + // feeder runs the same store over a second one, so the two transactions + // are real peers on the server. + const result = await run( + Effect.scoped( + Effect.gen(function* () { + const org = yield* freshOrg(); + const revoked = `user_${crypto.randomUUID()}`; + const { db: scanDb } = yield* DbService; + // The second connection is owned by this test body's scope. + const feederDb = Context.get(yield* Layer.build(makeDbLayer()), DbService).db; + const feeder = makeWorkOsMirrorStore(feederDb); + const directory = yield* MemberDirectory; + + // The scan's transaction: its claim (the `backfilled_at` CAS, an + // UPDATE that locks the row) is done, and the transaction is held + // open until `release` — the window a feeder can race. + const claimed = yield* Deferred.make(); + let release: () => void = () => undefined; + const held = new Promise((resolve) => { + release = resolve; + }); + const scan = yield* Effect.forkChild( + Effect.promise(() => + scanDb.transaction(async (tx) => { + await tx + .update(organizations) + .set({ backfilledAt: T2 }) + .where(eq(organizations.id, org)); + await Effect.runPromise(Deferred.succeed(claimed, undefined)); + await held; + }), + ), + { startImmediately: true }, + ); + yield* Deferred.await(claimed); + + // The feeder's write starts while the scan holds the row. + const written = yield* Deferred.make(); + yield* Effect.forkChild( + feeder + .upsertMembership(membership(org, revoked, { updatedAt: T1 })) + .pipe(Effect.flatMap((wrote) => Deferred.succeed(written, wrote))), + { startImmediately: true }, + ); + const beforeCommit = yield* Effect.timeoutOption(Deferred.await(written), "250 millis"); + release(); + yield* Fiber.join(scan); + const afterCommit = yield* Deferred.await(written); + const row = yield* directory.membership(revoked, org, ["active", "pending", "inactive"]); + return { beforeCommit, afterCommit, row }; + }), + ), + ); + expect( + Option.isNone(result.beforeCommit), + "the write waits while the scan holds the organization row", + ).toBe(true); + expect(result.afterCommit, "once the scan has committed, its mark refuses the payload").toBe( + false, + ); + expect(result.row, "so the revoked membership was never inserted").toBeNull(); + }); + + it("applies a scan only when its listing is newer than the one already applied, and never to a deleted or unknown organization", async () => { + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const directory = yield* MemberDirectory; + const store = yield* UserStoreService; + const scanned = yield* freshOrg(); + const untouched = yield* freshOrg(); + const staying = `user_${crypto.randomUUID()}`; + const leaving = `user_${crypto.randomUUID()}`; + const member = (id: string) => ({ + user: user(id), + membership: membership(scanned, id), + }); + + const before = yield* mirror.organizationBackfilledAt(scanned); + // The later listing (T2) no longer contains `leaving`; applied first. + const later = yield* mirror.applyOrganizationScan({ + organizationId: scanned, + listedAt: T2, + members: [member(staying)], + }); + const afterLater = yield* mirror.organizationBackfilledAt(scanned); + // The earlier listing (T1) still contains `leaving`: refused whole. + const earlier = yield* mirror.applyOrganizationScan({ + organizationId: scanned, + listedAt: T1, + members: [member(staying), member(leaving)], + }); + const afterEarlier = yield* mirror.organizationBackfilledAt(scanned); + const leavingRow = yield* directory.membership(leaving, scanned, [ + "active", + "pending", + "inactive", + ]); + // The same instant is not newer either: a replayed listing writes nothing. + const same = yield* mirror.applyOrganizationScan({ + organizationId: scanned, + listedAt: T2, + members: [], + }); + const stayingRow = yield* directory.membership(staying, scanned); + const other = yield* mirror.organizationBackfilledAt(untouched); + const unknown = yield* mirror.applyOrganizationScan({ + organizationId: "org_never_mirrored", + listedAt: T3, + members: [], + }); + yield* store.use("deleteOrganizationCascade", (s) => + s.deleteOrganizationCascade(untouched, T3), + ); + const deleted = yield* mirror.applyOrganizationScan({ + organizationId: untouched, + listedAt: T4, + members: [{ user: user(staying), membership: membership(untouched, staying) }], + }); + const deletedRow = yield* directory.membership(staying, untouched); + return { + before, + later, + afterLater, + earlier, + afterEarlier, + leavingRow, + same, + stayingRow, + other, + unknown, + deleted, + deletedRow, + }; + }), + ); + expect(result.before, "a freshly mirrored organization is unscanned").toBeNull(); + expect(result.later).toEqual( + Option.some({ + usersWritten: 1, + membershipsWritten: 1, + membershipsTombstoned: 0, + }), + ); + expect(result.afterLater, "the scan marks the organization as of its listing").toEqual(T2); + expect(result.earlier, "an older listing is refused whole").toEqual(Option.none()); + expect(result.afterEarlier, "and the mark never moves backwards").toEqual(T2); + expect( + result.leavingRow, + "the membership only the older listing held was never written", + ).toBeNull(); + expect(result.same, "a listing at the recorded instant is refused too").toEqual(Option.none()); + expect(result.stayingRow?.status, "so it tombstones nothing the newer one wrote").toBe( + "active", + ); + expect(result.other, "another organization's mark is its own").toBeNull(); + expect(result.unknown, "an organization the mirror does not hold takes no scan").toEqual( + Option.none(), + ); + expect(result.deleted, "nor does a deleted one, however new the listing").toEqual( + Option.none(), + ); + expect(result.deletedRow).toBeNull(); + }); +}); + +describe("cloud MemberDirectory", () => { + const seed = (org: string) => + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + const ids = { + ada: `user_${crypto.randomUUID()}`, + grace: `user_${crypto.randomUUID()}`, + linus: `user_${crypto.randomUUID()}`, + gone: `user_${crypto.randomUUID()}`, + }; + yield* mirror.upsertUser( + user(ids.ada, { + email: "Ada.Lovelace@Placeholder.test", + firstName: "Ada", + lastName: "Lovelace", + lastSignInAt: T2, + }), + ); + yield* mirror.upsertUser( + user(ids.grace, { + email: "grace@placeholder.test", + firstName: "Grace", + lastName: "Hopper", + }), + ); + yield* mirror.upsertUser( + user(ids.linus, { + email: "linus@placeholder.test", + firstName: "Linus", + lastName: null, + }), + ); + yield* mirror.upsertUser(user(ids.gone, { email: "gone@placeholder.test" })); + yield* mirror.upsertMembership(membership(org, ids.ada, { role: "admin" })); + yield* mirror.upsertMembership(membership(org, ids.grace, { status: "pending" })); + yield* mirror.upsertMembership(membership(org, ids.linus)); + yield* mirror.upsertMembership(membership(org, ids.gone, { status: "inactive" })); + return ids; + }); + + it("lists active + pending members by default, ordered by email, and pages stably", async () => { + const result = await run( + Effect.gen(function* () { + const directory = yield* MemberDirectory; + const org = yield* freshOrg(); + const ids = yield* seed(org); + const all = yield* directory.members(org); + const page1 = yield* directory.members(org, { limit: 2, offset: 0 }); + const page2 = yield* directory.members(org, { limit: 2, offset: 2 }); + const inactive = yield* directory.members(org, { + statuses: ["inactive"], + }); + return { ids, all, page1, page2, inactive }; + }), + ); + expect(result.all.map((m) => m.email)).toEqual([ + "Ada.Lovelace@Placeholder.test", + "grace@placeholder.test", + "linus@placeholder.test", + ]); + expect(result.all.find((m) => m.accountId === result.ids.ada)).toMatchObject({ + role: "admin", + status: "active", + name: "Ada Lovelace", + lastActiveAt: T2.getTime(), + }); + expect(result.all.find((m) => m.accountId === result.ids.linus)?.name).toBe("Linus"); + expect([...result.page1, ...result.page2].map((m) => m.accountId)).toEqual( + result.all.map((m) => m.accountId), + ); + expect(result.inactive.map((m) => m.accountId)).toEqual([result.ids.gone]); + }); + + it("searches email and name case-insensitively, escaping LIKE wildcards", async () => { + const result = await run( + Effect.gen(function* () { + const directory = yield* MemberDirectory; + const org = yield* freshOrg(); + const ids = yield* seed(org); + const byEmail = yield* directory.members(org, { search: "LOVELACE@" }); + const byName = yield* directory.members(org, { + search: " grace hop ", + }); + const nothing = yield* directory.members(org, { search: "nobody" }); + const blank = yield* directory.members(org, { search: " " }); + const wildcard = yield* directory.members(org, { search: "%" }); + return { ids, byEmail, byName, nothing, blank, wildcard }; + }), + ); + expect(result.byEmail.map((m) => m.accountId)).toEqual([result.ids.ada]); + expect(result.byName.map((m) => m.accountId)).toEqual([result.ids.grace]); + expect(result.nothing).toEqual([]); + expect(result.blank.length, "a blank term is no filter").toBe(3); + 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* () { + const directory = yield* MemberDirectory; + const org = yield* freshOrg(); + const other = yield* freshOrg(); + const ids = yield* seed(org); + const found = yield* directory.findByEmail(org, "ada.lovelace@placeholder.test"); + const inactive = yield* directory.findByEmail(org, "gone@placeholder.test"); + const inactiveAsked = yield* directory.findByEmail(org, "gone@placeholder.test", [ + "inactive", + ]); + const wrongOrg = yield* directory.findByEmail(other, "ada.lovelace@placeholder.test"); + const batch = yield* directory.membersById(org, [ids.ada, ids.gone, "user_unknown"]); + const batchAll = yield* directory.membersById( + org, + [ids.ada, ids.gone], + ["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, + inactive, + inactiveAsked, + wrongOrg, + batch, + batchAll, + empty, + byId, + byIdForeign, + byIdUnknown, + }; + }), + ); + expect(result.found?.accountId).toBe(result.ids.ada); + expect(result.inactive, "an inactive member is not found by default").toBeNull(); + expect(result.inactiveAsked?.status, "but is when asked for").toBe("inactive"); + expect(result.wrongOrg).toBeNull(); + expect([...result.batch.keys()], "a batch excludes inactive by default").toEqual([ + result.ids.ada, + ]); + 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-mirror.ts b/apps/cloud/src/auth/workos-mirror.ts new file mode 100644 index 0000000000..70ea8bf366 --- /dev/null +++ b/apps/cloud/src/auth/workos-mirror.ts @@ -0,0 +1,57 @@ +// --------------------------------------------------------------------------- +// WorkOsMirror — the WRITE side of cloud's local membership mirror. +// +// WorkOS owns users and organization memberships. This service keeps the +// `accounts` / `memberships` rows (db/schema.ts) in step with it so the read +// side (`auth/member-directory.ts`, the cloud `MemberDirectory`) never has to +// ask WorkOS. Three feeders write through it: the login callback (user + +// memberships already in hand), Executor-initiated changes (write-through in +// `auth/handlers.ts` and `account/workos-account-service.ts`), and the WorkOS +// Events API reconciler (dashboard-side changes, replayed in order from a +// persisted cursor). The one-off backfill runs the same store out-of-band. +// +// The queries live in `workos-mirror-store.ts`; this file binds them to the +// per-request `DbService`. Per-request layer shape, like `UserStoreService`: +// it holds the request's postgres socket, so it is rebuilt per request +// (`RequestScopedServicesLive`) and never shared across Workers requests. +// --------------------------------------------------------------------------- + +import { Context, Effect, Layer } from "effect"; + +import { DbService } from "../db/db"; +import { makeWorkOsMirrorStore, type WorkOsMirrorShape } from "./workos-mirror-store"; + +export { WorkOsMirrorError } from "./errors"; +export { + WorkOsMirrorWrite, + mirrorMembershipFromWorkOs, + mirrorUserFromWorkOs, + type WorkOsMembershipPayload, + type WorkOsMirrorMembership, + type WorkOsMirrorMembershipRef, + type WorkOsMirrorShape, + type WorkOsMirrorUser, + type WorkOsMirrorWriteOutcome, + type WorkOsOrganizationScan, + type WorkOsOrganizationScanWrites, + type WorkOsScannedMember, + type WorkOsUserPayload, +} from "./workos-mirror-store"; + +export class WorkOsMirror extends Context.Service()( + "@executor-js/cloud/WorkOsMirror", +) { + static Live = Layer.effect(this)( + Effect.map(DbService.asEffect(), ({ db }) => makeWorkOsMirrorStore(db)), + ); +} + +/** + * A FRESH `WorkOsMirror` layer (new layer value per call), for a service built + * once but invoked across many Workers requests — the same reason + * `makeUserStoreLayer` exists. See [[makeDbLayer]]. + */ +export const makeWorkOsMirrorLayer = (): Layer.Layer => + Layer.effect(WorkOsMirror)( + Effect.map(DbService.asEffect(), ({ db }) => makeWorkOsMirrorStore(db)), + ); diff --git a/apps/cloud/src/auth/workos-webhook.ts b/apps/cloud/src/auth/workos-webhook.ts new file mode 100644 index 0000000000..5309cdc581 --- /dev/null +++ b/apps/cloud/src/auth/workos-webhook.ts @@ -0,0 +1,94 @@ +// --------------------------------------------------------------------------- +// `POST /api/webhooks/workos` — the WorkOS webhook endpoint, which only +// POKES the reconciler. It verifies the delivery's signature and, when it +// is genuine, starts one `syncWorkOsEvents` pass past the response. It +// never applies the webhook's own payload: webhooks are unordered and +// at-least-once, while the Events API the reconciler reads is ordered and +// replayable from the persisted cursor. The webhook's only job is to turn +// "within a minute" (the cron) into "within seconds" for dashboard-side +// changes such as a revoked membership. +// +// Unauthenticated by design (WorkOS holds no session); the signature IS the +// authentication. Nothing about the payload is reflected in the response. +// --------------------------------------------------------------------------- + +import { Effect, Option, Schema } from "effect"; +import { Headers, HttpRouter, HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; + +import { WorkOSClient } from "./workos"; + +export const WORKOS_WEBHOOK_PATH = "/api/webhooks/workos"; + +const SIGNATURE_HEADER = "workos-signature"; + +// The SDK verifies the signature over `JSON.stringify(payload)`, so the body +// must be a JSON object; an array or scalar can never be a WorkOS delivery. +const WebhookPayload = Schema.Record(Schema.String, Schema.Unknown); +const decodeWebhookPayload = Schema.decodeUnknownOption(WebhookPayload); + +export interface WorkOsWebhookDeps { + /** + * The endpoint's signing secret (`WORKOS_WEBHOOK_SECRET`). `undefined` + * when the deployment has not configured one: every delivery is then + * refused with 503, never accepted unverified. + */ + readonly secret: string | undefined; + /** + * Hand the reconciler pass to the platform so it outlives the response + * (`waitUntil` from `cloudflare:workers`). The promise never rejects: the + * runner reports its own failures. + */ + readonly detach: (work: Promise) => void; + /** One reconciler pass over fresh services (`runWorkOsEventsSync`). */ + readonly sync: () => Promise; +} + +/** + * The webhook route. 200 for a verified delivery (a sync pass has been + * detached), 400 for a missing or invalid signature or a body that is not a + * JSON object, 503 when no signing secret is configured. + */ +export const makeWorkOsWebhookRoute = (deps: WorkOsWebhookDeps) => + HttpRouter.add( + "POST", + WORKOS_WEBHOOK_PATH, + Effect.gen(function* () { + if (deps.secret === undefined) { + yield* Effect.logError( + "workos_webhook: WORKOS_WEBHOOK_SECRET is not set; refusing the delivery", + ); + return HttpServerResponse.empty({ status: 503 }); + } + const secret = deps.secret; + const request = yield* HttpServerRequest.HttpServerRequest; + const sigHeader = Headers.get(request.headers, SIGNATURE_HEADER); + if (Option.isNone(sigHeader)) { + return HttpServerResponse.empty({ status: 400 }); + } + const body = yield* request.json.pipe(Effect.option); + const payload = Option.flatMap(body, decodeWebhookPayload); + if (Option.isNone(payload)) { + return HttpServerResponse.empty({ status: 400 }); + } + + const workos = yield* WorkOSClient; + const verified = yield* workos + .constructWebhookEvent({ + payload: payload.value, + sigHeader: sigHeader.value, + secret, + }) + .pipe(Effect.option); + if (Option.isNone(verified)) { + yield* Effect.logWarning("workos_webhook: signature rejected"); + return HttpServerResponse.empty({ status: 400 }); + } + + yield* Effect.logInfo("workos_webhook: verified delivery; poking the reconciler", { + event: verified.value.event, + eventId: verified.value.id, + }); + deps.detach(deps.sync()); + return HttpServerResponse.empty({ status: 200 }); + }), + ); diff --git a/apps/cloud/src/auth/workos.ts b/apps/cloud/src/auth/workos.ts index 821ea90278..918a7e8556 100644 --- a/apps/cloud/src/auth/workos.ts +++ b/apps/cloud/src/auth/workos.ts @@ -4,7 +4,13 @@ import { env } from "cloudflare:workers"; import { Context, Data, Effect, Layer, Option, Predicate, Schema } from "effect"; -import { GeneratePortalLinkIntent, WorkOS } from "@workos-inc/node/worker"; +import { + GeneratePortalLinkIntent, + 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"; import { workosAccessTokenOptions } from "./access-token-options"; @@ -16,6 +22,7 @@ import { tryPromiseService, withServiceLogging, workosErrorFromFailure, + type WorkOSError, } from "./errors"; const COOKIE_NAME = "wos-session"; @@ -48,6 +55,20 @@ type WorkOSAutoPaginatable = { readonly autoPagination: () => Promise; }; +/** + * One read of the WorkOS Events API stream. `events` names the types to + * return; `after` resumes from an event id (exclusive), `rangeStart` (ISO) + * bounds a first read that has no cursor yet. Mirrors the SDK's + * `ListEventOptions` with readonly inputs. + */ +export type WorkOSListEventsOptions = { + readonly events: readonly WorkOSEventName[]; + readonly after?: string; + readonly rangeStart?: string; + readonly limit?: number; + readonly order?: "asc" | "desc"; +}; + export type WorkOSCollectedList = { readonly object: "list"; readonly data: Resource[]; @@ -645,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({ @@ -664,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) => @@ -733,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) => @@ -753,6 +770,48 @@ const make = Effect.gen(function* () { wos.organizations.listOrganizationRoles({ organizationId }), ), + /** + * One page of the Events API stream, oldest first when `order` is `asc`. + * The reconciler (`workos-events-sync.ts`) is the only consumer: it pages + * by `after` = the last event id it applied, so the stream is replayable + * from the persisted cursor. Returns the SDK page as-is (`data` + + * `listMetadata.after`); paging is the caller's loop, not + * `collectWorkOSList`, because each page is committed before the next is + * read. + */ + listEvents: (options: WorkOSListEventsOptions) => + use("events.listEvents", (wos) => + wos.events.listEvents({ + events: [...options.events], + ...(options.after === undefined ? {} : { after: options.after }), + ...(options.rangeStart === undefined ? {} : { rangeStart: options.rangeStart }), + ...(options.limit === undefined ? {} : { limit: options.limit }), + ...(options.order === undefined ? {} : { order: options.order }), + }), + ), + + /** + * Verify a webhook delivery against `secret` (the endpoint's signing + * secret from the WorkOS dashboard) and decode its event. A local HMAC + * check, no network: it fails with a status-less `WorkOSError` when the + * `WorkOS-Signature` header is missing its parts, older than the SDK's + * tolerance, or does not match `payload`. The decoded event is returned + * for the caller to inspect; the webhook route deliberately does NOT + * apply it (the Events API is the only source the mirror replays from). + */ + constructWebhookEvent: (params: { + readonly payload: Record; + readonly sigHeader: string; + readonly secret: string; + }): Effect.Effect => + use("webhooks.constructEvent", (wos) => + wos.webhooks.constructEvent({ + payload: params.payload, + sigHeader: params.sigHeader, + secret: params.secret, + }), + ), + /** Get an organization (includes domains). */ getOrganization: (organizationId: string) => use("organizations.getOrganization", (wos) => diff --git a/apps/cloud/src/db/db.test.ts b/apps/cloud/src/db/db.test.ts index 889daab6dc..708ea548f0 100644 --- a/apps/cloud/src/db/db.test.ts +++ b/apps/cloud/src/db/db.test.ts @@ -94,7 +94,11 @@ describe("DbService", () => { Effect.gen(function* () { const { db } = yield* DbService; yield* Effect.promise(() => - makeUserStore(db).upsertOrganization({ id: organizationId, name: "Acme" }), + makeUserStore(db).upsertOrganization({ + id: organizationId, + name: "Acme", + updatedAt: new Date(), + }), ); }), ), @@ -121,16 +125,24 @@ describe("DbService", () => { }); describe("upsertOrganization · slug is minted at insert", () => { - const upsert = (org: { id: string; name: string }) => + const upsert = (org: { id: string; name: string; updatedAt?: Date }) => program( Effect.gen(function* () { const { db } = yield* DbService; - return yield* Effect.promise(() => makeUserStore(db).upsertOrganization(org)); + return yield* Effect.promise(() => + makeUserStore(db).upsertOrganization({ + updatedAt: new Date(), + ...org, + }), + ); }), ); it("mints a valid slug on insert", async () => { - const org = await upsert({ id: `org_${crypto.randomUUID()}`, name: "Slug Mint Co" }); + const org = await upsert({ + id: `org_${crypto.randomUUID()}`, + name: "Slug Mint Co", + }); expect(org.slug, "a new org row is born with a slug").toBeTruthy(); expect(isValidOrgSlug(org.slug), "the minted slug fits the URL grammar").toBe(true); }); @@ -143,6 +155,32 @@ describe("upsertOrganization · slug is minted at insert", () => { expect(renamed.name, "the name is refreshed on conflict").toBe("Renamed Org"); }); + it("refuses a name stamped earlier than the one it holds, and never renames a deleted org", async () => { + const id = `org_${crypto.randomUUID()}`; + const t1 = new Date("2026-01-01T00:00:00.000Z"); + const t2 = new Date("2026-01-02T00:00:00.000Z"); + const t3 = new Date("2026-01-03T00:00:00.000Z"); + await upsert({ id, name: "Original Name", updatedAt: t2 }); + // A payload fetched before the rename landed (a login that stalled). + const stale = await upsert({ id, name: "Stale Name", updatedAt: t1 }); + expect(stale.name, "an older name never reverts a newer one").toBe("Original Name"); + const replay = await upsert({ id, name: "Replayed Name", updatedAt: t2 }); + expect(replay.name, "the same instant is accepted, so replays converge").toBe("Replayed Name"); + await program( + Effect.gen(function* () { + const { db } = yield* DbService; + yield* Effect.promise(() => makeUserStore(db).deleteOrganizationCascade(id, t3)); + }), + ); + const afterDelete = await upsert({ + id, + name: "Resurrected Name", + updatedAt: t3, + }); + expect(afterDelete.deletedAt, "a deleted org stays deleted").toEqual(t3); + expect(afterDelete.name, "and keeps its last name").toBe("Replayed Name"); + }); + it("discriminates same-name collisions into distinct slugs", async () => { // Same name → same slug base; the second insert collides on the unique // index and gets a discriminated slug. diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts index 86faa45bb9..a7268e2764 100644 --- a/apps/cloud/src/db/org-deletion.test.ts +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -7,7 +7,8 @@ // two orgs across every tenant table + blob namespace, purges one, and asserts: // - every executor tenant table row for the target org is gone // - org- and user-scoped secret blobs for the target org are gone -// - the identity row is gone and its memberships cascade with it +// - the org's memberships are gone; the identity row stays as a tombstone +// marked deleted (so a delayed feeder cannot re-mint the org live) // - a second org's data is completely untouched // - the blob prefix match escapes LIKE wildcards (a `_` in the org id must // not widen the match to a look-alike namespace) @@ -196,11 +197,14 @@ const NOT_ORG_OWNED: Record = { blob: "org-scoped by namespace prefix, purged via the LIKE match", // Instance-wide, not owned by any org. private_executor_cloud_settings: "singleton instance settings, not org-scoped", - // Identity mirror. `organizations` is deleted directly and `memberships` - // cascades from its FK; `accounts` deliberately outlives the org. - organizations: "the identity row itself, deleted directly", - memberships: "cascades from the organizations FK", + // Identity mirror. `organizations` is kept as a tombstone marked deleted, + // `memberships` are deleted by organization id; `accounts` deliberately + // outlives the org. + organizations: "the identity row itself, kept as a tombstone marked deleted", + memberships: "deleted by organization id", + membership_tombstones: "deleted by organization id", accounts: "shared across orgs — deliberately survives", + workos_sync: "the WorkOS Events API cursor, instance-wide and not org-scoped", }; const countTenantRows = async (db: DrizzleDb, tenant: string): Promise => { @@ -229,14 +233,24 @@ describe("purgeOrganizationData", () => { // A look-alike blob that only an UNescaped `_` wildcard would match: // `o:/…` with the underscore replaced by another char. const trapNs = `o:${orgA.replace("_", "X")}/plugin`; + const now = new Date(); + const deletedAt = new Date("2026-01-02T00:00:00.000Z"); await program( Effect.gen(function* () { const { db } = yield* DbService; yield* Effect.promise(async () => { const store = makeUserStore(db); - await store.upsertOrganization({ id: orgA, name: "Delete Me" }); - await store.upsertOrganization({ id: orgB, name: "Keep Me" }); + await store.upsertOrganization({ + id: orgA, + name: "Delete Me", + updatedAt: now, + }); + await store.upsertOrganization({ + id: orgB, + name: "Keep Me", + updatedAt: now, + }); await store.ensureAccount(accountId); await db.insert(memberships).values({ accountId, organizationId: orgA }); await db.insert(memberships).values({ accountId, organizationId: orgB }); @@ -255,7 +269,7 @@ describe("purgeOrganizationData", () => { await program( Effect.gen(function* () { const { db } = yield* DbService; - yield* Effect.promise(() => makeUserStore(db).deleteOrganizationCascade(orgA)); + yield* Effect.promise(() => makeUserStore(db).deleteOrganizationCascade(orgA, deletedAt)); }), ); @@ -265,10 +279,13 @@ describe("purgeOrganizationData", () => { yield* Effect.promise(async () => { const store = makeUserStore(db); - // Target org: every tenant row + blob gone, identity gone, membership - // cascaded, but the shared account survives (it may join other orgs). + // Target org: every tenant row + blob gone, memberships gone, the + // identity row kept as a tombstone, and the shared account survives + // (it may join other orgs). expect(await countTenantRows(db, orgA)).toBe(0); - expect(await store.getOrganization(orgA)).toBeNull(); + const tombstone = await store.getOrganization(orgA); + expect(tombstone?.deletedAt, "the org row stays, marked deleted").toEqual(deletedAt); + expect(tombstone?.name, "as it was").toBe("Delete Me"); const orgAMemberships = await db .select() .from(memberships) @@ -283,7 +300,7 @@ describe("purgeOrganizationData", () => { // Second org: fully intact. expect(await countTenantRows(db, orgB)).toBeGreaterThan(0); - expect(await store.getOrganization(orgB)).not.toBeNull(); + expect((await store.getOrganization(orgB))?.deletedAt).toBeNull(); const orgBMemberships = await db .select() .from(memberships) diff --git a/apps/cloud/src/db/org-deletion.ts b/apps/cloud/src/db/org-deletion.ts index b2a922a3fd..abcff12f3b 100644 --- a/apps/cloud/src/db/org-deletion.ts +++ b/apps/cloud/src/db/org-deletion.ts @@ -10,11 +10,21 @@ // // External side effects (the WorkOS org, the Autumn customer) are NOT touched // here — the caller (auth handler) sequences those around this purge. +// +// The `organizations` row itself is NOT deleted: it stays as a TOMBSTONE, +// marked `deleted_at`, with its memberships removed. The membership mirror's +// feeders write whatever WorkOS payload they hold — a login that fetched its +// membership list before the deletion can write it after this purge — and +// the tombstone is what makes those writes refuse: `upsertOrganization` +// never re-mints or renames a marked organization, and the mirror never +// inserts a membership of one. Without it the login would insert a fresh, +// live organization row plus an active membership, and the deleted +// organization would authorize again. import { eq, or, sql } from "drizzle-orm"; import type { DrizzleDb } from "./db"; -import { organizations } from "./schema"; +import { memberships, organizations } from "./schema"; import { artifact, blob, @@ -36,10 +46,16 @@ const escapeLike = (value: string): string => value.replace(/[\\%_]/g, "\\$&"); /** * Delete all rows owned by `organizationId`: every executor tenant table, the - * org's secret blobs (org- and user-scoped), and the identity mirror row (which - * cascades to local `memberships`). Idempotent — a second run deletes nothing. + * org's secret blobs (org- and user-scoped), and its local `memberships` — + * and mark the identity row deleted as of `deletedAt` (an earlier mark + * stands), keeping it as a tombstone. Idempotent — a second run deletes + * nothing and keeps the first mark. */ -export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Promise => +export const purgeOrganizationData = ( + db: DrizzleDb, + organizationId: string, + deletedAt: Date, +): Promise => db.transaction(async (tx) => { // Executor tenant tables — every row is scoped by `tenant = organizationId`. await tx.delete(tool).where(eq(tool.tenant, organizationId)); @@ -66,7 +82,14 @@ export const purgeOrganizationData = (db: DrizzleDb, organizationId: string): Pr ), ); - // Identity mirror — FK `ON DELETE CASCADE` removes local memberships too. - // `accounts` are intentionally left: a user may belong to other orgs. - await tx.delete(organizations).where(eq(organizations.id, organizationId)); + // Identity mirror: the memberships go, the organization row stays as a + // tombstone (see the header). `accounts` are intentionally left: a user + // may belong to other orgs. + await tx.delete(memberships).where(eq(memberships.organizationId, organizationId)); + await tx + .update(organizations) + .set({ + deletedAt: sql`coalesce(${organizations.deletedAt}, ${deletedAt.toISOString()}::timestamptz)`, + }) + .where(eq(organizations.id, organizationId)); }); diff --git a/apps/cloud/src/db/schema.ts b/apps/cloud/src/db/schema.ts index fb1bd79987..e0ecd2d664 100644 --- a/apps/cloud/src/db/schema.ts +++ b/apps/cloud/src/db/schema.ts @@ -2,22 +2,56 @@ // Cloud-specific identity & multi-tenancy tables // --------------------------------------------------------------------------- // -// AuthKit owns the canonical user/membership data. We mirror minimally: +// AuthKit owns the canonical user/membership data. We mirror it locally: // -// - `accounts` — login identity (foreign key anchor for created_by, etc.) +// - `accounts` — login identity + profile (foreign key anchor for +// created_by, etc.; email/name/avatar for member lists) // - `organizations` — billing entity, scoping root for all domain data -// - `memberships` — which accounts belong to which organizations +// - `memberships` — which accounts belong to which organizations, with +// the WorkOS role and status +// - `workos_sync` — the WorkOS Events API cursor the reconciler resumes +// from, and the replay boundary the one-off backfill +// records // -// We do NOT mirror invitations or user profile data — those stay in WorkOS -// and are queried via API when needed. +// The mirror is fed by login (the callback has the user + memberships in +// hand), write-through on every Executor-initiated change, and the WorkOS +// Events API (dashboard-side changes). It is the read path for membership and +// member lists — WorkOS is a write target and an event source, never a +// per-request read. Invitations are NOT mirrored; they stay live in WorkOS. +// +// `workos_updated_at` on `accounts` and `memberships` is the WorkOS +// `updatedAt` of the payload that last wrote the row. Every upsert is guarded +// on it, so feeders can be replayed and reordered without an older payload +// clobbering a newer one. -import { pgTable, primaryKey, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; +import { sql } from "drizzle-orm"; +import { index, pgTable, primaryKey, text, timestamp, uniqueIndex } from "drizzle-orm/pg-core"; -/** Login identity. The `id` is the WorkOS user ID. */ -export const accounts = pgTable("accounts", { - id: text("id").primaryKey(), - createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), -}); +/** + * Login identity + mirrored WorkOS profile. The `id` is the WorkOS user ID. + * Profile columns are nullable because a row can be minted by `ensureAccount` + * (an api-key path, a membership arriving before its user event) with nothing + * but the id; the next user payload fills them in. + */ +export const accounts = pgTable( + "accounts", + { + id: text("id").primaryKey(), + email: text("email"), + firstName: text("first_name"), + lastName: text("last_name"), + avatarUrl: text("avatar_url"), + /** WorkOS `updatedAt` of the user payload that last wrote this row. */ + workosUpdatedAt: timestamp("workos_updated_at", { withTimezone: true }), + lastSignInAt: timestamp("last_sign_in_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + // `findByEmail` and the search filter compare lower-cased; the index + // matches that expression so the lookup stays indexed. + emailLowerIdx: index("accounts_email_lower_idx").on(sql`lower(${t.email})`), + }), +); /** * Organization (billing entity, scoping root). The `id` is the WorkOS @@ -33,6 +67,47 @@ export const organizations = pgTable( id: text("id").primaryKey(), name: text("name").notNull(), slug: text("slug").notNull(), + /** + * When this organization's membership list was last FULLY scanned from + * WorkOS (the one-off backfill, or the on-demand scan a seat count + * triggers), or null if it never was. Until then the mirror may hold only + * the members login and write-through happened to record, so a count read + * from it is partial; every seat gate checks this mark first. Per + * organization, never database-wide: an org mirrored lazily after a + * backfill ran starts unmarked and is scanned on its first count. The + * mark also orders membership writes: a payload stamped before it is + * refused, since the scan was the full listing at that instant and a + * membership it did not contain was revoked before it — before the + * events replay boundary, so nothing would tombstone it again. + */ + backfilledAt: timestamp("backfilled_at", { withTimezone: true }), + /** + * 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 }), + /** + * The instant the stored `name` is known to have been the organization's + * name in WorkOS: the WorkOS `updatedAt` of the organization payload that + * wrote it, or — for a name learned from a membership list at sign-in, + * which carries no organization timestamp — the instant that list was + * fetched. A name write stamped earlier than this is refused + * (`upsertOrganization`), so a sign-in whose list predates a rename cannot + * revert it. Null only on rows written before the stamp existed. + */ + workosUpdatedAt: timestamp("workos_updated_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ @@ -41,9 +116,26 @@ export const organizations = pgTable( ); /** - * Account ↔ organization link. Lets us answer "which workspaces does this - * account belong to?" without a WorkOS round-trip, and gives future + * Account ↔ organization link, mirroring the WorkOS organization membership. + * Answers "which workspaces does this account belong to?" and "is this caller + * an active member with which role?" without a WorkOS round-trip, and gives * per-(account, organization) data a foreign key to point at. + * + * `membershipId` is the WorkOS `om_…` id — nullable only because rows written + * before the mirror existed carry none; every feeder sets it. `role` is the + * WorkOS role slug as issued (`admin` / `member`); `status` is the WorkOS + * membership status (`active` / `pending` / `inactive`). A membership deleted + * in WorkOS is never dropped here: it is tombstoned as `inactive` with + * `deleted_at` set, so a feeder replaying an older payload cannot resurrect it. + * + * `deleted_at` means "the membership under `membership_id` was DELETED in + * WorkOS" — an identity fact, not a timestamp to order payloads by. WorkOS + * never reuses a deleted `om_…` id, so any payload naming that id is stale + * however it is stamped, and a payload naming a DIFFERENT id for the same + * (account, organization) is the member re-added: a replacement, ordered by + * `workos_updated_at` like every other write. Distinct from `status = + * 'inactive'` with `deleted_at` null, which is a membership WorkOS + * deactivated but still holds and can reactivate under the same id. */ export const memberships = pgTable( "memberships", @@ -54,9 +146,106 @@ export const memberships = pgTable( organizationId: text("organization_id") .notNull() .references(() => organizations.id, { onDelete: "cascade" }), + membershipId: text("membership_id"), + role: text("role").notNull().default("member"), + status: text("status", { enum: ["active", "pending", "inactive"] }) + .notNull() + .default("active"), + /** WorkOS `updatedAt` of the membership payload that last wrote this row. */ + workosUpdatedAt: timestamp("workos_updated_at", { withTimezone: true }), + /** + * When the membership under `membership_id` was deleted in WorkOS, or null + * while WorkOS still holds it (whatever its `status`). Set once; cleared + * only when a replacement membership (another id) takes the row over. + */ + deletedAt: timestamp("deleted_at", { withTimezone: true }), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ pk: primaryKey({ columns: [t.accountId, t.organizationId] }), + membershipIdUnique: uniqueIndex("memberships_membership_id_unique").on(t.membershipId), + organizationIdx: index("memberships_organization_id_idx").on(t.organizationId), + }), +); + +/** + * Every WorkOS membership id (`om_…`) a DELETE has named, keyed by that id + * alone. The `memberships` row is keyed by (account, organization) and holds + * ONE membership id, so a row-level tombstone can only record the deletion of + * the id the row happens to carry: a delete of membership B arriving while + * the row still holds an older membership A of the same pair (A replaced by + * B in WorkOS before the mirror saw either, B then deleted) has no row to + * tombstone, and a later payload of B — stamped after A, under another id — + * would take the row over live. This ledger is what the mirror consults + * instead: a delete always records the id here, whatever the row holds, and + * no membership write ever names a recorded id again, however it is stamped + * — WorkOS never reuses a deleted `om_…` id. `deleted_at` records when; it + * orders nothing. Rows cascade with their account and organization. + */ +export const membershipTombstones = pgTable( + "membership_tombstones", + { + membershipId: text("membership_id").primaryKey(), + accountId: text("account_id") + .notNull() + .references(() => accounts.id, { onDelete: "cascade" }), + organizationId: text("organization_id") + .notNull() + .references(() => organizations.id, { onDelete: "cascade" }), + deletedAt: timestamp("deleted_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + organizationIdx: index("membership_tombstones_organization_id_idx").on(t.organizationId), }), ); + +/** + * The WorkOS Events API sync state. One row per stream (`id` names the + * stream; the reconciler uses `"events"`), holding the id of the last event + * applied. Advanced only by compare-and-set, so two concurrent reconciler + * runs cannot both believe they own the stream: the loser's CAS fails and it + * stops. + * + * `range_start` on the `"events"` row is the REPLAY BOUNDARY: the instant the + * FIRST one-off backfill run (`scripts/backfill-workos-mirror.ts`) began + * reading WorkOS, recorded BEFORE its first listing. Everything before it is + * covered by that backfill; the reconciler's first run (no cursor yet) reads + * the events stream from here, so a revocation between the backfill and the + * first run is never skipped. Written once: a run that fails part-way leaves + * it standing and its retry keeps it, and a later run keeps it too, because + * the backfill does not refresh everything the events stream carries + * (organization renames, deleted users' profiles) — those after the first + * boundary are replayed from it. Without a cursor or a boundary the + * reconciler does not guess; it waits for the backfill. + * + * `backfill_completed_at` is when a backfill run first wrote EVERY live + * organization (`scripts/backfill-workos-mirror.ts` completing, or refusing + * an organization only because a later listing was already applied). Until + * it is set, the mirror may lack members who have not signed in since it + * shipped, so a membership check read from it would deny them: it is the + * first half of the mirror-readiness mark the authorization path consults + * before trusting the mirror over WorkOS. Write-once — a later completed run + * keeps the first instant, so readiness never flips back. Per-organization + * completeness for the seat gates is tracked separately + * (`organizations.backfilled_at`). + * + * `drained_at` is when a reconciler run last read the events stream to its + * END (an empty page, or a page with nothing after it) — the second half of + * the readiness mark: a mirror whose reconciler has not caught up recently + * may still grant a member WorkOS already revoked, so the authorization + * path trusts the mirror only while this is within its lag budget. Moved + * forward by every draining run; never cleared. A run that stops at its + * page budget or yields to another run leaves it as it was. + * + * Migration 0019 seeds the boundary and the completion mark on a database + * with no organizations, where there is nothing to backfill; `drained_at` + * is left for the reconciler's first run to set (migration 0020). + */ +export const workosSync = pgTable("workos_sync", { + id: text("id").primaryKey(), + cursor: text("cursor"), + rangeStart: timestamp("range_start", { withTimezone: true }), + backfillCompletedAt: timestamp("backfill_completed_at", { withTimezone: true }), + drainedAt: timestamp("drained_at", { withTimezone: true }), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/apps/cloud/src/env-augment.d.ts b/apps/cloud/src/env-augment.d.ts index 773ca4d468..715991f394 100644 --- a/apps/cloud/src/env-augment.d.ts +++ b/apps/cloud/src/env-augment.d.ts @@ -111,6 +111,14 @@ declare global { /** Optional WorkOS base-URL override (WorkOS emulator in tests/dev). */ WORKOS_API_URL?: string; + /** + * Signing secret of the WorkOS webhook endpoint that pokes the + * membership-mirror reconciler (`/api/webhooks/workos`). Set with + * `wrangler secret put WORKOS_WEBHOOK_SECRET`; while unset the route + * refuses every delivery (503) and the every-minute cron alone keeps + * the mirror current. + */ + WORKOS_WEBHOOK_SECRET?: string; // MCP EXECUTOR_MCP_DEBUG?: string; 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 dc2a5a7316..956bb83c3d 100644 --- a/apps/cloud/src/extensions/billing/route.node.test.ts +++ b/apps/cloud/src/extensions/billing/route.node.test.ts @@ -1,12 +1,28 @@ 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 { 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"); +// 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, +}); + const MEMBER = "user_session"; const SESSION_ORG = "org_session"; const URL_ORG = "org_url"; @@ -16,56 +32,90 @@ 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. +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(() => fn({ - ensureAccount: async (id: string) => ({ id, createdAt }), - getAccount: async (id: string) => ({ id, createdAt }), + 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 === URL_SLUG ? URL_ORG : "org_outsider", 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`), + }), +); + 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))); describe("billing route org selector", () => { it.effect("fails closed when no selector header is sent", () => diff --git a/apps/cloud/src/extensions/billing/service.ts b/apps/cloud/src/extensions/billing/service.ts index c4759f269c..af04a5b829 100644 --- a/apps/cloud/src/extensions/billing/service.ts +++ b/apps/cloud/src/extensions/billing/service.ts @@ -58,6 +58,17 @@ const isCustomerNotFoundCause = (cause: unknown): boolean => { } }; +/** + * The HTTP status an Autumn failure carries, when the autumn-js SDK error + * underneath it has one; `undefined` for a network or SDK-level failure. + */ +export const autumnStatusOf = (failure: AutumnFailure): number | undefined => { + const cause = failure.cause; + if (typeof cause !== "object" || cause === null) return undefined; + const { statusCode } = cause as { readonly statusCode?: unknown }; + return typeof statusCode === "number" ? statusCode : undefined; +}; + // --------------------------------------------------------------------------- // Service interface // --------------------------------------------------------------------------- diff --git a/apps/cloud/src/extensions/routes.ts b/apps/cloud/src/extensions/routes.ts index 4c04bd02cd..f1c4389fe7 100644 --- a/apps/cloud/src/extensions/routes.ts +++ b/apps/cloud/src/extensions/routes.ts @@ -9,6 +9,8 @@ // - Swagger UI + the OpenAPI JSON for the full cloud spec. // - the Autumn billing proxy (`/api/billing/*`) — billing-as-extension (the // `extensions.routes` SEAM, but served under `/api` like everything else). +// - the WorkOS webhook (`/api/webhooks/workos`) — signature-verified poke of +// the membership-mirror reconciler. // - the global request-failure logging middleware. // // They all serve UNDER the `/api` prefix (the same namespace the protected + @@ -19,15 +21,17 @@ // so the postgres.js socket lives in the request fiber's scope). // --------------------------------------------------------------------------- +import { env, waitUntil } from "cloudflare:workers"; import { Effect, Layer } from "effect"; import { HttpRouter, HttpServerResponse } from "effect/unstable/http"; 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 { WorkOsMirror } from "../auth/workos-mirror"; import { CloudAuthPublicHandlers, CloudSessionAuthHandlers, @@ -35,6 +39,8 @@ import { } from "../auth/handlers"; import { CloudAuthApi, CloudAuthPublicApi } from "../auth/api"; import { SessionAuthLive } from "../auth/middleware-live"; +import { runWorkOsEventsSync } from "../auth/workos-events-runner"; +import { makeWorkOsWebhookRoute } from "../auth/workos-webhook"; import { makeCloudAdminUsersRoutes } from "../admin/admin-users-api"; import { OrgApi, OrgHttpApi } from "../org/api"; import { orgAuthMiddleware } from "../org/auth-middleware"; @@ -72,7 +78,9 @@ const spec = OpenApi.fromApi(CloudOpenApi); * read it — the few app-only billing touchpoints. It is NOT on the neutral boot * core. */ -export const makeCloudExtensionRoutes = (rsLive: Layer.Layer) => { +export const makeCloudExtensionRoutes = ( + rsLive: Layer.Layer, +) => { // Session routes (login / callback / me / switch-org / …). Handlers yield // `UserStoreService` directly; the per-request DB combine keeps the postgres // socket request-scoped. @@ -108,7 +116,19 @@ export const makeCloudExtensionRoutes = (rsLive: Layer.Layer Unauthorized (challenge: Bearer resource_metadata=…) // - invalid token/api key -> Unauthorized (challenge: Bearer error="invalid_token" …) // - transient JWKS OR membership-lookup infra -> Unavailable (caught here; -// envelope renders a retryable 503 -32001). A WorkOS blip during the live -// org check is a TRANSIENT failure, not evidence the org is gone, so it -// must NOT reach the Forbidden/destroy path below. +// envelope renders a retryable 503 -32001). The membership check reads +// the local mirror (`auth/organization.ts`), so the infra that can fail +// here is the database — or WorkOS, on the one path that still asks it +// (an organization the mirror has never seen). Either is TRANSIENT, not +// evidence the org is gone, and must NOT reach the Forbidden/destroy +// path below. // - no org / revoked org -> Forbidden ("No organization in session …", -32001). // This requires a POSITIVE determination (the lookup SUCCEEDED and the org // is absent), never a failed lookup. Because authenticate reads the -// mcp-session-id header to do the live org check, the envelope's +// mcp-session-id header to do the membership check, the envelope's // dispose-on-Forbidden-with-sessionId path reproduces the old inline // clearExistingSession. // - verified + org allowed -> Authenticated(principal) @@ -70,15 +73,17 @@ const TOOLKIT_PROTECTED_RESOURCE_METADATA_PATH = `${PROTECTED_RESOURCE_METADATA_ const NO_ORGANIZATION_MESSAGE = "No organization in session — log in via the web app first"; -// A transient WorkOS failure (429 / 5xx / timeout / network) during the live -// membership lookup must NOT masquerade as "org revoked" — but the failure -// channel alone is not enough to tell them apart: WorkOS also answers with -// DEFINITIVE 4xx denials (401 revoked/invalid API key, 403, 404 deleted org) -// that the SDK throws as typed exceptions. So the classification is: +// A transient failure during the membership lookup must NOT masquerade as +// "org revoked". The lookup reads the local mirror, so in steady state its +// only failure is the database; the one path that still asks WorkOS (an +// organization the mirror has never seen, resolved for a caller WorkOS +// confirms as its member) can also fail with a DEFINITIVE 4xx denial (401 +// revoked/invalid API key, 403, 404 deleted org) that the SDK throws as a +// typed exception. So the classification is: // - lookup SUCCEEDS with `null` -> genuine absence -> Forbidden // - lookup FAILS with WorkOS 401/403/404 -> definitive denial -> Forbidden // (fail CLOSED: WorkOS answered and said no; retrying cannot help) -// - lookup FAILS any other way (429/5xx/timeout/network/no status) +// - lookup FAILS any other way (database, 429/5xx/timeout/network/no status) // -> transient -> retryable 503, session preserved // The status rides on `WorkOSError.status` (threaded from the SDK exception at // the service boundary in auth/workos.ts); `isDefinitiveWorkOSDenial` is the @@ -91,7 +96,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 +187,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..9d34df7c57 100644 --- a/apps/cloud/src/mcp/auth.ts +++ b/apps/cloud/src/mcp/auth.ts @@ -17,6 +17,8 @@ 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 { makeWorkOsMirrorLayer } from "../auth/workos-mirror"; import { CoreSharedServices } from "../auth/workos"; import { makeDbLayer } from "../db/db"; import { bearerChallenge } from "./responses"; @@ -60,7 +62,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 +203,26 @@ 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 workOsMirrorLive = makeWorkOsMirrorLayer().pipe(Layer.provide(dbLive)); + return Layer.mergeAll( + dbLive, + userStoreLive, + memberDirectoryLive, + 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) @@ -316,7 +326,9 @@ export const McpAuthLive = Layer.effect( if (!verified) return mcpUnauthorized("invalid_token", "The access token is invalid"); if (Predicate.isTagged(verified, "Unauthorized")) return verified; if (!verified.accountId) { - yield* Effect.annotateCurrentSpan({ "mcp.auth.outcome": "missing_subject" }); + yield* Effect.annotateCurrentSpan({ + "mcp.auth.outcome": "missing_subject", + }); return mcpUnauthorized("invalid_token", "The access token is invalid"); } yield* Effect.annotateCurrentSpan({ @@ -331,7 +343,9 @@ export const McpAuthLive = Layer.effect( verifyBearer: Effect.fn("mcp.auth.verify_bearer")(function* (request) { const authHeader = request.headers.get("authorization"); if (!authHeader?.startsWith(BEARER_PREFIX)) { - yield* Effect.annotateCurrentSpan({ "mcp.auth.outcome": "missing_bearer" }); + yield* Effect.annotateCurrentSpan({ + "mcp.auth.outcome": "missing_bearer", + }); return mcpUnauthorized("missing_bearer"); } const token = authHeader.slice(BEARER_PREFIX.length).trim(); diff --git a/apps/cloud/src/org/auth-middleware.ts b/apps/cloud/src/org/auth-middleware.ts index 477de037bc..9c61236f3b 100644 --- a/apps/cloud/src/org/auth-middleware.ts +++ b/apps/cloud/src/org/auth-middleware.ts @@ -1,10 +1,15 @@ -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 { 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 +32,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 membership mirror (`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. + */ +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 +81,16 @@ 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, +) => 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..1cae2aa9a2 100644 --- a/apps/cloud/src/org/handlers.test.ts +++ b/apps/cloud/src/org/handlers.test.ts @@ -1,24 +1,37 @@ -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 { 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, read from the local membership mirror unconditionally +// (`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 +68,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,9 +96,13 @@ 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" }), + Effect.succeed({ + id: "dom_1", + organizationId: "org_1", + domain: "acme.test", + }), }), ), ), @@ -132,9 +114,13 @@ 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" }), + Effect.succeed({ + id: "dom_other", + organizationId: "org_2", + domain: "evil.test", + }), }), ), ), @@ -146,7 +132,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 +140,159 @@ describe("Org domain handlers", () => { ); }); }); + +// --------------------------------------------------------------------------- +// The admin gate over HTTP, through `orgAuthMiddleware`: the role the gate +// sees is the one the middleware resolved through `authorizeOrganizationSelector`, +// which reads the local membership mirror unconditionally — there is no +// readiness gate and no WorkOS fallback (`auth/organization.ts`). The mirror +// row below is the sole source of the role, so `stubWorkOS` here serves only +// session authentication and dies on anything else, proving membership is +// never re-checked against WorkOS. +// --------------------------------------------------------------------------- + +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, at whatever role a test sets. +const callerRow = (role: "admin" | "member"): DirectoryMember => ({ + accountId: CALLER, + membershipId: `om_${CALLER}_${ORG}`, + organizationId: ORG, + email: null, + name: null, + avatarUrl: null, + role, + status: "active", + lastActiveAt: null, +}); + +const unread = (why: string) => () => Effect.die(why); +const stubDirectory = (role: "admin" | "member") => + Layer.succeed(MemberDirectory)({ + membership: (accountId, organizationId) => + Effect.succeed(accountId === CALLER && organizationId === ORG ? callerRow(role) : 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 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: session authentication and the domain to +// delete. `stubWorkOS` dies on anything else, so a `listUserMemberships` call +// would fail the test — membership is never re-checked against WorkOS. +const workosForCaller = (deleted: string[]) => + stubWorkOS({ + authenticateSealedSession: () => + Effect.succeed({ + userId: CALLER, + email: "caller@placeholder.test", + organizationId: ORG, + }), + getOrganizationDomain: () => + Effect.succeed({ id: DOMAIN, organizationId: ORG, domain: "acme.test" }), + deleteOrganizationDomain: (domainId: string) => + Effect.sync(() => { + deleted.push(domainId); + }), + }); + +const orgApp = (role: "admin" | "member", workos: Layer.Layer) => { + const rsLive = Layer.mergeAll(stubDb, stubUsers, stubDirectory(role), stubMirror); + 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 (role: "admin" | "member") => { + const deleted: string[] = []; + const app = orgApp(role, workosForCaller(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", async () => { + const { status, deleted } = await deleteDomain("admin"); + expect(status).toBe(200); + expect(deleted).toEqual([DOMAIN]); + }); + + it("refuses a mirrored plain member, without ever consulting WorkOS", async () => { + const { status, deleted } = await deleteDomain("member"); + expect(status).toBe(403); + expect(deleted).toEqual([]); + }); +}); 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/cloud/src/server.ts b/apps/cloud/src/server.ts index 4f5bf76289..fc9c146f3a 100644 --- a/apps/cloud/src/server.ts +++ b/apps/cloud/src/server.ts @@ -13,6 +13,7 @@ import handler from "@tanstack/react-start/server-entry"; import { isAppOwnedPath, servedByAppPlane } from "./app-paths"; import { marketingProxyRequest } from "./edge/marketing"; import { passthroughResponse } from "./edge/passthrough"; +import { runWorkOsEventsSync } from "./auth/workos-events-runner"; import { makeCloudMcpAgentHandler } from "./mcp/agent-handler"; import { classifyMcpPath, prepareMcpOrgScope } from "./mcp/mount"; import { parseTraceparent } from "./mcp/traceparent"; @@ -433,6 +434,20 @@ const cloudflareHandler: ExportedHandler = { }, ); }, + + // Cron: the membership-mirror reconciler (wrangler.jsonc `triggers.crons`, + // every minute). One pass over the WorkOS Events API from the persisted + // cursor, on fresh request-scoped services. `Sentry.withSentry` instruments + // `scheduled` alongside `fetch` (`instrumentExportedHandlerScheduled`), so + // a failing pass reports like a failing request. The tracer is installed + // here as on the fetch path — a scheduled invocation may be the isolate's + // first — and flushed past the pass so the run's spans export before the + // isolate goes idle. + scheduled: async (_controller, _env, ctx) => { + installTracerProvider(); + await runWorkOsEventsSync(); + ctx.waitUntil(flushTracerProvider()); + }, }; export default Sentry.withSentry(cloudSentryOptions, cloudflareHandler); diff --git a/apps/cloud/wrangler.jsonc b/apps/cloud/wrangler.jsonc index 3578de6d25..6d92064589 100644 --- a/apps/cloud/wrangler.jsonc +++ b/apps/cloud/wrangler.jsonc @@ -18,6 +18,13 @@ "limits": { "cpu_ms": 30000, }, + // Every minute: replay the WorkOS Events API into the membership mirror + // (`scheduled` in src/server.ts → auth/workos-events-sync.ts). Changes made + // in the WorkOS dashboard reach the mirror within this interval; the + // signed webhook at /api/webhooks/workos shortens it to seconds. + "triggers": { + "crons": ["* * * * *"], + }, "observability": { "enabled": true, }, 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 new file mode 100644 index 0000000000..bd25e8d13b --- /dev/null +++ b/apps/host-selfhost/src/auth/member-directory.test.ts @@ -0,0 +1,151 @@ +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; + +import { afterAll, describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { MemberDirectory } from "@executor-js/api/server"; + +// The self-host `MemberDirectory` over REAL Better Auth: the org plugin's +// `member` rows joined to `user` rows through Better Auth's own adapter, the +// same read `mcp/auth.ts` makes for an OAuth token's role. +// +// Members are created server-side (`createUser` + `addMember`, no session — +// the same calls the bootstrap seed makes), so this pins the adapter read +// itself rather than the sign-up flow. + +process.env.EXECUTOR_DATA_DIR = mkdtempSync(join(tmpdir(), "eh-member-directory-")); +process.env.BETTER_AUTH_SECRET = "member-directory-secret-0123456789-abcdefghij"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_EMAIL = "owner@placeholder.test"; +process.env.EXECUTOR_BOOTSTRAP_ADMIN_PASSWORD = "owner-pass-123456"; + +const { makeSelfHostApp } = await import("../app"); +const { BetterAuth } = await import("./better-auth"); +const { betterAuthMemberDirectoryLayer } = await import("./member-directory"); + +const app = await makeSelfHostApp(); +afterAll(() => app.closeDb()); + +const { auth, organizationId } = app.betterAuth; +const directoryLayer = betterAuthMemberDirectoryLayer.pipe( + Layer.provide(Layer.succeed(BetterAuth)(app.betterAuth)), +); +const run = (body: Effect.Effect) => + Effect.runPromise(body.pipe(Effect.provide(directoryLayer))); + +const addMember = async (email: string, name: string, role: "admin" | "member") => { + const created = await auth.api.createUser({ + body: { email, name, password: "pw-12345678" }, + }); + await auth.api.addMember({ + body: { userId: created.user.id, role, organizationId }, + }); + return created.user.id; +}; + +// Better Auth lower-cases the email it stores; the mixed case here proves the +// directory does not depend on that. +const ada = await addMember("Ada.Lovelace@Placeholder.test", "Ada Lovelace", "admin"); +const grace = await addMember("grace@placeholder.test", "Grace Hopper", "member"); +const linus = await addMember("linus@placeholder.test", "Linus", "member"); +// A user who is NOT a member of the org: must never be reported. +const outsider = await auth.api.createUser({ + body: { + email: "outsider@placeholder.test", + name: "Outsider", + password: "pw-12345678", + }, +}); + +describe("self-host MemberDirectory", () => { + it("reports the org's members with Better Auth roles, ordered by email", async () => { + const members = await run( + Effect.flatMap(MemberDirectory.asEffect(), (d) => d.members(organizationId)), + ); + const emails = members.map((m) => m.email); + expect(emails).toEqual([ + "ada.lovelace@placeholder.test", + "grace@placeholder.test", + "linus@placeholder.test", + "owner@placeholder.test", + ]); + const first = members.find((m) => m.accountId === ada); + expect(first).toMatchObject({ + organizationId, + role: "admin", + status: "active", + name: "Ada Lovelace", + lastActiveAt: null, + }); + expect(first?.membershipId, "membershipId is the member ROW id, not the user id").not.toBe(ada); + expect(members.find((m) => m.email === "owner@placeholder.test")?.role).toBe("owner"); + expect(members.some((m) => m.accountId === outsider.user.id)).toBe(false); + }); + + it("searches email and name case-insensitively and pages stably", async () => { + const result = await run( + Effect.gen(function* () { + const d = yield* MemberDirectory; + return { + byEmail: yield* d.members(organizationId, { search: "LOVELACE@" }), + byName: yield* d.members(organizationId, { search: " grace hop " }), + nothing: yield* d.members(organizationId, { search: "nobody" }), + page1: yield* d.members(organizationId, { limit: 2, offset: 0 }), + page2: yield* d.members(organizationId, { limit: 2, offset: 2 }), + inactive: yield* d.members(organizationId, { + statuses: ["inactive"], + }), + all: yield* d.members(organizationId), + }; + }), + ); + expect(result.byEmail.map((m) => m.accountId)).toEqual([ada]); + expect(result.byName.map((m) => m.accountId)).toEqual([grace]); + expect(result.nothing).toEqual([]); + // Two pages of two, concatenated, are the whole ordered list. + const paged = [...result.page1, ...result.page2].map((m) => m.accountId); + expect(paged).toEqual(result.all.map((m) => m.accountId)); + expect(result.page1.length + result.page2.length).toBe(4); + expect(result.inactive, "Better Auth members are always active").toEqual([]); + }); + + it("resolves one membership, a batch by id, and an email in any casing", async () => { + 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, + 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"), + }; + }), + ); + 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 new file mode 100644 index 0000000000..f6787ac3e6 --- /dev/null +++ b/apps/host-selfhost/src/auth/member-directory.ts @@ -0,0 +1,246 @@ +// --------------------------------------------------------------------------- +// Self-host's `MemberDirectory`: the shared read seam over Better Auth's +// organization `member` table joined with `user`. +// +// Reads go through Better Auth's OWN adapter (`auth.$context` → `adapter`) +// rather than `auth.api.listMembers`, for the same reason `mcp/auth.ts` reads +// the membership row that way: the adapter needs no session headers, so the +// HTTP, MCP, and admin planes can all resolve membership through this one code +// path. Better Auth members carry no status (an invitation is not a member), +// so every member reports `"active"`; roles are the plugin's own slugs +// (`owner` / `admin` / `member`), verbatim. +// +// The adapter has no case-insensitive predicate and no join filter, so search +// and email matching run in memory over the org's member list — a single-org +// self-host instance is small, and one code path answering every read is +// worth more than an indexed lookup here. +// --------------------------------------------------------------------------- + +import { Effect, Layer, Schema } from "effect"; + +import { + DEFAULT_MEMBER_STATUSES, + MemberDirectory, + MemberDirectoryError, + normalizeAdminUserEmail, + normalizeMemberSearch, + type DirectoryMember, + type MemberDirectoryShape, + type MemberQuery, + type MemberStatus, +} from "@executor-js/api/server"; + +import { BetterAuth, type BetterAuthHandle } from "./better-auth"; + +// What the adapter hands back is untyped (`findMany` trusts its caller), so +// each row is decoded at this boundary. Extra columns are dropped. +const MemberRow = Schema.Struct({ + id: Schema.String, + userId: Schema.String, + organizationId: Schema.String, + role: Schema.String, +}); +const UserRow = Schema.Struct({ + id: Schema.String, + email: Schema.String, + name: Schema.NullishOr(Schema.String), + image: Schema.NullishOr(Schema.String), +}); +const decodeMemberRows = Schema.decodeUnknownEffect(Schema.Array(MemberRow)); +const decodeUserRows = Schema.decodeUnknownEffect(Schema.Array(UserRow)); + +// The adapter caps every `findMany` at 100 rows unless told otherwise, so +// reads page explicitly until a short page. `in` predicates are chunked so a +// large id list never overruns SQLite's bound-parameter limit. +const PAGE_SIZE = 500; +const IN_CHUNK = 200; + +type BetterAuthAdapter = Awaited["adapter"]; +type AdapterWhere = Parameters[0]["where"]; + +const byEmailThenAccount = (a: DirectoryMember, b: DirectoryMember): number => { + // Nulls sort last, matching Postgres's default ASC ordering on cloud. + if (a.email !== b.email) { + if (a.email === null) return 1; + if (b.email === null) return -1; + return a.email < b.email ? -1 : 1; + } + return a.accountId < b.accountId ? -1 : a.accountId > b.accountId ? 1 : 0; +}; + +const makeService = (adapter: BetterAuthAdapter): MemberDirectoryShape => { + const read = (op: string, fn: () => Promise): Effect.Effect => + Effect.tryPromise(fn).pipe( + Effect.tapCause((cause) => Effect.logError(`member_directory.${op} failed`, cause)), + Effect.mapError( + () => + new MemberDirectoryError({ + message: `Failed to read the member directory (${op})`, + }), + ), + Effect.withSpan(`member_directory.${op}`), + ); + + const undecodable = (op: string) => () => + new MemberDirectoryError({ + message: `Undecodable member directory row (${op})`, + }); + + const memberRows = (op: string, where: AdapterWhere) => + Effect.gen(function* () { + const rows: (typeof MemberRow)["Type"][] = []; + for (let offset = 0; ; offset += PAGE_SIZE) { + const page = yield* read(op, () => + adapter.findMany({ + model: "member", + where, + limit: PAGE_SIZE, + offset, + }), + ).pipe(Effect.flatMap(decodeMemberRows), Effect.mapError(undecodable(op))); + rows.push(...page); + if (page.length < PAGE_SIZE) return rows; + } + }); + + const userRows = (op: string, userIds: readonly string[]) => + Effect.gen(function* () { + const users = new Map(); + for (let start = 0; start < userIds.length; start += IN_CHUNK) { + const ids = userIds.slice(start, start + IN_CHUNK); + const page = yield* read(op, () => + adapter.findMany({ + model: "user", + where: [{ field: "id", operator: "in", value: [...ids] }], + limit: ids.length, + }), + ).pipe(Effect.flatMap(decodeUserRows), Effect.mapError(undecodable(op))); + for (const user of page) users.set(user.id, user); + } + return users; + }); + + // Every read: the member rows matching `where`, joined to their users. A + // member whose user row is gone is not reported — there is no principal + // behind it to name. + const load = (op: string, where: AdapterWhere) => + Effect.gen(function* () { + const rows = yield* memberRows(op, where); + const users = yield* userRows( + op, + rows.map((row) => row.userId), + ); + const members: DirectoryMember[] = []; + for (const row of rows) { + const user = users.get(row.userId); + if (user === undefined) continue; + members.push({ + accountId: row.userId, + membershipId: row.id, + organizationId: row.organizationId, + email: user.email, + name: user.name ?? null, + avatarUrl: user.image ?? null, + role: row.role, + status: "active", + lastActiveAt: null, + }); + } + return members; + }); + + const orgWhere = (organizationId: string): AdapterWhere => [ + { field: "organizationId", value: organizationId }, + ]; + + const matches = (member: DirectoryMember, term: string): boolean => + (member.email !== null && member.email.toLowerCase().includes(term)) || + (member.name !== null && member.name.toLowerCase().includes(term)); + + // Every Better Auth member is active; a query for other statuses only has + // nothing to report. + const reportsActive = (statuses: readonly MemberStatus[]) => statuses.includes("active"); + + return { + membership: (accountId, organizationId, statuses = DEFAULT_MEMBER_STATUSES) => + !reportsActive(statuses) + ? Effect.succeed(null) + : load("membership", [ + { field: "userId", value: accountId }, + { 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 []; + const term = normalizeMemberSearch(query.search); + const all = yield* load("members", orgWhere(organizationId)); + const matched = term === undefined ? all : all.filter((m) => matches(m, term)); + matched.sort(byEmailThenAccount); + const start = query.offset ?? 0; + const end = query.limit === undefined ? undefined : start + query.limit; + return matched.slice(start, end); + }), + + membersById: (organizationId, accountIds, statuses = DEFAULT_MEMBER_STATUSES) => + Effect.gen(function* () { + const found = new Map(); + if (!reportsActive(statuses)) return found; + for (let start = 0; start < accountIds.length; start += IN_CHUNK) { + const ids = accountIds.slice(start, start + IN_CHUNK); + const members = yield* load("membersById", [ + { field: "organizationId", value: organizationId }, + { field: "userId", operator: "in", value: [...ids] }, + ]); + for (const member of members) found.set(member.accountId, member); + } + return found; + }), + + findByEmail: (organizationId, email, statuses = DEFAULT_MEMBER_STATUSES) => + !reportsActive(statuses) + ? Effect.succeed(null) + : load("findByEmail", orgWhere(organizationId)).pipe( + Effect.map( + (members) => + members.find( + (member) => + member.email !== null && normalizeAdminUserEmail(member.email) === email, + ) ?? null, + ), + ), + }; +}; + +/** The self-host `MemberDirectory` over the boot-scoped Better Auth handle. */ +export const betterAuthMemberDirectoryLayer: Layer.Layer = + Layer.effect(MemberDirectory)( + Effect.gen(function* () { + const { auth } = yield* BetterAuth; + const { adapter } = yield* Effect.promise(() => auth.$context); + return MemberDirectory.of(makeService(adapter)); + }), + ); diff --git a/e2e/cloud/login-csrf.test.ts b/e2e/cloud/login-csrf.test.ts index fbc237e8b3..183eb72a16 100644 --- a/e2e/cloud/login-csrf.test.ts +++ b/e2e/cloud/login-csrf.test.ts @@ -36,12 +36,12 @@ scenario( if (!callback) throw new Error("AuthKit did not return a callback"); return callback; }; - await step("Refuse a valid authorization code with no state", async () => { + await step("Discard a valid code without state and restart login", async () => { const callback = new URL(await interceptCallback()); callback.searchParams.delete("state"); const response = await page.request.get(callback.toString(), { maxRedirects: 0 }); - expect(response.status()).toBe(400); - expect(await response.text()).toBe("Invalid login state"); + expect(response.status()).toBe(302); + expect(response.headers().location).toBe("/api/auth/login"); expect( (await page.context().cookies()).some((cookie) => cookie.name === "wos-session"), ).toBe(false); @@ -73,3 +73,54 @@ scenario( }); }), ); + +scenario( + "Auth · a provider-initiated login restarts with browser-bound state", + { timeout: 180_000 }, + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const email = `provider-login-${randomUUID()}@e2e.test`; + // Discover this deployment's provider URL without setting a browser cookie. + const login = yield* Effect.promise(() => + fetch(new URL("/api/auth/login", target.baseUrl), { redirect: "manual" }), + ); + expect(login.status).toBe(302); + const location = login.headers.get("location"); + if (!location) throw new Error("Login did not redirect to AuthKit"); + const providerUrl = new URL(location); + providerUrl.searchParams.delete("state"); + + yield* browser.session({ label: "anonymous" }, async ({ page, step }) => { + await step("Sign in directly at the provider, as a hosted invitation does", async () => { + await page.goto(providerUrl.toString()); + await page.getByPlaceholder("new-user@example.com").fill(email); + const callbackResponse = page.waitForResponse( + (response) => new URL(response.url()).pathname === "/api/auth/callback", + ); + await page.getByRole("button", { name: /Continue/ }).click(); + const callback = await callbackResponse; + expect(new URL(callback.url()).searchParams.has("state")).toBe(false); + expect(callback.status()).toBe(302); + expect(callback.headers().location).toBe("/api/auth/login"); + await page.waitForURL((url) => url.searchParams.has("state")); + expect((await page.context().cookies()).map((cookie) => cookie.name)).not.toContain( + "wos-session", + ); + }); + + await step("Complete the fresh login and reach the signed-in app", async () => { + // The emulator asks again; hosted AuthKit can reuse its browser session. + await page.getByPlaceholder("new-user@example.com").fill(email); + await page.getByRole("button", { name: /Continue/ }).click(); + await page.waitForURL((url) => url.pathname === "/create-org", { timeout: 30_000 }); + const me = await page.request.get(new URL("/api/auth/me", target.baseUrl).toString()); + expect(me.status()).toBe(200); + expect(await me.json()).toMatchObject({ user: { email } }); + const cookieNames = (await page.context().cookies()).map((cookie) => cookie.name); + expect(cookieNames).toContain("wos-session"); + expect(cookieNames).not.toContain("wos-login-state"); + }); + }); + }), +); diff --git a/e2e/cloud/mcp-workos-blip-session-survival.test.ts b/e2e/cloud/mcp-workos-blip-session-survival.test.ts index 905dff41c6..d6a5022aa5 100644 --- a/e2e/cloud/mcp-workos-blip-session-survival.test.ts +++ b/e2e/cloud/mcp-workos-blip-session-survival.test.ts @@ -1,25 +1,25 @@ -// Cloud: how the per-request live-membership check classifies WorkOS failures, -// pinned in BOTH directions at the real upstream (faults armed on the WorkOS -// emulator's membership endpoint — the same emulator the product's real WorkOS -// SDK talks to; no product code or stubs touched): +// Cloud: an MCP session's relationship to WorkOS after the membership mirror. // -// 1. A TRANSIENT WorkOS outage (5xx/timeout) must NOT destroy a live MCP -// session. This is the churn-risk defect: a WorkOS blip used to collapse to -// Forbidden, and a Forbidden carrying a session id schedules the session -// Durable Object for destruction (in-flight executions, paused approvals, -// undelivered results — all gone). For a shared-API-key org a single blip -// could mass-condemn every session at once. Contract: the blip request fails -// RETRYABLY (503 + Retry-After), and once WorkOS recovers the SAME session -// id keeps serving requests. +// Membership is authorized from the local mirror on every /mcp request +// (`auth/organization.ts`); WorkOS is a write target and an event source, not +// a per-request read. Two contracts follow, pinned here at the real upstream +// (faults armed on the WorkOS emulator's membership endpoint — the same +// emulator the product's real WorkOS SDK talks to; no product code or stubs +// touched): // -// 2. A DEFINITIVE WorkOS denial (401 — the revoked/invalid API key answer) must -// fail CLOSED: Forbidden, session condemned. Retrying cannot help; treating -// it as transient would preserve sessions indefinitely for a revoked -// customer (the fail-open inversion the adversarial review caught). +// 1. A WorkOS OUTAGE is INVISIBLE to a live session. Before the mirror, a +// 5xx from the membership lookup had to be classified as transient (a +// retryable 503 that left the session alive) so a blip could not +// mass-condemn every session of a shared-API-key org. Now the request never +// asks WorkOS at all: a request issued during the outage is a plain 200, +// and the SAME session id keeps serving afterwards. The fault is armed on +// the exact endpoint the old check hit, so an unnoticed regression back to +// a per-request WorkOS read would fail this as a 503 (or worse, a 403). // -// Red/green for (1): pre-fix, the outage request returns a session-destroying -// Forbidden and the post-outage request gets 404 "reconnect". With the fix the -// outage request is a 503 and the post-outage request is a clean 200. +// 2. A REVOKED membership still fails CLOSED. The mirror is not a cache with a +// TTL: a removal made through the product writes the mirror in the same +// request, so the removed member's next /mcp request is a Forbidden, the +// session is condemned, and the id is dead. Retrying cannot help. import { expect } from "@effect/vitest"; import { Effect } from "effect"; @@ -29,6 +29,7 @@ import { scenario } from "../src/scenario"; import { Mcp, Target } from "../src/services"; import type { Identity } from "../src/target"; import { WORKOS_EMULATOR_PORT } from "../targets/cloud"; +import { cookieOf, joinOrg, orgSelectorOf } from "./support/session"; const JSON_AND_SSE = "application/json, text/event-stream"; const PROTOCOL_VERSION = "2025-03-26"; @@ -105,11 +106,11 @@ const openSession = async (mcpUrl: string, bearer: string): Promise => { return sessionId; }; -// The live membership check is `GET /user_management/organization_memberships` -// (WorkOS `listOrganizationMemberships`). A bounded count covers the outage -// request without leaking into later (post-clear) requests; we also clear -// explicitly. `times` is generous so any internal retry inside the one faulted -// request still sees the outage, but the finalizer removes whatever remains. +// The endpoint the pre-mirror per-request check hit +// (`GET /user_management/organization_memberships`). Armed to prove it is no +// longer on the request path: a request that reached it would fail. `times` +// is generous so any retry inside a faulted request still sees the outage; the +// finalizer removes whatever remains. const MEMBERSHIP_FAULT = { match: { method: "GET", @@ -119,22 +120,8 @@ const MEMBERSHIP_FAULT = { times: 8, } as const; -// The definitive-denial counterpart: WorkOS ANSWERS the membership lookup with -// 401 — the shape of a revoked/invalid API key. Not a blip; must fail closed. -const MEMBERSHIP_DENIAL_FAULT = { - match: { - method: "GET", - pathPattern: "/user_management/organization_memberships*", - }, - response: { - status: 401, - body: { message: "Could not authorize the request. Maybe your API key is invalid?" }, - }, - times: 8, -} as const; - scenario( - "MCP sessions · a transient WorkOS outage 503s retryably and leaves the session alive", + "MCP sessions · a WorkOS outage is invisible to a live session, which is authorized from the mirror", {}, Effect.gen(function* () { const target = yield* Target; @@ -151,106 +138,150 @@ scenario( const healthy = yield* Effect.promise(() => mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(2) }), ); - expect(healthy.status, "the session serves requests before the blip").toBe(200); + expect(healthy.status, "the session serves requests before the outage").toBe(200); yield* Effect.promise(() => healthy.text()); yield* Effect.gen(function* () { - // The blip: WorkOS membership lookups start failing with 503. + // The outage: WorkOS membership lookups would fail with 503 — if + // anything asked. yield* Effect.promise(() => workos.faults.arm(MEMBERSHIP_FAULT)); - // A request issued DURING the outage. The membership lookup fails - // transiently — this must be a retryable 503, NOT a Forbidden (which - // would condemn the session). + // A request issued DURING the outage. Membership is read from the + // mirror, so WorkOS is never consulted and the request is a plain + // success — not a retryable 503 (the pre-mirror contract) and never a + // Forbidden (which would condemn the session). const duringOutage = yield* Effect.promise(() => mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(3) }), ); - const outageBody = (yield* Effect.promise(() => duringOutage.json())) as JsonRpcError; expect( duringOutage.status, - "a WorkOS blip is a retryable 503, not a session-destroying error", - ).toBe(503); - expect( - duringOutage.status, - "the blip is NOT surfaced as a 404 reconnect (which would mean the session was destroyed)", - ).not.toBe(404); - expect( - outageBody.error.code, - "the 503 is a JSON-RPC error envelope the transport retries", - ).toBe(-32001); - expect( - duringOutage.headers.get("retry-after"), - "the 503 advertises a Retry-After so clients back off", - ).toEqual(expect.any(String)); + "a WorkOS outage does not touch a request: membership comes from the mirror", + ).toBe(200); + yield* Effect.promise(() => duringOutage.text()); }).pipe( - // Always lift the outage, even if an assertion above fails, so the - // recovery request runs against a healthy WorkOS. + // Always lift the outage, even if an assertion above fails. Effect.ensuring(Effect.promise(() => workos.faults.clear())), ); - // WorkOS has recovered. The SAME session id must still serve requests: the - // blip left it untouched. On the pre-fix code this is a 404 (the outage - // request destroyed the DO); with the fix it is a clean 200. + // The SAME session id keeps serving after the outage: nothing condemned it. const afterOutage = yield* Effect.promise(() => mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(4) }), ); - expect( - afterOutage.status, - "the session survived the blip and resumes work once WorkOS recovers", - ).toBe(200); + expect(afterOutage.status, "the session is untouched by the outage").toBe(200); yield* Effect.promise(() => afterOutage.text()); }), ); scenario( - "MCP sessions · a definitive WorkOS denial fails closed and condemns the session", + "MCP sessions · a revoked membership fails closed on the next request and condemns the session", {}, Effect.gen(function* () { const target = yield* Target; const mcp = yield* Mcp; - const identity = yield* target.newIdentity(); - const bearer = yield* mcp.mintBearer(emailOf(identity)); - const workos = yield* Effect.promise(() => - connectEmulator({ baseUrl: `http://127.0.0.1:${WORKOS_EMULATOR_PORT}` }), - ); + // An admin's org with one plain member, joined through the real invite → + // accept flow. The member is the one whose access is revoked. + const admin = yield* target.newIdentity(); + const invitee = yield* target.newIdentity({ org: false }); + const member = yield* joinOrg(target, admin, invitee); + const bearer = yield* mcp.mintBearer(emailOf(member)); + const orgSelector = orgSelectorOf(member); - // A healthy session doing real work before the denial. - const sessionId = yield* Effect.promise(() => openSession(target.mcpUrl, bearer)); - const healthy = yield* Effect.promise(() => - mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(2) }), - ); - expect(healthy.status, "the session serves requests before the denial").toBe(200); + // The member's healthy session doing real work before the revocation. + const mcpUrl = `${target.mcpUrl}`; + const withOrg = (body: unknown, sessionId?: string) => + fetch(mcpUrl, { + method: "POST", + headers: { + accept: JSON_AND_SSE, + "content-type": "application/json", + authorization: `Bearer ${bearer}`, + "x-executor-mcp-organization": orgSelector, + ...(sessionId ? { "mcp-session-id": sessionId } : {}), + }, + body: JSON.stringify(body), + }); + const initialize = yield* Effect.promise(() => withOrg(INITIALIZE_REQUEST)); + const sessionId = initialize.headers.get("mcp-session-id"); + yield* Effect.promise(() => initialize.text()); + expect(initialize.status, "the member opens a session in the org").toBe(200); + if (!sessionId) throw new Error("initialize returned no session id"); + const initialized = yield* Effect.promise(() => withOrg(INITIALIZED_NOTIFICATION, sessionId)); + yield* Effect.promise(() => initialized.text()); + const healthy = yield* Effect.promise(() => withOrg(toolsList(2), sessionId)); + expect(healthy.status, "the session serves requests before the revocation").toBe(200); yield* Effect.promise(() => healthy.text()); - yield* Effect.gen(function* () { - // WorkOS starts ANSWERING the membership lookup with 401 — the - // revoked/invalid API key shape. Deterministic denial, not a blip. - yield* Effect.promise(() => workos.faults.arm(MEMBERSHIP_DENIAL_FAULT)); + // The admin removes the member through the product. The removal writes + // the mirror in the same request (a deletion tombstone keyed to the + // WorkOS membership id), so no reconciler tick is needed for it to land. + const members = yield* Effect.promise(async () => { + const response = await fetch(new URL("/api/account/members", target.baseUrl), { + headers: { ...(admin.headers ?? {}) }, + }); + if (!response.ok) throw new Error(`/api/account/members failed (${response.status})`); + return (await response.json()) as { + readonly members: ReadonlyArray<{ readonly id: string; readonly isCurrentUser: boolean }>; + }; + }); + const removed = members.members.find((row) => !row.isCurrentUser); + if (!removed) throw new Error("the joined member is not listed in the org"); + const removal = yield* Effect.promise(() => + fetch(new URL(`/api/account/members/${removed.id}`, target.baseUrl), { + method: "DELETE", + headers: { ...(admin.headers ?? {}), origin: new URL(target.baseUrl).origin }, + }), + ); + expect(removal.status, "the admin removes the member").toBe(200); + yield* Effect.promise(() => removal.text()); - const denied = yield* Effect.promise(() => - mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(3) }), - ); - const deniedBody = (yield* Effect.promise(() => denied.json())) as JsonRpcError; - expect( - denied.status, - "a definitive WorkOS denial fails closed as Forbidden, never a retryable 503", - ).toBe(403); - expect(deniedBody.error.code, "the denial is a JSON-RPC error envelope").toBe(-32001); - }).pipe(Effect.ensuring(Effect.promise(() => workos.faults.clear()))); + // The removed member's NEXT request on the live session: a positive + // determination from the mirror that they hold no active membership — a + // real Forbidden, which condemns the session. + const denied = yield* Effect.promise(() => withOrg(toolsList(3), sessionId)); + const deniedBody = (yield* Effect.promise(() => denied.json())) as JsonRpcError; + expect(denied.status, "a revoked member fails closed as Forbidden on the next request").toBe( + 403, + ); + expect(deniedBody.error.code, "the denial is a JSON-RPC error envelope").toBe(-32001); - // The Forbidden carried the session id, so the session was condemned: the - // id must NOT serve requests once WorkOS recovers. If this returned 200 the - // fail-closed contract is broken (a revoked customer kept a live session). - const afterDenial = yield* Effect.promise(() => - mcpPost(target.mcpUrl, { bearer, sessionId, body: toolsList(4) }), + // While revoked, every further request is refused at the gate — still a + // Forbidden, never a 200 (a removed member kept a live session) and never + // a retryable 503 (nothing about this is transient). + const stillDenied = yield* Effect.promise(() => withOrg(toolsList(4), sessionId)); + expect(stillDenied.status, "a revoked member stays refused, deterministically").toBe(403); + yield* Effect.promise(() => stillDenied.text()); + + // The Forbidden carried the session id, so the session was condemned. A + // caller the gate admits proves it: the admin, still a member, presents + // the condemned id with their own bearer. Had the id survived, the answer + // would be the ownership Forbidden (-32003: the session belongs to someone + // else); condemned, it is dead and the client is told to reconnect. + const adminBearer = yield* mcp.mintBearer(emailOf(admin)); + const condemned = yield* Effect.promise(() => + fetch(mcpUrl, { + method: "POST", + headers: { + accept: JSON_AND_SSE, + "content-type": "application/json", + authorization: `Bearer ${adminBearer}`, + "x-executor-mcp-organization": orgSelectorOf(admin), + "mcp-session-id": sessionId, + }, + body: JSON.stringify(toolsList(5)), + }), ); - expect( - afterDenial.status, - "the condemned session id is dead after a definitive denial (reconnect required)", - ).toBe(404); - const afterBody = (yield* Effect.promise(() => afterDenial.json())) as JsonRpcError; - expect(afterBody.error.message, "the client is told to reconnect").toMatch( + expect(condemned.status, "the condemned session id is dead (reconnect required)").toBe(404); + const condemnedBody = (yield* Effect.promise(() => condemned.json())) as JsonRpcError; + expect(condemnedBody.error.message, "the client is told to reconnect").toMatch( /timed out|reconnect|not found/i, ); + + // The admin's own access is unaffected by removing someone else. + const adminStillIn = yield* Effect.promise(() => + fetch(new URL("/api/account/me", target.baseUrl), { headers: { cookie: cookieOf(admin) } }), + ); + expect(adminStillIn.status, "the admin keeps their access").toBe(200); + yield* Effect.promise(() => adminStillIn.text()); }), ); diff --git a/e2e/cloud/oauth-background-catalog.test.ts b/e2e/cloud/oauth-background-catalog.test.ts new file mode 100644 index 0000000000..c6c630d943 --- /dev/null +++ b/e2e/cloud/oauth-background-catalog.test.ts @@ -0,0 +1,178 @@ +import { randomBytes } from "node:crypto"; + +import { expect } from "@effect/vitest"; +import { Effect, Option, Schema } from "effect"; +import { HttpServerRequest, HttpServerResponse } from "effect/unstable/http"; +import { composePluginApi } from "@executor-js/api/server"; +import { mcpHttpPlugin } from "@executor-js/plugin-mcp/api"; +import { + AuthTemplateSlug, + ConnectionName, + IntegrationSlug, + OAuthClientSlug, +} from "@executor-js/sdk/shared"; +import { serveTestHttpApp } from "@executor-js/sdk/testing"; + +import { createEmulatorInstance } from "../src/emulator-instance"; +import { scenario } from "../src/scenario"; +import { Api, Browser, Target } from "../src/services"; +import { visit } from "../src/surfaces/browser"; + +const api = composePluginApi([mcpHttpPlugin()] as const); +const decodeRpc = Schema.decodeUnknownOption( + Schema.fromJsonString(Schema.Struct({ method: Schema.String })), +); + +scenario( + "OAuth · slow catalog discovery persists after the cloud callback returns", + { timeout: 180_000 }, + Effect.scoped( + Effect.gen(function* () { + const target = yield* Target; + const browser = yield* Browser; + const { client: makeApiClient } = yield* Api; + const identity = yield* target.newIdentity(); + const client = yield* makeApiClient(api, identity); + const upstream = yield* createEmulatorInstance("mcp", "oauth-background-catalog"); + const firstListing = Promise.withResolvers(); + const releaseCallbackListing = Promise.withResolvers(); + const releaseOtherListings = Promise.withResolvers(); + let listings = 0; + + // Only delay traffic. OAuth, credentials and MCP responses all come from + // the published emulator. Hold later listings separately so a UI/API read + // cannot repair the catalog and hide failure of the callback's own sync. + const proxy = yield* serveTestHttpApp((request) => + Effect.promise(async () => { + const web = await Effect.runPromise(HttpServerRequest.toWeb(request)); + const body = web.method === "GET" || web.method === "HEAD" ? undefined : await web.text(); + const path = new URL(web.url); + const headers = new Headers(web.headers); + headers.delete("host"); + const response = await fetch(`${upstream}${path.pathname}${path.search}`, { + method: web.method, + headers, + body, + redirect: "manual", + }); + const rpc = body === undefined ? Option.none() : decodeRpc(body); + if (response.ok && Option.isSome(rpc) && rpc.value.method === "tools/list") { + listings += 1; + if (listings === 1) { + firstListing.resolve(); + await releaseCallbackListing.promise; + } else { + await releaseOtherListings.promise; + } + } + return HttpServerResponse.fromWeb(response); + }), + ); + yield* Effect.addFinalizer(() => + Effect.sync(() => { + releaseCallbackListing.resolve(); + releaseOtherListings.resolve(); + }), + ); + + const slug = IntegrationSlug.make(`slow_oauth_${randomBytes(4).toString("hex")}`); + yield* client.mcp.addServer({ + payload: { + transport: "remote", + name: "Slow OAuth catalog", + endpoint: `${proxy.baseUrl}/mcp`, + slug, + authenticationTemplate: [{ kind: "oauth2" }], + }, + }); + yield* Effect.addFinalizer(() => + Effect.gen(function* () { + releaseCallbackListing.resolve(); + releaseOtherListings.resolve(); + yield* client.mcp.removeServer({ params: { slug } }).pipe(Effect.ignore); + }), + ); + + const probe = yield* client.oauth.probe({ payload: { url: `${proxy.baseUrl}/mcp` } }); + if (!probe.registrationEndpoint || !probe.authorizationUrl || !probe.tokenUrl) { + return yield* Effect.die("Emulator did not advertise OAuth registration"); + } + const { client: oauthClient } = yield* client.oauth.registerDynamic({ + payload: { + owner: "org", + slug: OAuthClientSlug.make(`${slug}_client`), + registrationEndpoint: probe.registrationEndpoint, + authorizationUrl: probe.authorizationUrl, + tokenUrl: probe.tokenUrl, + resource: probe.resource, + scopes: probe.scopesSupported ?? [], + originIntegration: slug, + }, + }); + yield* Effect.addFinalizer(() => + client.oauth + .removeClient({ + params: { slug: oauthClient }, + payload: { owner: "org" }, + }) + .pipe(Effect.ignore), + ); + const started = yield* client.oauth.start({ + payload: { + owner: "org", + client: oauthClient, + clientOwner: "org", + name: ConnectionName.make("main"), + integration: slug, + template: AuthTemplateSlug.make("oauth2"), + }, + }); + if (started.status !== "redirect") return yield* Effect.die("Expected OAuth authorization"); + yield* Effect.addFinalizer(() => + client.oauth.cancel({ payload: { state: started.state } }).pipe(Effect.ignore), + ); + + yield* browser.session(identity, async ({ page, step }) => { + await step("Authorize the OAuth connection", async () => { + // Stay off the integration screen until discovery is verified: its + // refresh after the callback could otherwise repair a failed sync. + await page.goto(started.authorizationUrl); + const authorization = new URL(page.url()); + const approved = await page.request.post(`${upstream}/authorize/approve`, { + form: { ...Object.fromEntries(authorization.searchParams), login: "admin" }, + maxRedirects: 0, + }); + expect(approved.status()).toBe(302); + const callback = approved.headers()["location"]; + if (!callback) throw new Error("Emulator did not return the OAuth callback"); + await page.goto(callback, { waitUntil: "domcontentloaded" }); + await page.getByRole("heading", { name: "Connected" }).waitFor(); + }); + await step("Keep discovery blocked after the callback returns", async () => { + await expect.poll(() => listings, { timeout: 15_000 }).toBeGreaterThan(0); + await firstListing.promise; + const connections = await Effect.runPromise( + client.connections.list({ query: { integration: slug } }), + ); + expect(connections).toHaveLength(1); + }); + await step("Receive the tools from the completed background discovery", async () => { + releaseCallbackListing.resolve(); + await expect + .poll( + async () => { + const tools = await Effect.runPromise( + client.tools.list({ query: { integration: slug } }), + ); + return tools.length; + }, + { timeout: 30_000, interval: 500 }, + ) + .toBeGreaterThan(0); + await visit(page, `/integrations/${slug}`); + await page.getByRole("button", { name: "Add connection" }).waitFor(); + }); + }); + }), + ), +); 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 new file mode 100644 index 0000000000..df7c9beb37 --- /dev/null +++ b/packages/core/api/src/admin/member-directory.ts @@ -0,0 +1,59 @@ +// --------------------------------------------------------------------------- +// The admin users plane's directory, derived from the shared `MemberDirectory` +// seam — so each host's `AdminUsersProvider` no longer carries its own +// identity join and email resolver. +// --------------------------------------------------------------------------- + +import { Effect } from "effect"; + +import { MemberStatus, type MemberDirectoryShape } from "../server/member-directory"; +import type { AdminUserDirectory, AdminUserIdentity } from "./reads"; + +/** + * 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. + * + * 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. + * + * 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, + organizationId: string, +): AdminUserDirectory => ({ + identities: (externalIds) => + directory.membersById(organizationId, externalIds, MemberStatus.literals).pipe( + Effect.map((members) => { + const identities = new Map(); + for (const [accountId, member] of members) { + identities.set(accountId, { + email: member.email, + displayName: member.name, + }); + } + return identities; + }), + ), + resolveEmail: (email) => + 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 104d841c39..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"; @@ -106,6 +107,17 @@ export { type IdentityProviderShape, type IdentityFailure, } from "./server/identity"; +export { + MemberDirectory, + MemberDirectoryError, + MemberStatus, + DEFAULT_MEMBER_STATUSES, + normalizeMemberSearch, + type DirectoryMember, + type MemberQuery, + type MemberDirectoryShape, +} from "./server/member-directory"; +export { adminUserDirectoryFromMembers } from "./admin/member-directory"; export { makeExecutionStackMiddleware, textFailureStrategy, diff --git a/packages/core/api/src/server/member-directory.ts b/packages/core/api/src/server/member-directory.ts new file mode 100644 index 0000000000..a80e1adf68 --- /dev/null +++ b/packages/core/api/src/server/member-directory.ts @@ -0,0 +1,162 @@ +// --------------------------------------------------------------------------- +// MemberDirectory — the ONE shared READ seam over "who belongs to this org". +// +// Sits beside `IdentityProvider` (./identity.ts) as the second provider-neutral +// auth surface. `IdentityProvider` answers "who is calling"; this answers "who +// is a member, with what role and status" — the question every member list, +// admin users page, seat count, and per-request membership check asks. Cloud +// (WorkOS) implements it over a LOCAL mirror of WorkOS users + memberships +// (fed by login, write-through, and the WorkOS Events API); self-host (Better +// Auth) implements it over its own `member` + `user` tables. Shared code +// consumes only this tag and never learns which host it is on. +// +// Read-only by design. Writes stay host-specific: cloud writes go to WorkOS +// and are mirrored back; self-host writes go through Better Auth's org plugin. +// Invitations are NOT members and are not reported here. +// --------------------------------------------------------------------------- + +import { Context, Effect, Schema } from "effect"; + +/** + * Membership lifecycle as the host stores it. `pending` is a member who has + * not completed joining (cloud: an accepted-but-unactivated WorkOS membership); + * `inactive` is a member who keeps their row but must not be granted access — + * a deactivated member, or (on cloud) the TOMBSTONE of a deleted membership: + * the mirror never drops a membership row, it marks it inactive with the + * deletion time, so a feeder replaying an older payload cannot resurrect it. + * Every read excludes `inactive` unless the caller names it in `statuses`; + * anything that grants access must additionally require `active`. + */ +export const MemberStatus = Schema.Literals(["active", "pending", "inactive"]); +export type MemberStatus = typeof MemberStatus.Type; + +/** + * One member of one organization, as the directory reports it. + * + * `accountId` is the host principal id — the SAME id space `IdentityProvider` + * binds as `Principal.accountId` and the subject table records in + * `external_id` (cloud: the WorkOS `user_…`; self-host: the Better Auth + * `user.id`). `membershipId` is the host's membership ROW id (`om_…` on cloud, + * `member.id` on self-host) and joins to nothing outside the host; it is + * carried for host-specific writes (remove, change role), never as a join key. + */ +export interface DirectoryMember { + readonly accountId: string; + readonly membershipId: string; + readonly organizationId: string; + readonly email: string | null; + readonly name: string | null; + readonly avatarUrl: string | null; + /** The host's role slug as stored (`"admin"` | `"member"` | `"owner"` …), not normalized. */ + readonly role: string; + readonly status: MemberStatus; + /** Epoch ms of the member's last sign-in, when the host records it. */ + readonly lastActiveAt: number | null; +} + +/** + * Filter + paging for {@link MemberDirectoryShape.members}. + * + * `search` is a case-insensitive substring match over email and name; the + * adapter trims + lower-cases it (the same rule `normalizeEmail` applies to + * emails) and an empty term is no filter. `statuses` defaults to active + + * pending. Results are ordered by email then `accountId` so paging is stable. + */ +export interface MemberQuery { + readonly search?: string; + readonly limit?: number; + readonly offset?: number; + readonly statuses?: readonly MemberStatus[]; +} + +export interface MemberDirectoryShape { + /** + * One account's membership in one org, or `null` when it holds none among + * `statuses` (default: active + pending, so a tombstoned membership reads + * as no membership). + */ + readonly membership: ( + accountId: string, + 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, + query?: MemberQuery, + ) => Effect.Effect; + /** + * The org's members among `accountIds` with a status in `statuses` + * (default: active + pending), keyed by `accountId`. Ids the org holds no + * such membership for are simply absent. One read for the whole batch — + * never a lookup per id. + */ + readonly membersById: ( + organizationId: string, + accountIds: readonly string[], + statuses?: readonly MemberStatus[], + ) => Effect.Effect, MemberDirectoryError>; + /** + * The org's member with this email among `statuses` (default: active + + * pending). `email` arrives ALREADY normalized (trimmed + lower-cased) and + * is compared against the normalized directory value, so casing never + * decides the answer on either host. + */ + readonly findByEmail: ( + organizationId: string, + email: string, + statuses?: readonly MemberStatus[], + ) => Effect.Effect; +} + +export class MemberDirectory extends Context.Service()( + "@executor-js/api/MemberDirectory", +) {} + +/** + * The directory could not be read (storage fault, undecodable row). Flat + * message only: the cause is logged by the adapter and deliberately not echoed + * to a caller. + */ +export class MemberDirectoryError extends Schema.TaggedErrorClass()( + "MemberDirectoryError", + { message: Schema.String }, +) {} + +/** + * The search-term normalization every adapter applies: trim + lower-case, the + * same rule `normalizeEmail` applies to emails. `undefined` means no filter, + * including for a blank term. + */ +export const normalizeMemberSearch = (search: string | undefined): string | undefined => { + if (search === undefined) return undefined; + const term = search.trim().toLowerCase(); + return term.length === 0 ? undefined : term; +}; + +/** + * The statuses every read reports when the caller names none: the members + * who hold or are joining the org. `inactive` — deactivated or tombstoned — + * is never reported by default. + */ +export const DEFAULT_MEMBER_STATUSES: readonly MemberStatus[] = ["active", "pending"]; diff --git a/packages/core/api/src/server/request-scoped.test.ts b/packages/core/api/src/server/request-scoped.test.ts new file mode 100644 index 0000000000..98c18226d0 --- /dev/null +++ b/packages/core/api/src/server/request-scoped.test.ts @@ -0,0 +1,144 @@ +import { describe, expect, it, onTestFinished } from "@effect/vitest"; +import { Context, Effect, Layer } from "effect"; +import { HttpRouter, HttpServer, HttpServerResponse } from "effect/unstable/http"; + +import { RequestBackgroundTasks, requestScopedMiddleware } from "./request-scoped"; + +class Resource extends Context.Service()( + "test/RequestResource", +) {} + +const fixture = ( + options: { + background?: boolean; + failTask?: boolean; + failRequest?: boolean; + blockRequest?: boolean; + } = {}, +) => { + const resources: Resource["Service"][] = []; + const gates: ReturnType>[] = []; + const keptAlive: Promise[] = []; + const writes: number[] = []; + const entered = Promise.withResolvers(); + const resource = Layer.effect(Resource)( + Effect.acquireRelease( + Effect.sync(() => { + const acquired = { id: resources.length, closed: false }; + resources.push(acquired); + gates.push(Promise.withResolvers()); + return acquired; + }), + (acquired) => + Effect.sync(() => { + acquired.closed = true; + }), + ), + ); + const routes = HttpRouter.add( + "GET", + "/", + Effect.gen(function* () { + const acquired = yield* Resource; + if (options.background !== false) { + const tasks = yield* RequestBackgroundTasks; + const gate = gates[acquired.id]; + if (!gate) return yield* Effect.die("Missing request gate"); + const task = Effect.runPromise( + Effect.gen(function* () { + yield* Effect.promise(() => gate.promise); + if (options.failTask) return yield* Effect.fail("Background failure"); + if (acquired.closed) + return yield* Effect.fail("Resource closed before background write"); + writes.push(acquired.id); + }), + ); + keptAlive.push(tasks.retain(task)); + } + entered.resolve(); + if (options.blockRequest) return yield* Effect.never; + if (options.failRequest) return yield* Effect.die("Request failure"); + return HttpServerResponse.empty(); + }), + ); + const app = HttpRouter.toWebHandler( + routes.pipe( + Layer.provide(requestScopedMiddleware(resource).layer), + Layer.provideMerge(HttpServer.layerServices), + ), + { disableLogger: true }, + ); + onTestFinished(async () => { + for (const gate of gates) gate.resolve(); + await Promise.all(keptAlive); + await app.dispose(); + }); + return { + resources, + gates, + keptAlive, + writes, + entered, + request: (signal?: AbortSignal) => + app.handler(new Request("http://test.local/", { signal }), Context.empty()), + }; +}; + +describe("request background resource ownership", () => { + it("returns the response before background work, and releases after its write", async () => { + const test = fixture(); + expect((await test.request()).status).toBe(204); + expect(test.resources.map((item) => item.closed)).toEqual([false]); + expect(test.writes).toEqual([]); + test.gates[0]?.resolve(); + await Promise.all(test.keptAlive); + expect(test.writes).toEqual([0]); + expect(test.resources.map((item) => item.closed)).toEqual([true]); + }); + + it("keeps concurrent requests isolated and releases each independently", async () => { + const test = fixture(); + const responses = await Promise.all([test.request(), test.request()]); + expect(responses.map((response) => response.status)).toEqual([204, 204]); + test.gates[0]?.resolve(); + await test.keptAlive[0]; + expect(test.resources.map((item) => item.closed)).toEqual([true, false]); + test.gates[1]?.resolve(); + await test.keptAlive[1]; + expect(test.writes).toEqual([0, 1]); + expect(test.resources.map((item) => item.closed)).toEqual([true, true]); + }); + + for (const failure of ["task", "request"] as const) { + it(`releases resources after a ${failure} failure`, async () => { + const test = fixture({ failTask: failure === "task", failRequest: failure === "request" }); + expect((await test.request()).status).toBe(failure === "request" ? 500 : 204); + expect(test.resources.map((item) => item.closed)).toEqual([false]); + test.gates[0]?.resolve(); + await Promise.all(test.keptAlive); + expect(test.writes).toEqual(failure === "task" ? [] : [0]); + expect(test.resources.map((item) => item.closed)).toEqual([true]); + }); + } + + it("closes before responding when no background work was registered", async () => { + const test = fixture({ background: false }); + expect((await test.request()).status).toBe(204); + expect(test.resources.map((item) => item.closed)).toEqual([true]); + expect(test.keptAlive).toEqual([]); + }); + + it("finishes retained work and cleanup after the client cancels the request", async () => { + const test = fixture({ blockRequest: true }); + const controller = new AbortController(); + const response = test.request(controller.signal); + await test.entered.promise; + controller.abort(); + expect((await response).status).toBe(499); + expect(test.resources.map((item) => item.closed)).toEqual([false]); + test.gates[0]?.resolve(); + await Promise.all(test.keptAlive); + expect(test.writes).toEqual([0]); + expect(test.resources.map((item) => item.closed)).toEqual([true]); + }); +}); diff --git a/packages/core/api/src/server/request-scoped.ts b/packages/core/api/src/server/request-scoped.ts index 1436d851bb..6f0aa05da8 100644 --- a/packages/core/api/src/server/request-scoped.ts +++ b/packages/core/api/src/server/request-scoped.ts @@ -13,8 +13,9 @@ // both build the inner layer at construction time. The only primitive // that actually rebuilds per request is a router middleware whose // per-request handler builds the layer with a *fresh* `MemoMap` and a -// per-request scope, so `acquireRelease` fires per request and finalizers -// run when the request fiber's scope closes. +// per-request scope, so `acquireRelease` fires per request. Background work +// retains that scope until its database writes finish; the response need not +// wait, but the platform keep-alive must include resource cleanup too. // // The fresh `MemoMap` matters: `Layer.build` would otherwise inherit // `CurrentMemoMap` from the boot context (`HttpRouter.toWebHandler` @@ -29,14 +30,24 @@ // coverage that pins this rule down (sequential AND concurrent cases). // --------------------------------------------------------------------------- -import { Effect, Layer } from "effect"; +import { Context, Effect, Layer, Scope } from "effect"; import { HttpRouter } from "effect/unstable/http"; +/** + * Retain this request's resources for already-started background work. The + * caller owns task error reporting; the returned promise settles only after + * all retained tasks AND resource finalizers finish, for the host's waitUntil. + */ +export class RequestBackgroundTasks extends Context.Service< + RequestBackgroundTasks, + { readonly retain: (task: Promise) => Promise } +>()("@executor-js/api/RequestBackgroundTasks") {} + /** * Build an `HttpRouter.middleware` that provides `layer`'s services to * each request. The layer is rebuilt per HTTP request so - * `Effect.acquireRelease` fires per request and is released when the - * request fiber's scope closes. + * `Effect.acquireRelease` fires per request. Resources close after the handler + * and its registered background work settle, without delaying the response. * * The returned value is a `Middleware`. Use `.layer` to apply it as a * standalone layer; use `.combine(other)` to fold it into another @@ -46,15 +57,55 @@ import { HttpRouter } from "effect/unstable/http"; * outer middleware's `requires`). */ export const requestScopedMiddleware = (layer: Layer.Layer) => - HttpRouter.middleware<{ provides: A }>()((httpEffect) => - Effect.scoped( + HttpRouter.middleware<{ provides: A | RequestBackgroundTasks }>()((httpEffect) => + Effect.uninterruptibleMask((restore) => Effect.gen(function* () { - // Fresh MemoMap per request — see file-level note for why we - // must NOT inherit `CurrentMemoMap` from the boot context. - const memoMap = yield* Layer.makeMemoMap; - const scope = yield* Effect.scope; - const services = yield* Layer.buildWithMemoMap(layer, memoMap, scope); - return yield* Effect.provideContext(httpEffect, services); + const scope = yield* Scope.make(); + const pending = new Set>(); + const released = Promise.withResolvers(); + const background = RequestBackgroundTasks.of({ + retain: (task) => { + // SDK tasks report their own failures. Both outcomes release the + // resource lease; a rejected task must not leak its database. + const settled = task.then( + () => { + pending.delete(settled); + }, + () => { + pending.delete(settled); + }, + ); + pending.add(settled); + return released.promise; + }, + }); + return yield* restore( + Effect.gen(function* () { + // Never inherit the boot MemoMap: concurrent requests each own + // their socket, including after either response has been sent. + const memoMap = yield* Layer.makeMemoMap; + const services = yield* Layer.buildWithMemoMap(layer, memoMap, scope); + return yield* Effect.provideContext(httpEffect, services); + }).pipe( + Effect.provideService(Scope.Scope, scope), + Effect.provideService(RequestBackgroundTasks, background), + ), + ).pipe( + Effect.onExit((exit) => { + const release = Effect.gen(function* () { + // A retained task may start another task before it settles. + while (pending.size > 0) { + yield* Effect.promise(() => Promise.all(pending)); + } + }).pipe( + Effect.ensuring(Scope.close(scope, exit)), + Effect.ensuring(Effect.sync(() => released.resolve())), + ); + // With no background work, preserve synchronous teardown. Otherwise + // the resource owner, including cleanup, is kept alive by the host. + return pending.size === 0 ? release : release.pipe(Effect.forkDetach, Effect.asVoid); + }), + ); }), ), ); diff --git a/packages/core/api/src/server/scoped-executor.ts b/packages/core/api/src/server/scoped-executor.ts index 5e10bbbfd1..749839be3e 100644 --- a/packages/core/api/src/server/scoped-executor.ts +++ b/packages/core/api/src/server/scoped-executor.ts @@ -51,6 +51,7 @@ import { } from "@executor-js/sdk/host-internal"; import { DbProvider } from "./executor-fuma-db"; +import { RequestBackgroundTasks } from "./request-scoped"; // --------------------------------------------------------------------------- // HostConfig seam — the two host scalars that vary the `createExecutor` options. @@ -126,12 +127,14 @@ export interface HostConfigShape { */ readonly toolsSyncTtlMs?: number | null; /** - * Forwarded verbatim to `ExecutorConfig.waitUntil`: the host's keep-alive + * Forwarded to `ExecutorConfig.waitUntil`: the host's keep-alive * for background work that outlives a request (stale tool-catalog rebuilds * that keep running after a read stops waiting). Cloud supplies the * platform `waitUntil` from `cloudflare:workers`, which binds to the * in-flight invocation ambiently; long-lived hosts (self-host, local, * tests) omit it and detached fibers simply run to completion in-process. + * Under requestScopedMiddleware, the promise also covers releasing that + * request's database after background work finishes. */ readonly waitUntil?: (promise: Promise) => void; } @@ -267,6 +270,14 @@ export const makeScopedExecutor = < const { db, blobs } = yield* DbProvider.asEffect(); const { plugins: pluginsFactory } = yield* PluginsProvider.asEffect(); const config = yield* HostConfig.asEffect(); + const background = yield* Effect.serviceOption(RequestBackgroundTasks); + const waitUntil = Option.match(background, { + onNone: () => config.waitUntil, + onSome: (tasks) => (task: Promise) => { + const released = tasks.retain(task); + config.waitUntil?.(released); + }, + }); // Explicit config wins; otherwise fall back to the request origin if a host // provided one (HTTP middleware / MCP session DO). Stays `undefined` for // non-request callers — `coreTools.webBaseUrl` is optional and only the @@ -323,7 +334,7 @@ export const makeScopedExecutor = < fetch: hostedFetch, onIntegrationChange: config.onIntegrationChange, ...(config.toolsSyncTtlMs !== undefined ? { toolsSyncTtlMs: config.toolsSyncTtlMs } : {}), - ...(config.waitUntil !== undefined ? { waitUntil: config.waitUntil } : {}), + ...(waitUntil !== undefined ? { waitUntil } : {}), onElicitation: "accept-all", ...(options?.orgWrites === undefined ? {} : { orgWrites: options.orgWrites }), redirectUri, 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), }) } >