diff --git a/apps/cloud/drizzle/0006_polite_power_man.sql b/apps/cloud/drizzle/0006_polite_power_man.sql new file mode 100644 index 000000000..8fa661c3a --- /dev/null +++ b/apps/cloud/drizzle/0006_polite_power_man.sql @@ -0,0 +1,25 @@ +CREATE TABLE "identity_sync_events" ( + "provider" text NOT NULL, + "event_id" text NOT NULL, + "event_type" text NOT NULL, + "processed_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "identity_sync_events_provider_event_id_pk" PRIMARY KEY("provider","event_id") +); +--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN "email" text;--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN "name" text;--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN "avatar_url" text;--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN "external_id" text;--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN "identity_provider" text DEFAULT 'workos' NOT NULL;--> statement-breakpoint +ALTER TABLE "accounts" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint +ALTER TABLE "memberships" ADD COLUMN "external_id" text;--> statement-breakpoint +ALTER TABLE "memberships" ADD COLUMN "identity_provider" text DEFAULT 'workos' NOT NULL;--> statement-breakpoint +ALTER TABLE "memberships" ADD COLUMN "status" text DEFAULT 'active' NOT NULL;--> statement-breakpoint +ALTER TABLE "memberships" ADD COLUMN "role_slug" text DEFAULT 'member' NOT NULL;--> statement-breakpoint +ALTER TABLE "memberships" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint +ALTER TABLE "memberships" ADD COLUMN "synced_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint +ALTER TABLE "organizations" ADD COLUMN "external_id" text;--> statement-breakpoint +ALTER TABLE "organizations" ADD COLUMN "identity_provider" text DEFAULT 'workos' NOT NULL;--> statement-breakpoint +ALTER TABLE "organizations" ADD COLUMN "updated_at" timestamp with time zone DEFAULT now() NOT NULL;--> statement-breakpoint +CREATE INDEX "memberships_organization_id_idx" ON "memberships" USING btree ("organization_id");--> statement-breakpoint +CREATE INDEX "memberships_provider_external_id_idx" ON "memberships" USING btree ("identity_provider","external_id"); diff --git a/apps/cloud/drizzle/meta/0006_snapshot.json b/apps/cloud/drizzle/meta/0006_snapshot.json new file mode 100644 index 000000000..841701659 --- /dev/null +++ b/apps/cloud/drizzle/meta/0006_snapshot.json @@ -0,0 +1,1726 @@ +{ + "id": "f8ad8544-f5eb-4857-9218-6dfde713c46e", + "prevId": "09d08343-8162-4e6b-91ab-ce0a9d6bad10", + "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 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_provider": { + "name": "identity_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workos'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.identity_sync_events": { + "name": "identity_sync_events", + "schema": "", + "columns": { + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_id": { + "name": "event_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "identity_sync_events_provider_event_id_pk": { + "name": "identity_sync_events_provider_event_id_pk", + "columns": [ + "provider", + "event_id" + ] + } + }, + "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 + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_provider": { + "name": "identity_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workos'" + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "role_slug": { + "name": "role_slug", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "synced_at": { + "name": "synced_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "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": {} + }, + "memberships_provider_external_id_idx": { + "name": "memberships_provider_external_id_idx", + "columns": [ + { + "expression": "identity_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_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 + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identity_provider": { + "name": "identity_provider", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workos'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.blob": { + "name": "blob", + "schema": "", + "columns": { + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "blob_namespace_key_pk": { + "name": "blob_namespace_key_pk", + "columns": [ + "namespace", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.connection": { + "name": "connection", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identity_label": { + "name": "identity_label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_secret_id": { + "name": "access_token_secret_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "refresh_token_secret_id": { + "name": "refresh_token_secret_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_state": { + "name": "provider_state", + "type": "jsonb", + "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 + } + }, + "indexes": { + "connection_scope_id_idx": { + "name": "connection_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "connection_provider_idx": { + "name": "connection_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "connection_scope_id_id_pk": { + "name": "connection_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.definition": { + "name": "definition", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "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": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "definition_scope_id_idx": { + "name": "definition_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "definition_source_id_idx": { + "name": "definition_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "definition_plugin_id_idx": { + "name": "definition_plugin_id_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "definition_scope_id_id_pk": { + "name": "definition_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.graphql_operation": { + "name": "graphql_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "graphql_operation_scope_id_idx": { + "name": "graphql_operation_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "graphql_operation_source_id_idx": { + "name": "graphql_operation_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "graphql_operation_scope_id_id_pk": { + "name": "graphql_operation_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.graphql_source": { + "name": "graphql_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "endpoint": { + "name": "endpoint", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "graphql_source_scope_id_idx": { + "name": "graphql_source_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "graphql_source_scope_id_id_pk": { + "name": "graphql_source_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_binding": { + "name": "mcp_binding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcp_binding_scope_id_idx": { + "name": "mcp_binding_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_binding_source_id_idx": { + "name": "mcp_binding_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_binding_scope_id_id_pk": { + "name": "mcp_binding_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_oauth_session": { + "name": "mcp_oauth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session": { + "name": "session", + "type": "jsonb", + "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 + } + }, + "indexes": { + "mcp_oauth_session_scope_id_idx": { + "name": "mcp_oauth_session_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_oauth_session_scope_id_id_pk": { + "name": "mcp_oauth_session_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_source": { + "name": "mcp_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "mcp_source_scope_id_idx": { + "name": "mcp_source_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "mcp_source_scope_id_id_pk": { + "name": "mcp_source_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.openapi_oauth_session": { + "name": "openapi_oauth_session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "session": { + "name": "session", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "openapi_oauth_session_scope_id_idx": { + "name": "openapi_oauth_session_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "openapi_oauth_session_scope_id_id_pk": { + "name": "openapi_oauth_session_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.openapi_operation": { + "name": "openapi_operation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "binding": { + "name": "binding", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "openapi_operation_scope_id_idx": { + "name": "openapi_operation_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "openapi_operation_source_id_idx": { + "name": "openapi_operation_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "openapi_operation_scope_id_id_pk": { + "name": "openapi_operation_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.openapi_source": { + "name": "openapi_source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "spec": { + "name": "spec", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "base_url": { + "name": "base_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "oauth2": { + "name": "oauth2", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "invocation_config": { + "name": "invocation_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "openapi_source_scope_id_idx": { + "name": "openapi_source_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "openapi_source_scope_id_id_pk": { + "name": "openapi_source_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.openapi_source_binding": { + "name": "openapi_source_binding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_scope_id": { + "name": "source_scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_scope_id": { + "name": "target_scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slot": { + "name": "slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "jsonb", + "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 + } + }, + "indexes": { + "openapi_source_binding_source_id_idx": { + "name": "openapi_source_binding_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "openapi_source_binding_source_scope_id_idx": { + "name": "openapi_source_binding_source_scope_id_idx", + "columns": [ + { + "expression": "source_scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "openapi_source_binding_target_scope_id_idx": { + "name": "openapi_source_binding_target_scope_id_idx", + "columns": [ + { + "expression": "target_scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "openapi_source_binding_slot_idx": { + "name": "openapi_source_binding_slot_idx", + "columns": [ + { + "expression": "slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "openapi_source_binding_id_pk": { + "name": "openapi_source_binding_id_pk", + "columns": [ + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.secret": { + "name": "secret", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "owned_by_connection_id": { + "name": "owned_by_connection_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "secret_scope_id_idx": { + "name": "secret_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_provider_idx": { + "name": "secret_provider_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "secret_owned_by_connection_id_idx": { + "name": "secret_owned_by_connection_id_idx", + "columns": [ + { + "expression": "owned_by_connection_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "secret_scope_id_id_pk": { + "name": "secret_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.source": { + "name": "source", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "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 + }, + "can_edit": { + "name": "can_edit", + "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 + } + }, + "indexes": { + "source_scope_id_idx": { + "name": "source_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "source_plugin_id_idx": { + "name": "source_plugin_id_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "source_scope_id_id_pk": { + "name": "source_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.tool": { + "name": "tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_id": { + "name": "source_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin_id": { + "name": "plugin_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "input_schema": { + "name": "input_schema", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "output_schema": { + "name": "output_schema", + "type": "jsonb", + "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 + } + }, + "indexes": { + "tool_scope_id_idx": { + "name": "tool_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_source_id_idx": { + "name": "tool_source_id_idx", + "columns": [ + { + "expression": "source_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "tool_plugin_id_idx": { + "name": "tool_plugin_id_idx", + "columns": [ + { + "expression": "plugin_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "tool_scope_id_id_pk": { + "name": "tool_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workos_vault_metadata": { + "name": "workos_vault_metadata", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_id": { + "name": "scope_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "purpose": { + "name": "purpose", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "workos_vault_metadata_scope_id_idx": { + "name": "workos_vault_metadata_scope_id_idx", + "columns": [ + { + "expression": "scope_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": { + "workos_vault_metadata_scope_id_id_pk": { + "name": "workos_vault_metadata_scope_id_id_pk", + "columns": [ + "scope_id", + "id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": {}, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/apps/cloud/drizzle/meta/_journal.json b/apps/cloud/drizzle/meta/_journal.json index 96a200266..5075ad647 100644 --- a/apps/cloud/drizzle/meta/_journal.json +++ b/apps/cloud/drizzle/meta/_journal.json @@ -43,6 +43,13 @@ "when": 1777000000000, "tag": "0005_drop_connection_kind", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1777397349159, + "tag": "0006_polite_power_man", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/apps/cloud/src/api/core-shared-services.ts b/apps/cloud/src/api/core-shared-services.ts index 21cc75141..82f8d5cdc 100644 --- a/apps/cloud/src/api/core-shared-services.ts +++ b/apps/cloud/src/api/core-shared-services.ts @@ -14,6 +14,7 @@ import { Layer } from "effect"; import { WorkOSAuth } from "../auth/workos"; +import { IdentityProvider } from "../identity/provider"; import { AutumnService } from "../services/autumn"; /** @@ -24,5 +25,6 @@ import { AutumnService } from "../services/autumn"; */ export const CoreSharedServices = Layer.mergeAll( WorkOSAuth.Default, + IdentityProvider.WorkOSLive.pipe(Layer.provide(WorkOSAuth.Default)), AutumnService.Default, ); diff --git a/apps/cloud/src/api/layers.ts b/apps/cloud/src/api/layers.ts index c9803127d..1113250a9 100644 --- a/apps/cloud/src/api/layers.ts +++ b/apps/cloud/src/api/layers.ts @@ -10,6 +10,7 @@ import { GraphqlGroup, GraphqlHandlers } from "@executor/plugin-graphql/api"; import { OrgAuth } from "../auth/middleware"; import { OrgAuthLive, SessionAuthLive } from "../auth/middleware-live"; import { UserStoreService } from "../auth/context"; +import { IdentityDirectory } from "../identity/directory"; import { CloudAuthPublicHandlers, CloudSessionAuthHandlers, @@ -35,10 +36,15 @@ const ObservabilityLive = observabilityMiddleware(ProtectedCloudApi); const DbLive = DbService.Live; const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); +const IdentityDirectoryLive = IdentityDirectory.Live.pipe( + Layer.provideMerge(UserStoreLive), + Layer.provideMerge(CoreSharedServices), +); export const SharedServices = Layer.mergeAll( DbLive, UserStoreLive, + IdentityDirectoryLive, CoreSharedServices, HttpServer.layerContext, TelemetryLive, diff --git a/apps/cloud/src/api/protected.ts b/apps/cloud/src/api/protected.ts index 3cd99a342..324b4bd5d 100644 --- a/apps/cloud/src/api/protected.ts +++ b/apps/cloud/src/api/protected.ts @@ -7,7 +7,7 @@ import { McpExtensionService } from "@executor/plugin-mcp/api"; import { GraphqlExtensionService } from "@executor/plugin-graphql/api"; import { authorizeOrganization } from "../auth/authorize-organization"; -import { WorkOSAuth } from "../auth/workos"; +import { IdentityProvider } from "../identity/provider"; import { makeExecutionStack } from "../services/execution-stack"; import { HttpResponseError, isServerError, toErrorServerResponse } from "./error-response"; import { ProtectedCloudApiLive, RouterConfig, SharedServices } from "./layers"; @@ -23,13 +23,13 @@ const lookupOrgForRequest = (request: HttpServerRequest.HttpServerRequest) => message: "Invalid request", }), ); - const workos = yield* WorkOSAuth; - const session = yield* workos.authenticateRequest(webRequest); + const identity = yield* IdentityProvider; + const session = yield* identity.authenticateRequest(webRequest); if (!session || !session.organizationId) return null; - const org = yield* authorizeOrganization(session.userId, session.organizationId); + const org = yield* authorizeOrganization(session.accountId, session.organizationId); if (!org) return null; - return { org, userId: session.userId }; + return { org, userId: session.accountId }; }); const createProtectedApp = (userId: string, organizationId: string, organizationName: string) => diff --git a/apps/cloud/src/auth/api.ts b/apps/cloud/src/auth/api.ts index 829134bc6..aff60203b 100644 --- a/apps/cloud/src/auth/api.ts +++ b/apps/cloud/src/auth/api.ts @@ -77,6 +77,7 @@ export class CloudAuthApi extends HttpApiGroup.make("cloudAuth") .add( HttpApiEndpoint.get("organizations")`/auth/organizations` .addSuccess(AuthOrganizationsResponse) + .addError(UserStoreError) .addError(WorkOSError), ) .add( diff --git a/apps/cloud/src/auth/authorize-organization.ts b/apps/cloud/src/auth/authorize-organization.ts index 43a2cd449..f495cf6e7 100644 --- a/apps/cloud/src/auth/authorize-organization.ts +++ b/apps/cloud/src/auth/authorize-organization.ts @@ -1,36 +1,9 @@ -// --------------------------------------------------------------------------- -// Organization authorization — live membership check against WorkOS. -// --------------------------------------------------------------------------- -// -// The sealed session cookie carries an organizationId that WorkOS signed at -// login / refresh time. WorkOS does NOT invalidate existing sessions when a -// membership is revoked, and `session.authenticate()` validates the JWT -// locally without hitting the API — so a removed user keeps full access -// until their access token naturally expires (~10 min). -// -// To close that gap we verify membership live on every protected request. -// `listUserMemberships` is one WorkOS call per request. If this becomes a -// hot path we can layer a short per-(user, org) TTL cache underneath, or -// swap it for a local memberships table fed by the WorkOS Events API. -// -// Returns the resolved organization (via resolveOrganization) if the user -// currently holds an *active* membership in it, otherwise null. Callers -// should treat null as "no access" and route accordingly (onboarding page / -// 403). - import { Effect } from "effect"; -import { resolveOrganization } from "./resolve-organization"; -import { WorkOSAuth } from "./workos"; +import { IdentityDirectory } from "../identity/directory"; export const authorizeOrganization = (userId: string, organizationId: string) => Effect.gen(function* () { - const workos = yield* WorkOSAuth; - const memberships = yield* workos.listUserMemberships(userId); - const active = memberships.data.find( - (m) => m.organizationId === organizationId && m.status === "active", - ); - if (!active) return null; - - return yield* resolveOrganization(organizationId); + const directory = yield* IdentityDirectory; + return yield* directory.authorizeOrganization(userId, organizationId); }); diff --git a/apps/cloud/src/auth/handlers.ts b/apps/cloud/src/auth/handlers.ts index 3299e6ddd..00e497a54 100644 --- a/apps/cloud/src/auth/handlers.ts +++ b/apps/cloud/src/auth/handlers.ts @@ -7,6 +7,8 @@ import { SessionContext } from "./middleware"; import { UserStoreService } from "./context"; import { authorizeOrganization } from "./authorize-organization"; import { env } from "cloudflare:workers"; +import { IdentityDirectory } from "../identity/directory"; +import { IdentityProvider } from "../identity/provider"; import { WorkOSError } from "./errors"; import { WorkOSAuth } from "./workos"; @@ -121,7 +123,16 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( const result = yield* workos.authenticateWithCode(urlParams.code); // Mirror the account locally - yield* users.use((s) => s.ensureAccount(result.user.id)); + yield* users.use((s) => + s.ensureAccount({ + id: result.user.id, + email: result.user.email, + name: [result.user.firstName, result.user.lastName].filter(Boolean).join(" ") || null, + avatarUrl: result.user.profilePictureUrl ?? null, + externalId: result.user.id, + identityProvider: "workos", + }), + ); let sealedSession = result.sealedSession; @@ -193,6 +204,7 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( .handle("organizations", () => Effect.gen(function* () { const workos = yield* WorkOSAuth; + const directory = yield* IdentityDirectory; const session = yield* SessionContext; const memberships = yield* workos.listUserMemberships(session.accountId); @@ -202,9 +214,10 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( Effect.map((org) => ({ id: org.id, name: org.name })), Effect.orElseSucceed(() => null), ), - ), + ), { concurrency: "unbounded" }, ); + yield* directory.refreshAccountMemberships(session.accountId); return { organizations: organizations.filter((org): org is NonNullable => org !== null), @@ -214,10 +227,10 @@ export const CloudSessionAuthHandlers = HttpApiBuilder.group( ) .handle("switchOrganization", ({ payload }) => Effect.gen(function* () { - const workos = yield* WorkOSAuth; + const identity = yield* IdentityProvider; const session = yield* SessionContext; - const refreshed = yield* workos.refreshSession( + const refreshed = yield* identity.refreshSession( session.sealedSession, payload.organizationId, ); diff --git a/apps/cloud/src/auth/middleware-live.ts b/apps/cloud/src/auth/middleware-live.ts index df8cfeaae..b85f421ba 100644 --- a/apps/cloud/src/auth/middleware-live.ts +++ b/apps/cloud/src/auth/middleware-live.ts @@ -3,20 +3,20 @@ // Imports the WorkOS SDK so it must NOT be pulled into the client bundle. // --------------------------------------------------------------------------- -import { Effect, Layer, Redacted } from "effect"; +import { Effect, Layer } from "effect"; +import { IdentityProvider } from "../identity/provider"; import { NoOrganization, OrgAuth, SessionAuth, Unauthorized } from "./middleware"; -import { WorkOSAuth } from "./workos"; export const SessionAuthLive = Layer.effect( SessionAuth, Effect.gen(function* () { - const workos = yield* WorkOSAuth; + const identity = yield* IdentityProvider; return SessionAuth.of({ cookie: (sealedSession) => Effect.gen(function* () { - const result = yield* workos - .authenticateSealedSession(Redacted.value(sealedSession)) + const result = yield* identity + .authenticateSealedSession(sealedSession) .pipe(Effect.orElseSucceed(() => null)); if (!result) { @@ -24,12 +24,12 @@ export const SessionAuthLive = Layer.effect( } return { - accountId: result.userId, + accountId: result.accountId, email: result.email, - name: `${result.firstName ?? ""} ${result.lastName ?? ""}`.trim() || null, + name: result.name, avatarUrl: result.avatarUrl ?? null, organizationId: result.organizationId ?? null, - sealedSession: result.refreshedSession ?? Redacted.value(sealedSession), + sealedSession: result.sealedSession, refreshedSession: result.refreshedSession ?? null, }; }), @@ -40,12 +40,12 @@ export const SessionAuthLive = Layer.effect( export const OrgAuthLive = Layer.effect( OrgAuth, Effect.gen(function* () { - const workos = yield* WorkOSAuth; + const identity = yield* IdentityProvider; return OrgAuth.of({ cookie: (sealedSession) => Effect.gen(function* () { - const result = yield* workos - .authenticateSealedSession(Redacted.value(sealedSession)) + const result = yield* identity + .authenticateSealedSession(sealedSession) .pipe(Effect.orElseSucceed(() => null)); if (!result) { @@ -57,10 +57,10 @@ export const OrgAuthLive = Layer.effect( } return { - accountId: result.userId, + accountId: result.accountId, organizationId: result.organizationId, email: result.email, - name: `${result.firstName ?? ""} ${result.lastName ?? ""}`.trim() || null, + name: result.name, avatarUrl: result.avatarUrl ?? null, }; }), diff --git a/apps/cloud/src/auth/resolve-organization.ts b/apps/cloud/src/auth/resolve-organization.ts index e59f9ff3c..ffcdad30b 100644 --- a/apps/cloud/src/auth/resolve-organization.ts +++ b/apps/cloud/src/auth/resolve-organization.ts @@ -1,30 +1,9 @@ -// --------------------------------------------------------------------------- -// Organization lookup — local mirror with lazy WorkOS fallback. -// --------------------------------------------------------------------------- -// -// We keep a minimal local mirror of organizations so domain tables can -// foreign-key against them and so we don't hit WorkOS on every request. -// But the mirror can drift: a user's session can reference an org that was -// created outside this app (or before the mirror existed). Rather than -// proactively mirroring on every login — which was the source of the messy -// callback flow we just untangled — we mirror lazily the first time an -// unknown org is read. All other callers just do `getOrganization` and get -// a self-healing lookup for free. - import { Effect } from "effect"; -import { UserStoreService } from "./context"; -import { WorkOSAuth } from "./workos"; +import { IdentityDirectory } from "../identity/directory"; export const resolveOrganization = (organizationId: string) => Effect.gen(function* () { - const users = yield* UserStoreService; - const existing = yield* users.use((s) => s.getOrganization(organizationId)); - if (existing) return existing; - - const workos = yield* WorkOSAuth; - const fresh = yield* workos.getOrganization(organizationId); - return yield* users.use((s) => - s.upsertOrganization({ id: fresh.id, name: fresh.name }), - ); + const directory = yield* IdentityDirectory; + return yield* directory.getOrganization(organizationId); }); diff --git a/apps/cloud/src/identity/directory.test.ts b/apps/cloud/src/identity/directory.test.ts new file mode 100644 index 000000000..d89c28960 --- /dev/null +++ b/apps/cloud/src/identity/directory.test.ts @@ -0,0 +1,141 @@ +import { describe, expect, it } from "@effect/vitest"; +import { Effect, Layer } from "effect"; + +import { UserStoreService } from "../auth/context"; +import { IdentityDirectory } from "./directory"; +import { IdentityProvider } from "./provider"; +import type { IdentityMembership, IdentityOrganization } from "./types"; + +const org: IdentityOrganization = { id: "org_1", name: "Acme" }; +const activeMembership: IdentityMembership = { + id: "mem_1", + accountId: "user_1", + organizationId: org.id, + status: "active", + roleSlug: "admin", +}; + +type LocalMembership = { + readonly accountId: string; + readonly organizationId: string; + readonly status: string; + readonly roleSlug: string; +}; + +const makeDirectory = (options: { + readonly localMemberships?: ReadonlyArray; + readonly providerMemberships?: ReadonlyArray; +}) => { + const localMemberships = [...(options.localMemberships ?? [])]; + const providerMemberships = options.providerMemberships ?? [activeMembership]; + let providerRefreshes = 0; + + const UserStoreTest = Layer.succeed(UserStoreService, { + use: (fn: (store: { + getOrganization: (id: string) => Promise; + upsertOrganization: (input: IdentityOrganization) => Promise; + getMembership: ( + accountId: string, + organizationId: string, + ) => Promise; + listMembershipsForAccount: (accountId: string) => Promise>; + upsertMembership: (input: LocalMembership) => Promise; + }) => Promise) => + Effect.promise(() => + fn({ + getOrganization: async (id) => (id === org.id ? org : null), + upsertOrganization: async (input) => input, + getMembership: async (accountId, organizationId) => + localMemberships.find( + (m) => m.accountId === accountId && m.organizationId === organizationId, + ) ?? null, + listMembershipsForAccount: async (accountId) => + localMemberships.filter((m) => m.accountId === accountId), + upsertMembership: async (input) => { + localMemberships.push(input); + return input; + }, + }), + ), + } as unknown as UserStoreService["Type"]); + + const IdentityProviderTest = Layer.succeed(IdentityProvider, { + authenticateSealedSession: () => Effect.succeed(null), + authenticateRequest: () => Effect.succeed(null), + listUserMemberships: () => + Effect.sync(() => { + providerRefreshes++; + return providerMemberships; + }), + listOrganizationMembers: () => Effect.succeed([]), + listOrganizationRoles: () => Effect.succeed([]), + getOrganization: () => Effect.succeed(org), + refreshSession: () => Effect.succeed(null), + } as IdentityProvider["Type"]); + + return { + get providerRefreshes() { + return providerRefreshes; + }, + layer: IdentityDirectory.Live.pipe( + Layer.provideMerge(UserStoreTest), + Layer.provideMerge(IdentityProviderTest), + ) as Layer.Layer, + }; +}; + +describe("IdentityDirectory", () => { + it.effect("authorizes from an active local membership without provider refresh", () => + Effect.gen(function* () { + const directory = makeDirectory({ localMemberships: [activeMembership] }); + + const result = yield* Effect.provide( + Effect.gen(function* () { + const service = yield* IdentityDirectory; + return yield* service.authorizeOrganization("user_1", "org_1"); + }), + directory.layer, + ); + + expect(result).toEqual(org); + expect(directory.providerRefreshes).toBe(0); + }), + ); + + it.effect("falls back to provider refresh when local membership is missing", () => + Effect.gen(function* () { + const directory = makeDirectory({}); + + const result = yield* Effect.provide( + Effect.gen(function* () { + const service = yield* IdentityDirectory; + return yield* service.authorizeOrganization("user_1", "org_1"); + }), + directory.layer, + ); + + expect(result).toEqual(org); + expect(directory.providerRefreshes).toBe(1); + }), + ); + + it.effect("does not authorize inactive memberships", () => + Effect.gen(function* () { + const directory = makeDirectory({ + localMemberships: [{ ...activeMembership, status: "inactive" }], + providerMemberships: [{ ...activeMembership, status: "inactive" }], + }); + + const result = yield* Effect.provide( + Effect.gen(function* () { + const service = yield* IdentityDirectory; + return yield* service.authorizeOrganization("user_1", "org_1"); + }), + directory.layer, + ); + + expect(result).toBeNull(); + expect(directory.providerRefreshes).toBe(1); + }), + ); +}); diff --git a/apps/cloud/src/identity/directory.ts b/apps/cloud/src/identity/directory.ts new file mode 100644 index 000000000..4e736b78a --- /dev/null +++ b/apps/cloud/src/identity/directory.ts @@ -0,0 +1,142 @@ +import { Context, Effect, Layer } from "effect"; + +import { UserStoreService } from "../auth/context"; +import { WorkOSError, type UserStoreError } from "../auth/errors"; +import { IdentityProvider } from "./provider"; +import type { IdentityMembership, IdentityOrganization } from "./types"; + +export class IdentityDirectory extends Context.Tag("@executor/cloud/IdentityDirectory")< + IdentityDirectory, + { + readonly getOrganization: ( + organizationId: string, + ) => Effect.Effect; + readonly authorizeOrganization: ( + accountId: string, + organizationId: string, + ) => Effect.Effect; + readonly listUserOrganizations: ( + accountId: string, + ) => Effect.Effect, UserStoreError | WorkOSError>; + readonly requireRole: ( + accountId: string, + organizationId: string, + roleSlug: string, + ) => Effect.Effect; + readonly refreshAccountMemberships: ( + accountId: string, + ) => Effect.Effect, UserStoreError | WorkOSError>; + } +>() { + static Live = Layer.effect( + this, + Effect.gen(function* () { + const users = yield* UserStoreService; + const provider = yield* IdentityProvider; + + const getOrganization = (organizationId: string) => + Effect.gen(function* () { + const existing = yield* users.use((store) => store.getOrganization(organizationId)); + if (existing) return { id: existing.id, name: existing.name }; + + const fresh = yield* provider.getOrganization(organizationId); + const saved = yield* users.use((store) => + store.upsertOrganization({ + id: fresh.id, + name: fresh.name, + externalId: fresh.id, + identityProvider: "workos", + }), + ); + return { id: saved.id, name: saved.name }; + }); + + const refreshAccountMemberships = (accountId: string) => + Effect.gen(function* () { + const memberships = yield* provider.listUserMemberships(accountId); + for (const membership of memberships) { + const org = yield* provider.getOrganization(membership.organizationId); + yield* users.use((store) => + store.upsertOrganization({ + id: org.id, + name: org.name, + externalId: org.id, + identityProvider: "workos", + }), + ); + yield* users.use((store) => + store.upsertMembership({ + accountId: membership.accountId, + organizationId: membership.organizationId, + externalId: membership.id, + identityProvider: "workos", + status: membership.status, + roleSlug: membership.roleSlug, + }), + ); + } + return memberships; + }); + + const authorizeOrganization = (accountId: string, organizationId: string) => + Effect.gen(function* () { + const local = yield* users.use((store) => + store.getMembership(accountId, organizationId), + ); + if (local?.status === "active") { + return yield* getOrganization(organizationId); + } + + const fresh = yield* refreshAccountMemberships(accountId); + const active = fresh.find( + (membership) => + membership.organizationId === organizationId && membership.status === "active", + ); + if (!active) return null; + return yield* getOrganization(organizationId); + }); + + const listActiveOrganizations = (memberships: ReadonlyArray<{ organizationId: string }>) => + Effect.all( + memberships.map((membership) => getOrganization(membership.organizationId)), + { concurrency: 5 }, + ).pipe( + Effect.map((orgs) => + orgs.filter((org): org is IdentityOrganization => org != null), + ), + ); + + return IdentityDirectory.of({ + getOrganization, + authorizeOrganization, + listUserOrganizations: (accountId) => + Effect.gen(function* () { + const local = yield* users.use((store) => store.listMembershipsForAccount(accountId)); + const active = local.filter((membership) => membership.status === "active"); + if (active.length > 0) return yield* listActiveOrganizations(active); + + const fresh = yield* refreshAccountMemberships(accountId); + return yield* listActiveOrganizations( + fresh.filter((membership) => membership.status === "active"), + ); + }), + requireRole: (accountId, organizationId, roleSlug) => + Effect.gen(function* () { + const local = yield* users.use((store) => + store.getMembership(accountId, organizationId), + ); + if (local?.status === "active" && local.roleSlug === roleSlug) return true; + + const fresh = yield* refreshAccountMemberships(accountId); + return fresh.some( + (membership) => + membership.organizationId === organizationId && + membership.status === "active" && + membership.roleSlug === roleSlug, + ); + }), + refreshAccountMemberships, + }); + }), + ); +} diff --git a/apps/cloud/src/identity/provider.ts b/apps/cloud/src/identity/provider.ts new file mode 100644 index 000000000..2718338b5 --- /dev/null +++ b/apps/cloud/src/identity/provider.ts @@ -0,0 +1,132 @@ +import { Context, Effect, Layer, Redacted } from "effect"; + +import { WorkOSError } from "../auth/errors"; +import { WorkOSAuth } from "../auth/workos"; +import type { + IdentityMemberProfile, + IdentityMembership, + IdentityOrganization, + IdentitySession, +} from "./types"; + +type WorkOSSession = { + readonly userId: string; + readonly email: string; + readonly firstName: string | null; + readonly lastName: string | null; + readonly avatarUrl: string | null | undefined; + readonly organizationId: string | null | undefined; + readonly refreshedSession: string | undefined; +}; + +export class IdentityProvider extends Context.Tag("@executor/cloud/IdentityProvider")< + IdentityProvider, + { + readonly authenticateSealedSession: ( + sealedSession: Redacted.Redacted, + ) => Effect.Effect; + readonly authenticateRequest: ( + request: Request, + ) => Effect.Effect; + readonly listUserMemberships: ( + accountId: string, + ) => Effect.Effect, WorkOSError>; + readonly listOrganizationMembers: ( + organizationId: string, + ) => Effect.Effect, WorkOSError>; + readonly listOrganizationRoles: ( + organizationId: string, + ) => Effect.Effect, WorkOSError>; + readonly getOrganization: ( + organizationId: string, + ) => Effect.Effect; + readonly refreshSession: ( + sealedSession: string, + organizationId?: string, + ) => Effect.Effect; + } +>() { + static WorkOSLive = Layer.effect( + this, + Effect.gen(function* () { + const workos = yield* WorkOSAuth; + + const toSession = (session: WorkOSSession | null, sealedSession: string) => { + if (!session) return null; + return { + accountId: session.userId, + email: session.email, + name: `${session.firstName ?? ""} ${session.lastName ?? ""}`.trim() || null, + avatarUrl: session.avatarUrl ?? null, + organizationId: session.organizationId ?? null, + sealedSession: session.refreshedSession ?? sealedSession, + refreshedSession: session.refreshedSession ?? null, + } satisfies IdentitySession; + }; + + return IdentityProvider.of({ + authenticateSealedSession: (sealedSession) => + Effect.map( + workos.authenticateSealedSession(Redacted.value(sealedSession)), + (session) => toSession(session, Redacted.value(sealedSession)), + ), + authenticateRequest: (request) => + Effect.map(workos.authenticateRequest(request), (session) => + toSession(session, parseCookie(request.headers.get("cookie"), "wos-session") ?? ""), + ), + listUserMemberships: (accountId) => + Effect.map(workos.listUserMemberships(accountId), (result) => + result.data.map((membership) => ({ + id: membership.id, + accountId: membership.userId, + organizationId: membership.organizationId, + status: membership.status, + roleSlug: membership.role?.slug ?? "member", + })), + ), + listOrganizationMembers: (organizationId) => + Effect.gen(function* () { + const result = yield* workos.listOrgMembers(organizationId); + return yield* Effect.all( + result.data.map((membership) => + Effect.gen(function* () { + const user = yield* workos.getUser(membership.userId); + return { + id: membership.id, + accountId: membership.userId, + organizationId: membership.organizationId, + status: membership.status, + roleSlug: membership.role?.slug ?? "member", + email: user.email, + name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null, + avatarUrl: user.profilePictureUrl ?? null, + lastActiveAt: user.lastSignInAt ?? null, + }; + }), + ), + { concurrency: 5 }, + ); + }), + listOrganizationRoles: (organizationId) => + Effect.map(workos.listOrgRoles(organizationId), (result) => + result.data.map((role) => ({ slug: role.slug, name: role.name })), + ), + getOrganization: (organizationId) => + Effect.map(workos.getOrganization(organizationId), (org) => ({ + id: org.id, + name: org.name, + })), + refreshSession: workos.refreshSession, + }); + }), + ); +} + +const parseCookie = (cookieHeader: string | null, name: string): string | null => { + if (!cookieHeader) return null; + const match = cookieHeader + .split(";") + .map((value) => value.trim()) + .find((value) => value.startsWith(`${name}=`)); + return match ? match.slice(name.length + 1) || null : null; +}; diff --git a/apps/cloud/src/identity/types.ts b/apps/cloud/src/identity/types.ts new file mode 100644 index 000000000..0d4ceb106 --- /dev/null +++ b/apps/cloud/src/identity/types.ts @@ -0,0 +1,31 @@ +export type IdentityProviderId = "workos" | "local" | (string & {}); + +export type IdentitySession = { + readonly accountId: string; + readonly email: string; + readonly name: string | null; + readonly avatarUrl: string | null; + readonly organizationId: string | null; + readonly sealedSession: string; + readonly refreshedSession: string | null; +}; + +export type IdentityOrganization = { + readonly id: string; + readonly name: string; +}; + +export type IdentityMembership = { + readonly id: string; + readonly accountId: string; + readonly organizationId: string; + readonly status: string; + readonly roleSlug: string; +}; + +export type IdentityMemberProfile = IdentityMembership & { + readonly email: string; + readonly name: string | null; + readonly avatarUrl: string | null; + readonly lastActiveAt: string | null; +}; diff --git a/apps/cloud/src/mcp-session.ts b/apps/cloud/src/mcp-session.ts index fa26ad870..405d5e6dd 100644 --- a/apps/cloud/src/mcp-session.ts +++ b/apps/cloud/src/mcp-session.ts @@ -25,6 +25,7 @@ import type { DrizzleDb, DbServiceShape } from "./services/db"; import { CoreSharedServices } from "./api/core-shared-services"; import { UserStoreService } from "./auth/context"; import { resolveOrganization } from "./auth/resolve-organization"; +import { IdentityDirectory } from "./identity/directory"; import { DbService, combinedSchema, resolveConnectionString } from "./services/db"; import { makeExecutionStack } from "./services/execution-stack"; import { makeMcpWorkerTransport, type McpWorkerTransport } from "./services/mcp-worker-transport"; @@ -160,7 +161,11 @@ const makeEphemeralDb = (): DbHandle => makeDbHandle({ idleTimeout: 0, maxLifeti const makeResolveOrganizationServices = (dbHandle: DbHandle) => { const DbLive = Layer.succeed(DbService, { sql: dbHandle.sql, db: dbHandle.db }); const UserStoreLive = UserStoreService.Live.pipe(Layer.provide(DbLive)); - return Layer.mergeAll(DbLive, UserStoreLive, CoreSharedServices); + const IdentityDirectoryLive = IdentityDirectory.Live.pipe( + Layer.provideMerge(UserStoreLive), + Layer.provideMerge(CoreSharedServices), + ); + return Layer.mergeAll(DbLive, UserStoreLive, IdentityDirectoryLive, CoreSharedServices); }; // Session services DON'T re-provide `DoTelemetryLive` — that would install a diff --git a/apps/cloud/src/org/api.ts b/apps/cloud/src/org/api.ts index a635f2f11..002b7c626 100644 --- a/apps/cloud/src/org/api.ts +++ b/apps/cloud/src/org/api.ts @@ -100,12 +100,14 @@ export class OrgApi extends HttpApiGroup.make("org") HttpApiEndpoint.post("invite")`/org/invite` .setPayload(InviteBody) .addSuccess(InviteResponse) + .addError(UserStoreError) .addError(WorkOSError) .addError(Forbidden), ) .add( HttpApiEndpoint.del("removeMember")`/org/members/${membershipIdParam}` .addSuccess(RemoveResponse) + .addError(UserStoreError) .addError(WorkOSError) .addError(Forbidden), ) @@ -113,6 +115,7 @@ export class OrgApi extends HttpApiGroup.make("org") HttpApiEndpoint.patch("updateMemberRole")`/org/members/${membershipIdParam}/role` .setPayload(UpdateRoleBody) .addSuccess(UpdateRoleResponse) + .addError(UserStoreError) .addError(WorkOSError) .addError(Forbidden), ) @@ -124,12 +127,14 @@ export class OrgApi extends HttpApiGroup.make("org") .add( HttpApiEndpoint.post("getDomainVerificationLink")`/org/domains/verify-link` .addSuccess(DomainVerificationLinkResponse) + .addError(UserStoreError) .addError(WorkOSError) .addError(Forbidden), ) .add( HttpApiEndpoint.del("deleteDomain")`/org/domains/${domainIdParam}` .addSuccess(RemoveResponse) + .addError(UserStoreError) .addError(WorkOSError) .addError(Forbidden), ) diff --git a/apps/cloud/src/org/handlers.ts b/apps/cloud/src/org/handlers.ts index ba154201a..bf8d4b55e 100644 --- a/apps/cloud/src/org/handlers.ts +++ b/apps/cloud/src/org/handlers.ts @@ -4,6 +4,8 @@ import { Effect } from "effect"; import { UserStoreService } from "../auth/context"; import { AuthContext } from "../auth/middleware"; import { env } from "cloudflare:workers"; +import { IdentityDirectory } from "../identity/directory"; +import { IdentityProvider } from "../identity/provider"; import { WorkOSAuth } from "../auth/workos"; import { AutumnService } from "../services/autumn"; import { OrgHttpApi } from "./compose"; @@ -11,10 +13,9 @@ import { Forbidden } from "./api"; const requireAdmin = Effect.gen(function* () { const auth = yield* AuthContext; - const workos = yield* WorkOSAuth; - const memberships = yield* workos.listOrgMembers(auth.organizationId); - const currentMembership = memberships.data.find((m) => m.userId === auth.accountId); - if (!currentMembership || currentMembership.role?.slug !== "admin") { + const directory = yield* IdentityDirectory; + const allowed = yield* directory.requireRole(auth.accountId, auth.organizationId, "admin"); + if (!allowed) { return yield* new Forbidden(); } }); @@ -57,29 +58,21 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => .handle("listMembers", () => Effect.gen(function* () { const auth = yield* AuthContext; - const workos = yield* WorkOSAuth; - - const memberships = yield* workos.listOrgMembers(auth.organizationId); - - const members = yield* Effect.all( - memberships.data.map((m) => - Effect.gen(function* () { - const user = yield* workos.getUser(m.userId); - return { - id: m.id, - userId: m.userId, - email: user.email, - name: [user.firstName, user.lastName].filter(Boolean).join(" ") || null, - avatarUrl: user.profilePictureUrl ?? null, - role: m.role?.slug ?? "member", - status: m.status, - lastActiveAt: user.lastSignInAt ?? null, - isCurrentUser: m.userId === auth.accountId, - }; - }), - ), - { concurrency: 5 }, - ); + const identity = yield* IdentityProvider; + + const memberships = yield* identity.listOrganizationMembers(auth.organizationId); + + const members = memberships.map((m) => ({ + id: m.id, + userId: m.accountId, + email: m.email, + name: m.name, + avatarUrl: m.avatarUrl, + role: m.roleSlug, + status: m.status, + lastActiveAt: m.lastActiveAt, + isCurrentUser: m.accountId === auth.accountId, + })); return { members }; }), @@ -87,12 +80,12 @@ export const OrgHandlers = HttpApiBuilder.group(OrgHttpApi, "org", (handlers) => .handle("listRoles", () => Effect.gen(function* () { const auth = yield* AuthContext; - const workos = yield* WorkOSAuth; + const identity = yield* IdentityProvider; - const result = yield* workos.listOrgRoles(auth.organizationId); + const roles = yield* identity.listOrganizationRoles(auth.organizationId); return { - roles: result.data.map((r) => ({ + roles: roles.map((r) => ({ slug: r.slug, name: r.name, })), diff --git a/apps/cloud/src/services/schema.ts b/apps/cloud/src/services/schema.ts index 1038c67f3..daf8dfe04 100644 --- a/apps/cloud/src/services/schema.ts +++ b/apps/cloud/src/services/schema.ts @@ -11,11 +11,17 @@ // We do NOT mirror invitations or user profile data — those stay in WorkOS // and are queried via API when needed. -import { pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core"; +import { index, pgTable, primaryKey, text, timestamp } from "drizzle-orm/pg-core"; /** Login identity. The `id` is the WorkOS user ID. */ export const accounts = pgTable("accounts", { id: text("id").primaryKey(), + email: text("email"), + name: text("name"), + avatarUrl: text("avatar_url"), + externalId: text("external_id"), + identityProvider: text("identity_provider").notNull().default("workos"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); @@ -23,6 +29,9 @@ export const accounts = pgTable("accounts", { export const organizations = pgTable("organizations", { id: text("id").primaryKey(), name: text("name").notNull(), + externalId: text("external_id"), + identityProvider: text("identity_provider").notNull().default("workos"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }); @@ -40,9 +49,30 @@ export const memberships = pgTable( organizationId: text("organization_id") .notNull() .references(() => organizations.id, { onDelete: "cascade" }), + externalId: text("external_id"), + identityProvider: text("identity_provider").notNull().default("workos"), + status: text("status").notNull().default("active"), + roleSlug: text("role_slug").notNull().default("member"), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), + syncedAt: timestamp("synced_at", { withTimezone: true }).notNull().defaultNow(), createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), }, (t) => ({ pk: primaryKey({ columns: [t.accountId, t.organizationId] }), + byOrganization: index("memberships_organization_id_idx").on(t.organizationId), + byExternal: index("memberships_provider_external_id_idx").on(t.identityProvider, t.externalId), + }), +); + +export const identitySyncEvents = pgTable( + "identity_sync_events", + { + provider: text("provider").notNull(), + eventId: text("event_id").notNull(), + eventType: text("event_type").notNull(), + processedAt: timestamp("processed_at", { withTimezone: true }).notNull().defaultNow(), + }, + (t) => ({ + pk: primaryKey({ columns: [t.provider, t.eventId] }), }), ); diff --git a/apps/cloud/src/services/user-store.ts b/apps/cloud/src/services/user-store.ts index d5a4781f8..39fd958d6 100644 --- a/apps/cloud/src/services/user-store.ts +++ b/apps/cloud/src/services/user-store.ts @@ -7,20 +7,68 @@ // so domain tables can foreign-key against them and so we can resolve org // metadata without an API call on every request. -import { eq } from "drizzle-orm"; +import { and, eq } from "drizzle-orm"; -import { accounts, organizations } from "./schema"; +import { accounts, identitySyncEvents, memberships, organizations } from "./schema"; import type { DrizzleDb } from "./db"; export type Account = typeof accounts.$inferSelect; export type Organization = typeof organizations.$inferSelect; +export type Membership = typeof memberships.$inferSelect; + +export type AccountInput = { + readonly id: string; + readonly email?: string | null; + readonly name?: string | null; + readonly avatarUrl?: string | null; + readonly externalId?: string | null; + readonly identityProvider?: string; +}; + +export type OrganizationInput = { + readonly id: string; + readonly name: string; + readonly externalId?: string | null; + readonly identityProvider?: string; +}; + +export type MembershipInput = { + readonly accountId: string; + readonly organizationId: string; + readonly externalId?: string | null; + readonly identityProvider?: string; + readonly status?: string; + readonly roleSlug?: string; +}; export const makeUserStore = (db: DrizzleDb) => ({ // --- Accounts --- - ensureAccount: async (id: string) => { - const [result] = await db.insert(accounts).values({ id }).onConflictDoNothing().returning(); - return result ?? (await db.select().from(accounts).where(eq(accounts.id, id)))[0]!; + ensureAccount: async (idOrAccount: string | AccountInput) => { + const account = typeof idOrAccount === "string" ? { id: idOrAccount } : idOrAccount; + const [result] = await db + .insert(accounts) + .values({ + id: account.id, + email: account.email ?? null, + name: account.name ?? null, + avatarUrl: account.avatarUrl ?? null, + externalId: account.externalId ?? account.id, + identityProvider: account.identityProvider ?? "workos", + }) + .onConflictDoUpdate({ + target: accounts.id, + set: { + email: account.email ?? null, + name: account.name ?? null, + avatarUrl: account.avatarUrl ?? null, + externalId: account.externalId ?? account.id, + identityProvider: account.identityProvider ?? "workos", + updatedAt: new Date(), + }, + }) + .returning(); + return result ?? (await db.select().from(accounts).where(eq(accounts.id, account.id)))[0]!; }, getAccount: async (id: string) => { @@ -30,13 +78,23 @@ export const makeUserStore = (db: DrizzleDb) => ({ // --- Organizations --- - upsertOrganization: async (org: { id: string; name: string }) => { + upsertOrganization: async (org: OrganizationInput) => { const [result] = await db .insert(organizations) - .values(org) + .values({ + id: org.id, + name: org.name, + externalId: org.externalId ?? org.id, + identityProvider: org.identityProvider ?? "workos", + }) .onConflictDoUpdate({ target: organizations.id, - set: { name: org.name }, + set: { + name: org.name, + externalId: org.externalId ?? org.id, + identityProvider: org.identityProvider ?? "workos", + updatedAt: new Date(), + }, }) .returning(); return result!; @@ -46,4 +104,76 @@ export const makeUserStore = (db: DrizzleDb) => ({ const rows = await db.select().from(organizations).where(eq(organizations.id, id)); return rows[0] ?? null; }, + + // --- Memberships --- + + upsertMembership: async (membership: MembershipInput) => { + const now = new Date(); + const values = { + accountId: membership.accountId, + organizationId: membership.organizationId, + externalId: membership.externalId ?? `${membership.accountId}:${membership.organizationId}`, + identityProvider: membership.identityProvider ?? "workos", + status: membership.status ?? "active", + roleSlug: membership.roleSlug ?? "member", + updatedAt: now, + syncedAt: now, + }; + const [result] = await db + .insert(memberships) + .values(values) + .onConflictDoUpdate({ + target: [memberships.accountId, memberships.organizationId], + set: { + externalId: values.externalId, + identityProvider: values.identityProvider, + status: values.status, + roleSlug: values.roleSlug, + updatedAt: now, + syncedAt: now, + }, + }) + .returning(); + return result!; + }, + + getMembership: async (accountId: string, organizationId: string) => { + const rows = await db + .select() + .from(memberships) + .where( + and(eq(memberships.accountId, accountId), eq(memberships.organizationId, organizationId)), + ); + return rows[0] ?? null; + }, + + listMembershipsForAccount: async (accountId: string) => + db.select().from(memberships).where(eq(memberships.accountId, accountId)), + + listMembershipsForOrganization: async (organizationId: string) => + db.select().from(memberships).where(eq(memberships.organizationId, organizationId)), + + deactivateMembership: async (accountId: string, organizationId: string) => { + const [result] = await db + .update(memberships) + .set({ status: "inactive", updatedAt: new Date(), syncedAt: new Date() }) + .where( + and(eq(memberships.accountId, accountId), eq(memberships.organizationId, organizationId)), + ) + .returning(); + return result ?? null; + }, + + recordIdentityEvent: async (event: { + provider: string; + eventId: string; + eventType: string; + }) => { + const [result] = await db + .insert(identitySyncEvents) + .values(event) + .onConflictDoNothing() + .returning(); + return result != null; + }, });