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 000000000..21a08b714 --- /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/meta/0018_snapshot.json b/apps/cloud/drizzle/meta/0018_snapshot.json new file mode 100644 index 000000000..5f29623ce --- /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/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 375397cec..421c19a0a 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -127,6 +127,13 @@ "when": 1788287088210, "tag": "0017_lush_thunderbolts", "breakpoints": true + }, + { + "idx": 18, + "version": "7", + "when": 1789570778982, + "tag": "0018_member_directory_mirror", + "breakpoints": true } ] } 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 5b96f01f7..736c1adaf 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 @@ -37,6 +37,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) => ({ @@ -82,8 +95,8 @@ 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, diff --git a/apps/cloud/src/api/layers.ts b/apps/cloud/src/api/layers.ts index 6e7326126..039d983e5 100644 --- a/apps/cloud/src/api/layers.ts +++ b/apps/cloud/src/api/layers.ts @@ -6,6 +6,7 @@ import { makeProtectedApiLayer, requestScopedMiddleware } from "@executor-js/api import { SessionAuthLive } from "../auth/middleware-live"; import { UserStoreService } from "../auth/context"; +import { WorkOsMirror } from "../auth/workos-mirror"; import { CloudAuthPublicHandlers, CloudSessionAuthHandlers, @@ -25,12 +26,13 @@ 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)); // 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.mergeAll(DbLive, UserStoreLive, WorkOsMirrorLive); // Boot-scoped layer. Built once at worker boot, reused across requests. // Safe for config, in-memory caches, the global tracer provider, and 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 d92ffe723..e87512d28 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 @@ -8,6 +8,19 @@ 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( @@ -50,8 +63,8 @@ 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}`, 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 b330ecdd7..b37481037 100644 --- a/apps/cloud/src/api/protected-jwt-auth.node.test.ts +++ b/apps/cloud/src/api/protected-jwt-auth.node.test.ts @@ -9,6 +9,19 @@ import { WorkOSClient, type WorkOSClientService } from "../auth/workos"; 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"; @@ -67,8 +80,8 @@ 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}`, diff --git a/apps/cloud/src/auth/member-directory.ts b/apps/cloud/src/auth/member-directory.ts new file mode 100644 index 000000000..01174d88f --- /dev/null +++ b/apps/cloud/src/auth/member-directory.ts @@ -0,0 +1,181 @@ +// --------------------------------------------------------------------------- +// 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); + }), + + 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))); 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 ec87511c3..624641644 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 @@ -13,6 +13,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") { @@ -65,8 +78,8 @@ 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}`, 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 ead56fb89..aa9f11c02 100644 --- a/apps/cloud/src/auth/org-selector-auth.node.test.ts +++ b/apps/cloud/src/auth/org-selector-auth.node.test.ts @@ -16,6 +16,19 @@ import { WorkOSClient, type WorkOSClientService } from "./workos"; 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. const MEMBER = "user_session"; const SESSION_ORG = "org_session"; @@ -67,8 +80,8 @@ 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, 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 d4f897073..83fd94525 100644 --- a/apps/cloud/src/auth/workos-callback-state.node.test.ts +++ b/apps/cloud/src/auth/workos-callback-state.node.test.ts @@ -57,12 +57,25 @@ const stubWorkOS = Layer.succeed( }), ); +// 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, 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 000000000..61e735739 --- /dev/null +++ b/apps/cloud/src/auth/workos-mirror.node.test.ts @@ -0,0 +1,701 @@ +// --------------------------------------------------------------------------- +// 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, stamped with the deletion time): +// a stale OR equal-timestamp upsert cannot resurrect it, a newer one +// reactivates it, and every default read treats the tombstone as no +// membership +// - 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 +// - 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) +// - `members` searches email AND name case-insensitively, pages stably +// - `findByEmail` ignores the casing WorkOS stored +// - a membership arriving before its user still holds (FK via ensureAccount) +// --------------------------------------------------------------------------- + +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { MemberDirectory } from "@executor-js/api/server"; + +import { DbService } from "../db/db"; +import { cloudMemberDirectoryLayer } from "./member-directory"; +import { UserStoreService } from "./context"; +import { WorkOsMirror, type WorkOsMirrorMembership, type WorkOsMirrorUser } from "./workos-mirror"; + +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)); + +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" })); + 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 so a stale upsert cannot resurrect it, and a newer one can", 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"]); + // 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, + 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.readded, "an upsert newer than the deletion 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("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 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 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, + 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.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(); + }); +}); + +describe("WorkOsMirror cursor", () => { + it("advances only by compare-and-set", async () => { + const result = await run( + Effect.gen(function* () { + const mirror = yield* WorkOsMirror; + // 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.setCursor(before, "event_1"); + const wrongPrev = yield* mirror.setCursor(before === null ? "event_0" : null, "event_x"); + const afterWrong = yield* mirror.getCursor(); + const right = yield* mirror.setCursor("event_1", "event_2"); + const after = yield* mirror.getCursor(); + return { first, wrongPrev, afterWrong, right, after }; + }), + ); + expect(result.first).toBe(true); + expect(result.wrongPrev, "a run holding a stale prev cannot move the cursor").toBe(false); + expect(result.afterWrong).toBe("event_1"); + expect(result.right).toBe(true); + expect(result.after).toBe("event_2"); + }); +}); + +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("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, []); + return { + ids, + found, + inactive, + inactiveAsked, + wrongOrg, + batch, + batchAll, + empty, + }; + }), + ); + 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); + }); +}); diff --git a/apps/cloud/src/auth/workos-mirror.ts b/apps/cloud/src/auth/workos-mirror.ts new file mode 100644 index 000000000..f3869e430 --- /dev/null +++ b/apps/cloud/src/auth/workos-mirror.ts @@ -0,0 +1,519 @@ +// --------------------------------------------------------------------------- +// 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), +// and the WorkOS Events API reconciler (dashboard-side changes, replayed in +// order from a persisted cursor). +// +// 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'`, `workos_updated_at` = the deletion time) — 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) is refused by that same guard instead of reinstating access; +// only a payload newer than the deletion (the member re-added in WorkOS) +// reactivates it. At an EQUAL timestamp the tombstone wins: a live row +// accepts a payload stamped the same instant (feeders replay the same +// payload and must converge), a tombstone does not, so a deletion at T is +// never undone by an active payload at T. Every stamp is on WorkOS's clock: +// a deletion replayed from the events stream is stamped with the event's +// time; one Executor made itself — WorkOS answers a delete with no time — +// is stamped with the `updatedAt` WorkOS last reported for the membership, +// or keeps the stamp the row already holds. Never a local clock: read after +// WorkOS answered, it can post-date a replacement membership WorkOS created +// for the same member meanwhile, and a tombstone stamped with it would +// refuse that replacement for good. The row tombstone can only speak for the +// membership id the row happens to hold, so every delete ALSO records the +// deleted WorkOS id in `membership_tombstones`, a ledger keyed by that id +// alone: WorkOS never reuses a deleted `om_…` id, so no membership write +// naming a recorded id is ever current, however it is stamped. That is what +// 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, and a +// later scan that still lists B would insert it live, stamped after A. With +// the ledger the delete is recorded whatever the row holds, and the scan's +// payload is refused by identity. A deleted USER is protected by identity +// the same way: `deleteUser` leaves the account row behind as a tombstone +// (profile cleared, stamped with the deletion), and no membership naming +// that account is ever 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 timestamp guard to refuse the insert against. +// The cursor advances only by compare-and-set, so two reconciler runs cannot +// both own the stream. +// +// 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 { and, eq, isNotNull, isNull, lt, ne, or, sql } from "drizzle-orm"; +import type { AnyPgColumn } from "drizzle-orm/pg-core"; +import { Context, Effect, Layer, Schema } from "effect"; + +import { type MemberStatus } from "@executor-js/api/server"; + +import { accounts, membershipTombstones, memberships, workosSync } from "../db/schema"; +import { DbService, type DrizzleDb } from "../db/db"; +import { + USER_STORE_FAILURE_REASONS, + 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" +>; + +/** + * The public failure of every mirror write: which call, and how it failed + * (classified from the driver cause the same way `UserStoreError` is). + */ +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}`; + } +} + +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 tombstone stamped at `updatedAt` or later — or the membership id + * is recorded in the deletion ledger (`membership_tombstones`, written by + * `deleteMembership`), whatever the payload is stamped: WorkOS never + * reuses a deleted `om_…` id, so no payload naming a recorded id is + * current — or the account is a deletion tombstone (`deleteUser`), + * whatever the payload is stamped: WorkOS never reuses a user id either, + * so a deleted user has no memberships to mirror. + */ + 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 and stamped so that only a payload newer than the + * stamp can reactivate the row. The stamp is on WorkOS's clock, so it + * orders against every payload the mirror is fed: `deletedAt` is an + * instant at or after the membership's last reported state — a deletion + * event's `createdAt`, or the `updatedAt` WorkOS last reported for the + * membership when Executor deletes it (WorkOS answers a delete with no + * time) — or `null` when the caller holds no WorkOS instant at all: the + * row then keeps the stamp it holds, the last state WorkOS reported for + * this membership. Never a local clock: read after WorkOS answered, it + * can post-date a replacement membership WorkOS created for the same + * member meanwhile, and a tombstone stamped with it would refuse that + * replacement for good. Every payload of the deleted membership a feeder + * could have fetched is at or before the stamp (a tombstone wins a tie), + * and every replacement is after it. A row the mirror does not hold and + * no instant to stamp it with takes the current time: there is no WorkOS + * stamp to keep. Matches the row by the membership's IDENTITY, never by + * timestamp: `false` when the row already carries this tombstone or a + * later one (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 was stamped. In + * EVERY case the deleted id is recorded in the deletion ledger + * (`membership_tombstones`) — a row holding another id is left alone, but + * the id this delete names is still refused to every later write, 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 delete changed + * anything: 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`, 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). + */ + 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; + /** + * Compare-and-set the cursor: advance to `next` only if it still reads + * `prev` (`null` = no cursor yet). `false` means another run moved it first + * — the caller must stop, it no longer owns the stream. + */ + readonly setCursor: ( + prev: string | null, + next: string, + ) => Effect.Effect; +} + +// The one 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. +const EVENTS_CURSOR_ID = "events"; + +// A tombstone keeps the row, marks it `inactive`, and moves its 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. +// With no instant at all (`deletedAt` null, see `deleteMembership`) the row +// keeps the stamp it holds, and a row that has none takes the current time +// — the only instant known. (A raw `sql` +// fragment binds the Date without the column's driver mapping, so it is +// passed as ISO text and cast.) +const noEarlierThan = (column: AnyPgColumn, at: Date | null) => + at === null + ? sql`coalesce(${column}, now())` + : sql`greatest(${column}, ${at.toISOString()}::timestamptz)`; + +const tombstone = (deletedAt: Date | null) => ({ + status: "inactive" as const, + workosUpdatedAt: noEarlierThan(memberships.workosUpdatedAt, deletedAt), +}); + +// Which stored membership rows a payload stamped `updatedAt` may overwrite: +// 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 deletion (or deactivation) at T beats an active payload +// at T, so an equal-timestamp payload can never undo a tombstone. +const membershipAcceptsPayload = (updatedAt: Date) => + or( + isNull(memberships.workosUpdatedAt), + lt(memberships.workosUpdatedAt, updatedAt), + and(eq(memberships.workosUpdatedAt, updatedAt), ne(memberships.status, "inactive")), + ); + +// 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. +const accountIsTombstone = and(isNull(accounts.email), isNotNull(accounts.workosUpdatedAt)); + +// Whether membership `id` is in the deletion ledger: a WorkOS id a delete +// has named, which never returns. Judged by 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; +}; + +// 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 unless the row already carries this tombstone or a +// later one: a replayed deletion changes nothing and reports so. With no +// instant given, any tombstone the row carries is this one or later. +const notTombstonedSince = (deletedAt: Date | null) => + or( + ne(memberships.status, "inactive"), + isNull(memberships.workosUpdatedAt), + deletedAt === null ? undefined : lt(memberships.workosUpdatedAt, deletedAt), + ); + +// Which (account, organization) row a delete of membership `id` at +// `deletedAt` 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. Its stamp cannot be trusted to +// order them: a removal Executor makes is stamped by a local clock, and one +// that reads the clock after WorkOS answered can post-date a replacement +// WorkOS created while the request was in flight. Identity orders them, +// timestamps do not. A row already carrying this tombstone or a later one is +// left alone (`notTombstonedSince`) so a replayed delete reports `false`. +const membershipDeletableBy = (id: string, deletedAt: Date | null) => + and( + or(isNull(memberships.membershipId), eq(memberships.membershipId, id)), + notTombstonedSince(deletedAt), + ); + +const makeService = (db: DrizzleDb): WorkOsMirrorShape => { + const run = (op: string, fn: () => Promise) => + withServiceLogging( + `workos_mirror.${op}`, + (failure) => + new WorkOsMirrorError({ + operation: op, + reason: userStoreReasonFromCause(failure), + }), + tryPromiseService(fn), + ); + + // Over `db` or a transaction handle (drizzle's is a `PgDatabase` too). + const ensureAccount = (on: DrizzleDb, id: string) => + on.insert(accounts).values({ id }).onConflictDoNothing({ target: accounts.id }); + + return { + upsertUser: (user) => + run("upsertUser", async () => { + 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: (membership) => + run("upsertMembership", async () => { + await ensureAccount(db, 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. + const deleted = await db + .select({ id: accounts.id }) + .from(accounts) + .where(and(eq(accounts.id, membership.accountId), accountIsTombstone)); + if (deleted.length > 0) 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, + }, + setWhere: membershipAcceptsPayload(membership.updatedAt), + }) + .returning({ accountId: memberships.accountId }); + return written.length > 0; + }), + + // 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: (membership, deletedAt) => + run("deleteMembership", () => + db.transaction(async (tx) => { + await ensureAccount(tx, membership.accountId); + // The ledger entry FIRST, whatever the row holds: this is the one + // record of the deletion that does not depend on the row carrying + // the deleted id. A replayed delete finds it there (`DO NOTHING`) + // and records nothing new. + const recorded = await tx + .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 tx + .insert(memberships) + .values({ + accountId: membership.accountId, + organizationId: membership.organizationId, + membershipId: membership.id, + status: "inactive", + workosUpdatedAt: deletedAt ?? new Date(), + }) + .onConflictDoUpdate({ + target: [memberships.accountId, memberships.organizationId], + set: { membershipId: membership.id, ...tombstone(deletedAt) }, + setWhere: membershipDeletableBy(membership.id, deletedAt), + }) + .returning({ accountId: memberships.accountId }); + return recorded.length > 0 || tombstoned.length > 0; + }), + ), + + deleteUser: (accountId, deletedAt) => + run("deleteUser", async () => { + // The account tombstone FIRST, then the memberships. A membership + // write checks the account tombstone before it inserts + // (`upsertMembership`), so a write racing this delete either sees + // the tombstone and refuses, or has landed before it and is caught + // by the membership tombstoning below. In the other order a write + // between the two statements would slip through live. + // + // The account tombstone: profile cleared, stamped with the deletion. + // Minted when absent, for the same reason as the membership one. + const cleared = await db + .insert(accounts) + .values({ id: accountId, workosUpdatedAt: deletedAt }) + .onConflictDoUpdate({ + target: accounts.id, + set: { + email: null, + firstName: null, + lastName: null, + avatarUrl: null, + workosUpdatedAt: noEarlierThan(accounts.workosUpdatedAt, deletedAt), + }, + // Applied unless the row already carries this tombstone or a + // later one (a replayed delete). + setWhere: 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), notTombstonedSince(deletedAt))); + return cleared.length > 0; + }), + + getCursor: () => + run("getCursor", async () => { + const rows = await db + .select({ cursor: workosSync.cursor }) + .from(workosSync) + .where(eq(workosSync.id, EVENTS_CURSOR_ID)); + // No row yet is the same state as a row with no cursor: nothing applied. + return rows[0]?.cursor ?? null; + }), + + setCursor: (prev, next) => + run("setCursor", async () => { + 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: EVENTS_CURSOR_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, EVENTS_CURSOR_ID), eq(workosSync.cursor, prev))) + .returning({ id: workosSync.id }); + return written.length > 0; + }), + }; +}; + +export class WorkOsMirror extends Context.Service()( + "@executor-js/cloud/WorkOsMirror", +) { + static Live = Layer.effect(this)(Effect.map(DbService.asEffect(), ({ db }) => makeService(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 }) => makeService(db))); diff --git a/apps/cloud/src/db/org-deletion.test.ts b/apps/cloud/src/db/org-deletion.test.ts index 86faa45bb..979f0b24b 100644 --- a/apps/cloud/src/db/org-deletion.test.ts +++ b/apps/cloud/src/db/org-deletion.test.ts @@ -200,7 +200,9 @@ const NOT_ORG_OWNED: Record = { // cascades from its FK; `accounts` deliberately outlives the org. organizations: "the identity row itself, deleted directly", memberships: "cascades from the organizations FK", + membership_tombstones: "cascades from the organizations FK", 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 => { diff --git a/apps/cloud/src/db/schema.ts b/apps/cloud/src/db/schema.ts index fb1bd7998..ef18c4824 100644 --- a/apps/cloud/src/db/schema.ts +++ b/apps/cloud/src/db/schema.ts @@ -2,22 +2,54 @@ // 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 // -// 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 @@ -41,9 +73,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 +103,67 @@ 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 cursor. 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. + */ +export const workosSync = pgTable("workos_sync", { + id: text("id").primaryKey(), + cursor: text("cursor"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}); diff --git a/apps/cloud/src/extensions/billing/route.node.test.ts b/apps/cloud/src/extensions/billing/route.node.test.ts index dc2a5a731..b25ec5312 100644 --- a/apps/cloud/src/extensions/billing/route.node.test.ts +++ b/apps/cloud/src/extensions/billing/route.node.test.ts @@ -7,6 +7,19 @@ 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"; @@ -37,8 +50,8 @@ 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, 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 000000000..1671adb8d --- /dev/null +++ b/apps/host-selfhost/src/auth/member-directory.test.ts @@ -0,0 +1,134 @@ +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; + return { + one: yield* d.membership(grace, organizationId), + oneInactive: yield* d.membership(grace, organizationId, ["inactive"]), + none: yield* d.membership(outsider.user.id, organizationId), + 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.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 000000000..4c8484117 --- /dev/null +++ b/apps/host-selfhost/src/auth/member-directory.ts @@ -0,0 +1,223 @@ +// --------------------------------------------------------------------------- +// 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)), + + 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/packages/core/api/src/admin/member-directory.ts b/packages/core/api/src/admin/member-directory.ts new file mode 100644 index 000000000..e90ad8934 --- /dev/null +++ b/packages/core/api/src/admin/member-directory.ts @@ -0,0 +1,50 @@ +// --------------------------------------------------------------------------- +// 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"; + +/** + * Both directions 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. + * + * Both read ANY membership status, not the directory's active + pending + * default: this plane reports footprint, not current access. A member who was + * removed while their connections remain must still be named on the users + * page and findable by the address an operator has for them. + * + * Both fail with `MemberDirectoryError`, which the shared reads treat as a + * decorative-join outage (identities) or surface as a failed read (resolve). + */ +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))), +}); diff --git a/packages/core/api/src/server.ts b/packages/core/api/src/server.ts index 104d841c3..b5974de98 100644 --- a/packages/core/api/src/server.ts +++ b/packages/core/api/src/server.ts @@ -106,6 +106,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 000000000..50bef4a34 --- /dev/null +++ b/packages/core/api/src/server/member-directory.ts @@ -0,0 +1,142 @@ +// --------------------------------------------------------------------------- +// 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; + /** 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"];