From 02e18fc904a1b4074b770fe65557b8820c470997 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:27:57 +0000 Subject: [PATCH 01/10] feat(slack): Require dashboard sign-in before first Slack use Add the experimental slack-sign-in feature. When it is on, Slack mentions and DMs from a user who has not signed in are dropped, and the user gets a private link to the dashboard sign-in page. The page marks Slack identities already linked to the Google user through a verified email as signed in. Refs #2219 Co-Authored-By: David Cramer --- .../content/docs/reference/config-and-env.md | 10 + .../migrations/0060_identity_signed_in_at.sql | 1 + .../junior/migrations/meta/0060_snapshot.json | 3625 +++++++++++++++++ packages/junior/migrations/meta/_journal.json | 7 + packages/junior/src/api/slack-sign-in.ts | 74 + packages/junior/src/app.ts | 16 + packages/junior/src/chat/dashboard-link.ts | 6 + packages/junior/src/chat/experimental.ts | 1 + packages/junior/src/chat/identities/README.md | 12 + packages/junior/src/chat/identities/sql.ts | 50 + .../junior/src/chat/ingress/slack-sign-in.ts | 69 + .../junior/src/chat/ingress/slack-webhook.ts | 4 + packages/junior/src/db/schema/identities.ts | 2 + .../slack/webhook-auth-boundary.test.ts | 72 + 14 files changed, 3949 insertions(+) create mode 100644 packages/junior/migrations/0060_identity_signed_in_at.sql create mode 100644 packages/junior/migrations/meta/0060_snapshot.json create mode 100644 packages/junior/src/api/slack-sign-in.ts create mode 100644 packages/junior/src/chat/ingress/slack-sign-in.ts diff --git a/packages/docs/src/content/docs/reference/config-and-env.md b/packages/docs/src/content/docs/reference/config-and-env.md index 5fa6c9736a..61b3665325 100644 --- a/packages/docs/src/content/docs/reference/config-and-env.md +++ b/packages/docs/src/content/docs/reference/config-and-env.md @@ -168,6 +168,9 @@ const app = await createApp({ // database directly and skip Conversation privacy checks. Enable them only // on isolated deployments, such as Previews behind deployment protection. "operator-tools": true, + // Ask each Slack user to sign in to the dashboard once before Junior + // replies to their mentions and DMs. Requires the dashboard. + "slack-sign-in": true, }, }); ``` @@ -211,6 +214,13 @@ pre-stable surface. `passive-routing` turns on replies to non-mention messages in threads Junior already joined. Leave it unset in production unless you are testing that path. +`slack-sign-in` makes each Slack user sign in once with the dashboard Google +account before Junior starts work from their mentions or DMs. Until then, +Junior drops the message and sends the user a private sign-in link. The Slack +account matches only when its Slack profile email is the same as the Google +account email, so the dashboard allowlist also controls who can use Junior in +Slack. Passive thread replies are not checked yet. + `operator-tools` adds `runOperatorSql` to non-public Conversations. The tool runs one SQL statement against the app database and returns up to 200 rows. Each call uses its own database connection, which is closed afterwards, so diff --git a/packages/junior/migrations/0060_identity_signed_in_at.sql b/packages/junior/migrations/0060_identity_signed_in_at.sql new file mode 100644 index 0000000000..782a9bfcd0 --- /dev/null +++ b/packages/junior/migrations/0060_identity_signed_in_at.sql @@ -0,0 +1 @@ +ALTER TABLE "junior_identities" ADD COLUMN "signed_in_at" timestamp with time zone; \ No newline at end of file diff --git a/packages/junior/migrations/meta/0060_snapshot.json b/packages/junior/migrations/meta/0060_snapshot.json new file mode 100644 index 0000000000..62aedb74fb --- /dev/null +++ b/packages/junior/migrations/meta/0060_snapshot.json @@ -0,0 +1,3625 @@ +{ + "id": "f0350ff0-39ec-4d4d-9db6-10336f76077d", + "prevId": "6936b8a8-1c94-4be1-a259-10512747eb34", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_bindings": { + "name": "junior_agent_bindings", + "schema": "", + "columns": { + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_bindings_child_idx": { + "name": "junior_agent_bindings_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "child_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_bindings_parent_conversation_id_name_pk": { + "name": "junior_agent_bindings_parent_conversation_id_name_pk", + "columns": [ + "parent_conversation_id", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_agent_invocations": { + "name": "junior_agent_invocations", + "schema": "", + "columns": { + "invocation_id": { + "name": "invocation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_context_json": { + "name": "credential_context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_visibility": { + "name": "destination_visibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_profile": { + "name": "model_profile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mailbox_status": { + "name": "mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_mailbox_status": { + "name": "parent_mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_invocations_child_idx": { + "name": "junior_agent_invocations_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_mailbox_idx": { + "name": "junior_agent_invocations_mailbox_idx", + "columns": [ + { + "expression": "mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_parent_mailbox_idx": { + "name": "junior_agent_invocations_parent_mailbox_idx", + "columns": [ + { + "expression": "parent_mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "child_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_api_tokens": { + "name": "junior_api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_email_normalized": { + "name": "owner_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_api_tokens_token_hash_uidx": { + "name": "junior_api_tokens_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_api_tokens_owner_email_idx": { + "name": "junior_api_tokens_owner_email_idx", + "columns": [ + { + "expression": "owner_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_artifacts": { + "name": "junior_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ext": { + "name": "ext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_artifacts_conversation_sha_uidx": { + "name": "junior_artifacts_conversation_sha_uidx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_gc_idx": { + "name": "junior_artifacts_gc_idx", + "columns": [ + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_conversation_idx": { + "name": "junior_artifacts_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_attachments": { + "name": "junior_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vision_summary": { + "name": "vision_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_attachments_conversation_idx": { + "name": "junior_attachments_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_provider_idx": { + "name": "junior_attachments_provider_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_gc_idx": { + "name": "junior_attachments_gc_idx", + "columns": [ + { + "expression": "storage_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_attachments_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_attachments_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_attachments", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automation_runs": { + "name": "junior_automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "automation_version": { + "name": "automation_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_for_ms": { + "name": "scheduled_for_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at_ms": { + "name": "claimed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at_ms": { + "name": "started_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "completed_at_ms": { + "name": "completed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_message_ts": { + "name": "result_message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_automation_runs_automation_idx": { + "name": "junior_automation_runs_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_completed_idx": { + "name": "junior_automation_runs_completed_idx", + "columns": [ + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_incomplete_idx": { + "name": "junior_automation_runs_incomplete_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automation_runs\".\"status\" in ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automation_runs_automation_id_junior_automations_id_fk": { + "name": "junior_automation_runs_automation_id_junior_automations_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_automation_runs_automation_version_fk": { + "name": "junior_automation_runs_automation_version_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automation_versions", + "columnsFrom": [ + "automation_id", + "automation_version" + ], + "columnsTo": [ + "automation_id", + "version" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "junior_automation_runs_conversation_id_unique": { + "name": "junior_automation_runs_conversation_id_unique", + "nullsNotDistinct": false, + "columns": [ + "conversation_id" + ] + } + }, + "policies": {}, + "checkConstraints": { + "junior_automation_runs_status_check": { + "name": "junior_automation_runs_status_check", + "value": "\"junior_automation_runs\".\"status\" in ('pending', 'running', 'completed', 'failed', 'blocked', 'skipped')" + } + }, + "isRLSEnabled": false + }, + "public.junior_automation_versions": { + "name": "junior_automation_versions", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "edited_by": { + "name": "edited_by", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "definition": { + "name": "definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_automation_versions_automation_id_fk": { + "name": "junior_automation_versions_automation_id_fk", + "tableFrom": "junior_automation_versions", + "tableTo": "junior_automations", + "columnsFrom": [ + "automation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_automation_versions_pk": { + "name": "junior_automation_versions_pk", + "columns": [ + "automation_id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automations": { + "name": "junior_automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_slack_user_id": { + "name": "creator_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "next_run_at_ms": { + "name": "next_run_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "run_now_at_ms": { + "name": "run_now_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_automations_team_idx": { + "name": "junior_automations_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_creator_identity_idx": { + "name": "junior_automations_creator_identity_idx", + "columns": [ + { + "expression": "creator_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_run_now_due_idx": { + "name": "junior_automations_run_now_due_idx", + "columns": [ + { + "expression": "run_now_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"run_now_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_next_run_due_idx": { + "name": "junior_automations_next_run_due_idx", + "columns": [ + { + "expression": "next_run_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"next_run_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_match_idx": { + "name": "junior_automations_match_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_automations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_automations", + "tableTo": "junior_identities", + "columnsFrom": [ + "creator_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_automations_kind_check": { + "name": "junior_automations_kind_check", + "value": "\"junior_automations\".\"kind\" in ('scheduled', 'event')" + }, + "junior_automations_status_check": { + "name": "junior_automations_status_check", + "value": "\"junior_automations\".\"status\" in ('active', 'blocked', 'paused', 'completed', 'deleted')" + }, + "junior_automations_record_check": { + "name": "junior_automations_record_check", + "value": "jsonb_typeof(\"junior_automations\".\"record\") = 'object'" + }, + "junior_automations_trigger_check": { + "name": "junior_automations_trigger_check", + "value": "(\"junior_automations\".\"kind\" = 'scheduled' and \"junior_automations\".\"namespace\" is null and \"junior_automations\".\"identifier\" is null) or (\"junior_automations\".\"kind\" = 'event' and \"junior_automations\".\"namespace\" is not null and \"junior_automations\".\"identifier\" is not null and \"junior_automations\".\"next_run_at_ms\" is null and \"junior_automations\".\"run_now_at_ms\" is null and \"junior_automations\".\"status\" <> 'completed')" + } + }, + "isRLSEnabled": false + }, + "public.junior_code_changes": { + "name": "junior_code_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "conversation_ids": { + "name": "conversation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_code_changes_provider_id_idx": { + "name": "junior_code_changes_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_opened_at_idx": { + "name": "junior_code_changes_opened_at_idx", + "columns": [ + { + "expression": "opened_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_merged_at_idx": { + "name": "junior_code_changes_merged_at_idx", + "columns": [ + { + "expression": "merged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_closed_at_idx": { + "name": "junior_code_changes_closed_at_idx", + "columns": [ + { + "expression": "closed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_open_idx": { + "name": "junior_code_changes_open_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_code_changes\".\"state\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_code_changes_repository_id_junior_code_repositories_id_fk": { + "name": "junior_code_changes_repository_id_junior_code_repositories_id_fk", + "tableFrom": "junior_code_changes", + "tableTo": "junior_code_repositories", + "columnsFrom": [ + "repository_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_code_repositories": { + "name": "junior_code_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_code_repositories_provider_id_idx": { + "name": "junior_code_repositories_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_annotations": { + "name": "junior_conversation_annotations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "annotation_json": { + "name": "annotation_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_annotations_conversation_id_fk": { + "name": "junior_conversation_annotations_conversation_id_fk", + "tableFrom": "junior_conversation_annotations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_annotations_pk": { + "name": "junior_conversation_annotations_pk", + "columns": [ + "conversation_id", + "plugin", + "kind", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_bindings": { + "name": "junior_conversation_bindings", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_conversation_id": { + "name": "provider_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_bindings_conversation_idx": { + "name": "junior_conversation_bindings_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_bindings", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_bindings_provider_conversation_pk": { + "name": "junior_conversation_bindings_provider_conversation_pk", + "columns": [ + "provider", + "provider_tenant_id", + "provider_destination_id", + "provider_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_briefs": { + "name": "junior_conversation_briefs", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "through_seq": { + "name": "through_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_usd": { + "name": "cost_usd", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "junior_conversation_briefs_conversation_turn_idx": { + "name": "junior_conversation_briefs_conversation_turn_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_conversation_version_idx": { + "name": "junior_conversation_briefs_conversation_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_search_idx": { + "name": "junior_conversation_briefs_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"search_text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_briefs", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_briefs_conversation_id_version_pk": { + "name": "junior_conversation_briefs_conversation_id_version_pk", + "columns": [ + "conversation_id", + "version" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_events": { + "name": "junior_conversation_events", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "history_version": { + "name": "history_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_events_history_version_idx": { + "name": "junior_conversation_events_history_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "history_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_type_idx": { + "name": "junior_conversation_events_type_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_actor_identity_idx": { + "name": "junior_conversation_events_actor_identity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_message_search_idx": { + "name": "junior_conversation_events_message_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"payload\"->>'text')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'message'", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_conversation_events_idempotency_idx": { + "name": "junior_conversation_events_idempotency_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_events_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_events_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_identities", + "columnsFrom": [ + "actor_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_events_conversation_id_seq_pk": { + "name": "junior_conversation_events_conversation_id_seq_pk", + "columns": [ + "conversation_id", + "seq" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_metrics": { + "name": "junior_conversation_metrics", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_metrics_occurred_at_metric_idx": { + "name": "junior_conversation_metrics_occurred_at_metric_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_metrics", + "tableTo": "junior_conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_metrics_conversation_id_run_id_metric_pk": { + "name": "junior_conversation_metrics_conversation_id_run_id_metric_pk", + "columns": [ + "conversation_id", + "run_id", + "metric" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_participants": { + "name": "junior_conversation_participants", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversation_participants_user_activity_idx": { + "name": "junior_conversation_participants_user_activity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_participants_root_idx": { + "name": "junior_conversation_participants_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_participants_user_id_junior_users_id_fk": { + "name": "junior_conversation_participants_user_id_junior_users_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_conversations", + "columnsFrom": [ + "root_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_participants_user_root_pk": { + "name": "junior_conversation_participants_user_root_pk", + "columns": [ + "user_id", + "root_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "location_json": { + "name": "location_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_conversation_id": { + "name": "forked_from_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_forked_from_idx": { + "name": "junior_conversations_forked_from_idx", + "columns": [ + { + "expression": "forked_from_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_root_idx": { + "name": "junior_conversations_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": [ + "destination_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "actor_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "creator_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": [ + "credential_subject_identity_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "parent_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "forked_from_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": [ + "root_conversation_id" + ], + "columnsTo": [ + "conversation_id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "signed_in_at": { + "name": "signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_location_configurations": { + "name": "junior_location_configurations", + "schema": "", + "columns": { + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "junior_location_configurations_location_id_junior_destinations_id_fk": { + "name": "junior_location_configurations_location_id_junior_destinations_id_fk", + "tableFrom": "junior_location_configurations", + "tableTo": "junior_destinations", + "columnsFrom": [ + "location_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_location_configurations_location_id_key_pk": { + "name": "junior_location_configurations_location_id_key_pk", + "columns": [ + "location_id", + "key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_snapshots": { + "name": "junior_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_hash": { + "name": "profile_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_duration_ms": { + "name": "build_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_started_at": { + "name": "build_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_phase": { + "name": "build_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_sandbox_name": { + "name": "build_sandbox_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_command_id": { + "name": "build_command_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_error": { + "name": "build_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_snapshots_snapshot_id_uidx": { + "name": "junior_snapshots_snapshot_id_uidx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_idx": { + "name": "junior_snapshots_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_profile_status_idx": { + "name": "junior_snapshots_workspace_profile_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_active_build_uidx": { + "name": "junior_snapshots_active_build_uidx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"junior_snapshots\".\"status\" = 'building'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_status_idx": { + "name": "junior_snapshots_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_snapshots_workspace_id_junior_workspaces_id_fk": { + "name": "junior_snapshots_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_snapshots", + "tableTo": "junior_workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_snapshots_status_check": { + "name": "junior_snapshots_status_check", + "value": "\"junior_snapshots\".\"status\" in ('building', 'failed', 'ready')" + }, + "junior_snapshots_build_phase_check": { + "name": "junior_snapshots_build_phase_check", + "value": "\"junior_snapshots\".\"build_phase\" is null or \"junior_snapshots\".\"build_phase\" in ('created', 'dependencies_installed', 'repositories_prepared')" + }, + "junior_snapshots_ready_fields_check": { + "name": "junior_snapshots_ready_fields_check", + "value": "\"junior_snapshots\".\"status\" <> 'ready' or (\"junior_snapshots\".\"snapshot_id\" is not null and \"junior_snapshots\".\"build_duration_ms\" is not null and \"junior_snapshots\".\"generated_at\" is not null)" + }, + "junior_snapshots_build_fields_check": { + "name": "junior_snapshots_build_fields_check", + "value": "\"junior_snapshots\".\"status\" = 'ready' or (\"junior_snapshots\".\"build_started_at\" is not null and \"junior_snapshots\".\"build_phase\" is not null)" + }, + "junior_snapshots_build_duration_check": { + "name": "junior_snapshots_build_duration_check", + "value": "\"junior_snapshots\".\"build_duration_ms\" is null or \"junior_snapshots\".\"build_duration_ms\" >= 0" + }, + "junior_snapshots_size_bytes_check": { + "name": "junior_snapshots_size_bytes_check", + "value": "\"junior_snapshots\".\"size_bytes\" is null or \"junior_snapshots\".\"size_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.junior_stats": { + "name": "junior_stats", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "junior_stats_date_namespace_metric_name_pk": { + "name": "junior_stats_date_namespace_metric_name_pk", + "columns": [ + "date", + "namespace", + "metric", + "name" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspace_repos": { + "name": "junior_workspace_repos", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_workspace_repos_workspace_id_junior_workspaces_id_fk": { + "name": "junior_workspace_repos_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_workspace_repos", + "tableTo": "junior_workspaces", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_workspace_repos_workspace_id_provider_repo_pk": { + "name": "junior_workspace_repos_workspace_id_provider_repo_pk", + "columns": [ + "workspace_id", + "provider", + "repo" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspaces": { + "name": "junior_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_script": { + "name": "setup_script", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_workspaces_name_idx": { + "name": "junior_workspaces_name_idx", + "columns": [ + { + "expression": "name", + "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": {} + } +} \ No newline at end of file diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 4cb48d53b8..0ff1803f66 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -421,6 +421,13 @@ "when": 1792739994091, "tag": "0059_automation_run_version", "breakpoints": true + }, + { + "idx": 60, + "version": "7", + "when": 1792804814768, + "tag": "0060_identity_signed_in_at", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/junior/src/api/slack-sign-in.ts b/packages/junior/src/api/slack-sign-in.ts new file mode 100644 index 0000000000..48c9ce43e1 --- /dev/null +++ b/packages/junior/src/api/slack-sign-in.ts @@ -0,0 +1,74 @@ +/** Own the dashboard-authenticated page that lets Slack users use Junior. */ +import type { User } from "@sentry/junior-plugin-api"; +import { getSqlExecutor } from "@/chat/db"; +import { markSlackIdentitiesSignedIn } from "@/chat/identities/sql"; + +export const SLACK_SIGN_IN_PATH = "/_junior/slack/sign-in"; + +function escapeHtml(value: string): string { + return value + .replaceAll("&", "&") + .replaceAll("<", "<") + .replaceAll(">", ">") + .replaceAll('"', """) + .replaceAll("'", "'"); +} + +function renderPage(status: number, title: string, message: string): Response { + return new Response( + ` + + + + + ${escapeHtml(title)} + + +
+
+

${escapeHtml(title)}

+

${escapeHtml(message)}

+
+
+ +`, + { + status, + headers: { + "cache-control": "no-store", + "content-security-policy": "frame-ancestors 'none'", + "content-type": "text/html; charset=utf-8", + "x-frame-options": "DENY", + }, + }, + ); +} + +/** + * Mark the Slack accounts of one dashboard-authenticated user as signed in. + * + * The dashboard enforces its Google account allowlist before this runs. A + * Slack account matches only when its verified Slack email links it to the + * same user. + */ +export async function handleSlackSignInPage(args: { + agentName: string; + user: User; +}): Promise { + const count = await markSlackIdentitiesSignedIn( + getSqlExecutor(), + args.user.id, + ); + if (count === 0) { + return renderPage( + 403, + "No Slack account matches", + `No Slack account uses ${args.user.email}. Sign in with the account that matches your Slack email.`, + ); + } + return renderPage( + 200, + "You are signed in", + `Return to Slack and send your message to ${args.agentName} again.`, + ); +} diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index 70823abd55..b65621d160 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -26,6 +26,7 @@ import { executeAgentRun } from "@/chat/agent"; import { normalizeSandboxEgressTracePropagationDomains } from "@/chat/sandbox/egress/tracing"; import { getExperimentalFeatures, + isExperimentalFeatureEnabled, setExperimentalFeatures, type ExperimentalFeaturesConfig, } from "@/chat/experimental"; @@ -115,6 +116,7 @@ import { ACP_AUTHORIZATION_PATH, handleAcpAuthorizationPage, } from "@/api/acp/authorization-page"; +import { handleSlackSignInPage, SLACK_SIGN_IN_PATH } from "@/api/slack-sign-in"; import { JUNIOR_VERSION } from "./version"; import { createDashboardRouteRegistrations, @@ -442,6 +444,12 @@ export async function createApp(options?: JuniorAppOptions): Promise { ); setSandboxResourceConfig(options?.sandbox); setExperimentalFeatures(options?.experimental); + if ( + isExperimentalFeatureEnabled("slack-sign-in") && + (!dashboard || dashboard.disabled) + ) { + throw new Error("experimental.slack-sign-in requires the dashboard"); + } setConfigDefaults(options?.configDefaults); warnUnregisteredConfigDefaults(options?.configDefaults); // Start from the defaults so this app cannot inherit an earlier app's @@ -560,6 +568,14 @@ export async function createApp(options?: JuniorAppOptions): Promise { }, ] : []; + if (isExperimentalFeatureEnabled("slack-sign-in")) { + authenticatedRoutes.push({ + handler: (_request, user) => + handleSlackSignInPage({ agentName: botConfig.userName, user }), + method: "GET", + path: SLACK_SIGN_IN_PATH, + }); + } try { validateDashboardRouteOwnership({ authenticatedRoutes, diff --git a/packages/junior/src/chat/dashboard-link.ts b/packages/junior/src/chat/dashboard-link.ts index f6a8f17682..464512d9da 100644 --- a/packages/junior/src/chat/dashboard-link.ts +++ b/packages/junior/src/chat/dashboard-link.ts @@ -73,6 +73,12 @@ function resolveDashboardPath(segmentPath: string): string | undefined { return `${baseURL}${path}`; } +/** Build the absolute URL of one host route when the core dashboard is enabled. */ +export function getDashboardRouteLink(path: string): string | undefined { + if (!dashboardConversationLinkOptions) return undefined; + return `${resolveDashboardBaseURL(dashboardConversationLinkOptions)}${path}`; +} + /** Build the dashboard conversation URL when the core dashboard is enabled. */ export function getDashboardConversationLink( conversationId: string, diff --git a/packages/junior/src/chat/experimental.ts b/packages/junior/src/chat/experimental.ts index dc7d1ef166..81cc8a73c8 100644 --- a/packages/junior/src/chat/experimental.ts +++ b/packages/junior/src/chat/experimental.ts @@ -6,6 +6,7 @@ export const EXPERIMENTAL_FEATURES = [ "operator-tools", "passive-routing", + "slack-sign-in", "subagents", ] as const; diff --git a/packages/junior/src/chat/identities/README.md b/packages/junior/src/chat/identities/README.md index b8c10296b1..37454f3875 100644 --- a/packages/junior/src/chat/identities/README.md +++ b/packages/junior/src/chat/identities/README.md @@ -38,3 +38,15 @@ the account. The person must start the connection from Slack or the web UI. Provider handles and subject IDs always remain identity-scoped. Display names are presentation data and must never be used to link identities or grant authority. + +## Slack Sign-In + +With the experimental `slack-sign-in` feature, a Slack identity must have +`signed_in_at` before Slack ingress accepts its mentions and DMs. The dashboard +page at `/_junior/slack/sign-in` sets it after Google sign-in. + +- The page marks only Slack identities that are already linked to the signed-in + user through a verified email. It never links a new identity, so a forwarded + link cannot sign in another Slack account. +- Ingress saves the Slack identity with its Slack profile email before it sends + the sign-in link, so the page can find the identity on the first sign-in. diff --git a/packages/junior/src/chat/identities/sql.ts b/packages/junior/src/chat/identities/sql.ts index 725022ad35..02635b8119 100644 --- a/packages/junior/src/chat/identities/sql.ts +++ b/packages/junior/src/chat/identities/sql.ts @@ -314,3 +314,53 @@ export async function deleteProviderIdentityForUser( ), ); } + +/** Return whether one Slack identity has completed dashboard sign-in. */ +export async function isSlackIdentitySignedIn( + executor: JuniorSqlDatabase, + slackTeamId: string, + slackUserId: string, +): Promise { + const rows = await executor + .db() + .select({ signedInAt: juniorIdentities.signedInAt }) + .from(juniorIdentities) + .where( + and( + eq(juniorIdentities.kind, "user"), + eq(juniorIdentities.provider, "slack"), + eq(juniorIdentities.providerTenantId, slackTeamId), + eq(juniorIdentities.providerSubjectId, slackUserId), + ), + ) + .limit(1); + return Boolean(rows[0]?.signedInAt); +} + +/** + * Mark the Slack identities of one signed-in user as signed in. + * + * Only identities already linked to the user through a verified email match. + * Sign-in never links a new Slack identity, so a shared sign-in link cannot + * grant access to another Slack account. + */ +export async function markSlackIdentitiesSignedIn( + executor: JuniorSqlDatabase, + userId: string, +): Promise { + const now = new Date(); + const rows = await executor + .db() + .update(juniorIdentities) + .set({ signedInAt: now, updatedAt: now }) + .where( + and( + eq(juniorIdentities.kind, "user"), + eq(juniorIdentities.provider, "slack"), + eq(juniorIdentities.userId, userId), + eq(juniorIdentities.emailVerified, true), + ), + ) + .returning({ id: juniorIdentities.id }); + return rows.length; +} diff --git a/packages/junior/src/chat/ingress/slack-sign-in.ts b/packages/junior/src/chat/ingress/slack-sign-in.ts new file mode 100644 index 0000000000..5cd06336d9 --- /dev/null +++ b/packages/junior/src/chat/ingress/slack-sign-in.ts @@ -0,0 +1,69 @@ +/** Require dashboard sign-in before a Slack user can start a Turn. */ +import { SLACK_SIGN_IN_PATH } from "@/api/slack-sign-in"; +import { botConfig } from "@/chat/config"; +import { getSqlExecutor } from "@/chat/db"; +import { getDashboardRouteLink } from "@/chat/dashboard-link"; +import { isExperimentalFeatureEnabled } from "@/chat/experimental"; +import type { SlackInboundEvent } from "@/chat/ingress/slack-payload"; +import { isSlackIdentitySignedIn, upsertIdentity } from "@/chat/identities/sql"; +import type { SlackInstallationContext } from "@/chat/slack/adapter-context"; +import { getSlackClient, withSlackRetries } from "@/chat/slack/client"; +import { parseSlackUserId } from "@/chat/slack/ids"; +import { formatSlackLink } from "@/chat/slack/mrkdwn"; +import { lookupSlackUserProfile } from "@/chat/slack/users"; + +/** + * Return whether this Slack user may start work. + * + * When the user has not signed in, save the Slack identity with the email from + * the Slack profile, so the sign-in page can match it. Then send the user a + * private sign-in link and drop the message. + */ +export async function hasSlackSignIn(args: { + event: SlackInboundEvent; + installation: SlackInstallationContext; +}): Promise { + if (!isExperimentalFeatureEnabled("slack-sign-in")) return true; + const teamId = args.installation.teamId; + const channelId = args.event.channel; + const userId = parseSlackUserId(args.event.user); + if (!teamId || !channelId || !userId) return false; + const executor = getSqlExecutor(); + if (await isSlackIdentitySignedIn(executor, teamId, userId)) { + return true; + } + + const profile = await lookupSlackUserProfile(userId); + await upsertIdentity(executor, { + kind: "user", + provider: "slack", + providerTenantId: teamId, + providerSubjectId: userId, + ...(profile.real_name ? { displayName: profile.real_name } : undefined), + ...(profile.name ? { handle: profile.name } : undefined), + ...(profile.email + ? { email: profile.email, emailVerified: true } + : undefined), + metadata: { platform: "slack" }, + }); + + const link = getDashboardRouteLink(SLACK_SIGN_IN_PATH); + if (!link) { + throw new Error("Slack sign-in requires the Junior dashboard"); + } + const client = getSlackClient(); + await withSlackRetries( + () => + client.chat.postEphemeral({ + channel: channelId, + user: userId, + text: `${formatSlackLink(link, `Sign in to use ${botConfig.userName}`)}. Sign in once with your work account, then send your message again.`, + ...(args.event.thread_ts + ? { thread_ts: args.event.thread_ts } + : undefined), + }), + 3, + { action: "chat.postEphemeral" }, + ); + return false; +} diff --git a/packages/junior/src/chat/ingress/slack-webhook.ts b/packages/junior/src/chat/ingress/slack-webhook.ts index d2c1a1b4c6..ae767f5be8 100644 --- a/packages/junior/src/chat/ingress/slack-webhook.ts +++ b/packages/junior/src/chat/ingress/slack-webhook.ts @@ -62,6 +62,7 @@ import { withNormalizedThreadId, } from "@/chat/ingress/message-router"; import { isSlackWorkspaceMember } from "@/chat/ingress/workspace-membership"; +import { hasSlackSignIn } from "@/chat/ingress/slack-sign-in"; import { slackMessageAttributes } from "./slack-message-telemetry"; import { getWorkspaceTeamId, @@ -537,6 +538,9 @@ async function routeParsedMessage(args: { return; } + // Passive thread messages are not checked for sign-in yet. + if (route === "mention" && !(await hasSlackSignIn(args))) return; + // Keep non-mention thread messages as Conversation history without waking a // worker when passive routing is off. Later explicit mentions still need them. // Write against the Slack thread id, not a bound mailbox Conversation id, so diff --git a/packages/junior/src/db/schema/identities.ts b/packages/junior/src/db/schema/identities.ts index 7a40695ced..a48ab55e75 100644 --- a/packages/junior/src/db/schema/identities.ts +++ b/packages/junior/src/db/schema/identities.ts @@ -29,6 +29,8 @@ export const juniorIdentities = pgTable( userId: text("user_id").references(() => juniorUsers.id), emailNormalized: text("email_normalized"), emailVerified: boolean("email_verified").notNull().default(false), + /** When the linked user confirmed this identity through dashboard sign-in. */ + signedInAt: timestamptz("signed_in_at"), }, (table) => [ uniqueIndex("junior_identities_provider_subject_uidx").on( diff --git a/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts b/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts index 4b437f2883..03d636506b 100644 --- a/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts +++ b/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts @@ -1,6 +1,10 @@ import { createMemoryState } from "@chat-adapter/state-memory"; import { afterEach, describe, expect, it, vi } from "vitest"; +import { handleSlackSignInPage, SLACK_SIGN_IN_PATH } from "@/api/slack-sign-in"; +import { setDashboardConversationLinkOptions } from "@/chat/dashboard-link"; import { closeDb, getConversationEventStore } from "@/chat/db"; +import { setExperimentalFeatures } from "@/chat/experimental"; +import { resolveViewerUser } from "@/chat/plugins/viewer"; import { JuniorChat } from "@/chat/ingress/junior-chat"; import { runWithWorkspaceTeamId } from "@/chat/ingress/workspace-membership"; import { getConversationWorkState } from "@/chat/task-execution/store"; @@ -15,6 +19,7 @@ import { slackWebhookRequest, } from "../../fixtures/conversation-work"; import { createSlackWebhookTestClient } from "../../fixtures/slack/webhook-client"; +import { SUITE_EXPERIMENTAL } from "../../fixtures/experimental-setup"; import { createTestMessage } from "../../fixtures/slack-harness"; import { slackApiOutbox } from "../../fixtures/slack-api-outbox"; import { usersInfoOk } from "../../fixtures/slack/factories/api"; @@ -255,6 +260,73 @@ describe("Slack webhook auth boundary", () => { await state.disconnect(); }); + it("asks Slack users to sign in before their first Turn", async () => { + setExperimentalFeatures({ ...SUITE_EXPERIMENTAL, "slack-sign-in": true }); + const previousLinks = setDashboardConversationLinkOptions({ + baseURL: "https://junior.example.com", + }); + try { + const state = createMemoryState(); + await state.connect(); + const adapter = createSlackAdapterFixture(); + const queue = createConversationWorkQueueTestAdapter(); + const services = { + getSlackAdapter: () => adapter, + queue, + runtime: createNoopSlackWebhookRuntime(), + state, + }; + let sequence = 1; + const send = async () => + await handleSlackWebhookAndFlush({ + request: slackWebhookRequest( + slackEventsApiEnvelope({ + eventType: "message", + channel: "D123", + user: "U123", + ts: `1712345.${String(sequence++).padStart(6, "0")}`, + threadTs: "1712345.000001", + text: "hello", + }), + ), + services, + }); + + expect((await send()).status).toBe(200); + expect(queue.queuedMessages()).toEqual([]); + const prompt = getCapturedSlackApiCalls("chat.postEphemeral").at(-1); + expect(prompt?.params).toMatchObject({ channel: "D123", user: "U123" }); + expect(String(prompt?.params.text)).toContain( + `https://junior.example.com${SLACK_SIGN_IN_PATH}`, + ); + + // Another Google account cannot sign in this Slack account. + const other = await resolveViewerUser("someone@example.com"); + expect( + (await handleSlackSignInPage({ agentName: "junior", user: other! })) + .status, + ).toBe(403); + expect((await send()).status).toBe(200); + expect(queue.queuedMessages()).toEqual([]); + + // The Google account that matches the Slack email signs in once. + const owner = await resolveViewerUser("testuser@example.com"); + expect( + (await handleSlackSignInPage({ agentName: "junior", user: owner! })) + .status, + ).toBe(200); + const prompts = getCapturedSlackApiCalls("chat.postEphemeral").length; + expect((await send()).status).toBe(200); + expect(queue.queuedMessages()).toHaveLength(1); + expect(getCapturedSlackApiCalls("chat.postEphemeral")).toHaveLength( + prompts, + ); + await state.disconnect(); + } finally { + setDashboardConversationLinkOptions(previousLinks); + } + }); + it.each(["message", "factory"] as const)( "checks membership before SDK routing for a %s", async (mode) => { From 0cfc6588555dcf9fafee9af1d47c430c19f9b030 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:55:44 +0000 Subject: [PATCH 02/10] ref(slack): Use a core Google sign-in route instead of the dashboard Slack sign-in now runs its own Google OAuth flow at /api/sign-in/callback, like plugin OAuth callbacks, so it does not need the dashboard. The state binds one Slack identity. The callback checks the dashboard allowlist and the Slack email before it marks that identity as signed in. Refs #2219 Co-Authored-By: David Cramer --- .../content/docs/reference/config-and-env.md | 19 +- packages/junior/src/api/slack-sign-in.ts | 74 ------ packages/junior/src/app.ts | 39 +-- packages/junior/src/chat/dashboard-link.ts | 6 - packages/junior/src/chat/identities/README.md | 18 +- packages/junior/src/chat/identities/sql.ts | 29 ++- .../junior/src/chat/ingress/slack-sign-in.ts | 40 ++- packages/junior/src/chat/sign-in.ts | 231 ++++++++++++++++++ .../slack/webhook-auth-boundary.test.ts | 144 ++++++----- 9 files changed, 395 insertions(+), 205 deletions(-) delete mode 100644 packages/junior/src/api/slack-sign-in.ts create mode 100644 packages/junior/src/chat/sign-in.ts diff --git a/packages/docs/src/content/docs/reference/config-and-env.md b/packages/docs/src/content/docs/reference/config-and-env.md index 61b3665325..f1c7b5c3d9 100644 --- a/packages/docs/src/content/docs/reference/config-and-env.md +++ b/packages/docs/src/content/docs/reference/config-and-env.md @@ -168,8 +168,8 @@ const app = await createApp({ // database directly and skip Conversation privacy checks. Enable them only // on isolated deployments, such as Previews behind deployment protection. "operator-tools": true, - // Ask each Slack user to sign in to the dashboard once before Junior - // replies to their mentions and DMs. Requires the dashboard. + // Ask each Slack user to sign in with Google once before Junior + // replies to their mentions and DMs. "slack-sign-in": true, }, }); @@ -214,12 +214,15 @@ pre-stable surface. `passive-routing` turns on replies to non-mention messages in threads Junior already joined. Leave it unset in production unless you are testing that path. -`slack-sign-in` makes each Slack user sign in once with the dashboard Google -account before Junior starts work from their mentions or DMs. Until then, -Junior drops the message and sends the user a private sign-in link. The Slack -account matches only when its Slack profile email is the same as the Google -account email, so the dashboard allowlist also controls who can use Junior in -Slack. Passive thread replies are not checked yet. +`slack-sign-in` makes each Slack user sign in once with Google before Junior +starts work from their mentions or DMs. Until then, Junior drops the message and +sends the user a private sign-in link. The dashboard is not required. Sign-in +uses the same Google OAuth client and allowlist as the dashboard: +`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and +`JUNIOR_DASHBOARD_GOOGLE_DOMAINS` or `JUNIOR_DASHBOARD_ALLOWED_EMAILS`. Add +`/api/sign-in/callback` as an authorized redirect URI on the Google +OAuth client. The Google email must match the Slack profile email. Passive +thread replies are not checked yet. `operator-tools` adds `runOperatorSql` to non-public Conversations. The tool runs one SQL statement against the app database and returns up to 200 rows. diff --git a/packages/junior/src/api/slack-sign-in.ts b/packages/junior/src/api/slack-sign-in.ts deleted file mode 100644 index 48c9ce43e1..0000000000 --- a/packages/junior/src/api/slack-sign-in.ts +++ /dev/null @@ -1,74 +0,0 @@ -/** Own the dashboard-authenticated page that lets Slack users use Junior. */ -import type { User } from "@sentry/junior-plugin-api"; -import { getSqlExecutor } from "@/chat/db"; -import { markSlackIdentitiesSignedIn } from "@/chat/identities/sql"; - -export const SLACK_SIGN_IN_PATH = "/_junior/slack/sign-in"; - -function escapeHtml(value: string): string { - return value - .replaceAll("&", "&") - .replaceAll("<", "<") - .replaceAll(">", ">") - .replaceAll('"', """) - .replaceAll("'", "'"); -} - -function renderPage(status: number, title: string, message: string): Response { - return new Response( - ` - - - - - ${escapeHtml(title)} - - -
-
-

${escapeHtml(title)}

-

${escapeHtml(message)}

-
-
- -`, - { - status, - headers: { - "cache-control": "no-store", - "content-security-policy": "frame-ancestors 'none'", - "content-type": "text/html; charset=utf-8", - "x-frame-options": "DENY", - }, - }, - ); -} - -/** - * Mark the Slack accounts of one dashboard-authenticated user as signed in. - * - * The dashboard enforces its Google account allowlist before this runs. A - * Slack account matches only when its verified Slack email links it to the - * same user. - */ -export async function handleSlackSignInPage(args: { - agentName: string; - user: User; -}): Promise { - const count = await markSlackIdentitiesSignedIn( - getSqlExecutor(), - args.user.id, - ); - if (count === 0) { - return renderPage( - 403, - "No Slack account matches", - `No Slack account uses ${args.user.email}. Sign in with the account that matches your Slack email.`, - ); - } - return renderPage( - 200, - "You are signed in", - `Return to Slack and send your message to ${args.agentName} again.`, - ); -} diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index b65621d160..f1eb5d2270 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -116,7 +116,12 @@ import { ACP_AUTHORIZATION_PATH, handleAcpAuthorizationPage, } from "@/api/acp/authorization-page"; -import { handleSlackSignInPage, SLACK_SIGN_IN_PATH } from "@/api/slack-sign-in"; +import { + handleSignInCallback, + resolveSignInAllowlist, + SIGN_IN_CALLBACK_PATH, + type SignInAllowlist, +} from "@/chat/sign-in"; import { JUNIOR_VERSION } from "./version"; import { createDashboardRouteRegistrations, @@ -437,6 +442,7 @@ export async function createApp(options?: JuniorAppOptions): Promise { }), }); let sandboxEgressTracePropagationDomains: string[] = []; + let signInAllowlist: SignInAllowlist | undefined; try { sandboxEgressTracePropagationDomains = normalizeSandboxEgressTracePropagationDomains( @@ -444,11 +450,13 @@ export async function createApp(options?: JuniorAppOptions): Promise { ); setSandboxResourceConfig(options?.sandbox); setExperimentalFeatures(options?.experimental); - if ( - isExperimentalFeatureEnabled("slack-sign-in") && - (!dashboard || dashboard.disabled) - ) { - throw new Error("experimental.slack-sign-in requires the dashboard"); + if (isExperimentalFeatureEnabled("slack-sign-in")) { + signInAllowlist = resolveSignInAllowlist(dashboard); + if (!signInAllowlist.domains.length && !signInAllowlist.emails.length) { + throw new Error( + "experimental.slack-sign-in requires allowed Google domains or emails", + ); + } } setConfigDefaults(options?.configDefaults); warnUnregisteredConfigDefaults(options?.configDefaults); @@ -568,14 +576,6 @@ export async function createApp(options?: JuniorAppOptions): Promise { }, ] : []; - if (isExperimentalFeatureEnabled("slack-sign-in")) { - authenticatedRoutes.push({ - handler: (_request, user) => - handleSlackSignInPage({ agentName: botConfig.userName, user }), - method: "GET", - path: SLACK_SIGN_IN_PATH, - }); - } try { validateDashboardRouteOwnership({ authenticatedRoutes, @@ -644,6 +644,17 @@ export async function createApp(options?: JuniorAppOptions): Promise { }); }); + if (signInAllowlist) { + const allowlist = signInAllowlist; + app.get(SIGN_IN_CALLBACK_PATH, (c) => + handleSignInCallback({ + allowlist, + request: c.req.raw, + state: getStateAdapter(), + }), + ); + } + app.get("/api/internal/sandbox-egress/signals", (c) => { return sandboxEgressSignalsGET(c.req.raw); }); diff --git a/packages/junior/src/chat/dashboard-link.ts b/packages/junior/src/chat/dashboard-link.ts index 464512d9da..f6a8f17682 100644 --- a/packages/junior/src/chat/dashboard-link.ts +++ b/packages/junior/src/chat/dashboard-link.ts @@ -73,12 +73,6 @@ function resolveDashboardPath(segmentPath: string): string | undefined { return `${baseURL}${path}`; } -/** Build the absolute URL of one host route when the core dashboard is enabled. */ -export function getDashboardRouteLink(path: string): string | undefined { - if (!dashboardConversationLinkOptions) return undefined; - return `${resolveDashboardBaseURL(dashboardConversationLinkOptions)}${path}`; -} - /** Build the dashboard conversation URL when the core dashboard is enabled. */ export function getDashboardConversationLink( conversationId: string, diff --git a/packages/junior/src/chat/identities/README.md b/packages/junior/src/chat/identities/README.md index 37454f3875..2b8263c79c 100644 --- a/packages/junior/src/chat/identities/README.md +++ b/packages/junior/src/chat/identities/README.md @@ -42,11 +42,13 @@ authority. ## Slack Sign-In With the experimental `slack-sign-in` feature, a Slack identity must have -`signed_in_at` before Slack ingress accepts its mentions and DMs. The dashboard -page at `/_junior/slack/sign-in` sets it after Google sign-in. - -- The page marks only Slack identities that are already linked to the signed-in - user through a verified email. It never links a new identity, so a forwarded - link cannot sign in another Slack account. -- Ingress saves the Slack identity with its Slack profile email before it sends - the sign-in link, so the page can find the identity on the first sign-in. +`signed_in_at` before Slack ingress accepts its mentions and DMs. +`chat/sign-in.ts` owns the Google sign-in that sets it. It does not need the +dashboard. + +- Ingress saves the Slack identity with its Slack profile email. Then it stores + one sign-in state for that Slack identity and sends a private Google link. +- The callback checks the dashboard allowlist. Then it marks only that Slack + identity, and only when it already links to the user with the Google email. + Sign-in never links a new identity, so a forwarded link cannot sign in + another Slack account. diff --git a/packages/junior/src/chat/identities/sql.ts b/packages/junior/src/chat/identities/sql.ts index 02635b8119..01b16be036 100644 --- a/packages/junior/src/chat/identities/sql.ts +++ b/packages/junior/src/chat/identities/sql.ts @@ -338,16 +338,18 @@ export async function isSlackIdentitySignedIn( } /** - * Mark the Slack identities of one signed-in user as signed in. + * Mark one Slack identity as signed in when its user has this verified email. * - * Only identities already linked to the user through a verified email match. - * Sign-in never links a new Slack identity, so a shared sign-in link cannot - * grant access to another Slack account. + * The Slack identity must already link to the user whose primary email is the + * signed-in email. Sign-in never links a new identity, so a forwarded sign-in + * link cannot sign in another Slack account. Return whether a row matched. */ -export async function markSlackIdentitiesSignedIn( +export async function markSlackIdentitySignedIn( executor: JuniorSqlDatabase, - userId: string, -): Promise { + args: { email: string; slackTeamId: string; slackUserId: string }, +): Promise { + const email = normalizeIdentityEmail(args.email); + if (!email) return false; const now = new Date(); const rows = await executor .db() @@ -357,10 +359,19 @@ export async function markSlackIdentitiesSignedIn( and( eq(juniorIdentities.kind, "user"), eq(juniorIdentities.provider, "slack"), - eq(juniorIdentities.userId, userId), + eq(juniorIdentities.providerTenantId, args.slackTeamId), + eq(juniorIdentities.providerSubjectId, args.slackUserId), eq(juniorIdentities.emailVerified, true), + inArray( + juniorIdentities.userId, + executor + .db() + .select({ id: juniorUsers.id }) + .from(juniorUsers) + .where(eq(juniorUsers.primaryEmailNormalized, email)), + ), ), ) .returning({ id: juniorIdentities.id }); - return rows.length; + return rows.length > 0; } diff --git a/packages/junior/src/chat/ingress/slack-sign-in.ts b/packages/junior/src/chat/ingress/slack-sign-in.ts index 5cd06336d9..afcb361249 100644 --- a/packages/junior/src/chat/ingress/slack-sign-in.ts +++ b/packages/junior/src/chat/ingress/slack-sign-in.ts @@ -1,27 +1,28 @@ -/** Require dashboard sign-in before a Slack user can start a Turn. */ -import { SLACK_SIGN_IN_PATH } from "@/api/slack-sign-in"; +/** Require Google sign-in before a Slack user can start a Turn. */ import { botConfig } from "@/chat/config"; import { getSqlExecutor } from "@/chat/db"; -import { getDashboardRouteLink } from "@/chat/dashboard-link"; import { isExperimentalFeatureEnabled } from "@/chat/experimental"; import type { SlackInboundEvent } from "@/chat/ingress/slack-payload"; import { isSlackIdentitySignedIn, upsertIdentity } from "@/chat/identities/sql"; import type { SlackInstallationContext } from "@/chat/slack/adapter-context"; -import { getSlackClient, withSlackRetries } from "@/chat/slack/client"; import { parseSlackUserId } from "@/chat/slack/ids"; import { formatSlackLink } from "@/chat/slack/mrkdwn"; import { lookupSlackUserProfile } from "@/chat/slack/users"; +import { deliverPrivateMessage } from "@/chat/oauth-flow"; +import { createSlackSignInUrl } from "@/chat/sign-in"; +import type { StateAdapter } from "chat"; /** * Return whether this Slack user may start work. * * When the user has not signed in, save the Slack identity with the email from - * the Slack profile, so the sign-in page can match it. Then send the user a + * the Slack profile, so the sign-in callback can match it. Then send the user a * private sign-in link and drop the message. */ export async function hasSlackSignIn(args: { event: SlackInboundEvent; installation: SlackInstallationContext; + state: StateAdapter; }): Promise { if (!isExperimentalFeatureEnabled("slack-sign-in")) return true; const teamId = args.installation.teamId; @@ -47,23 +48,16 @@ export async function hasSlackSignIn(args: { metadata: { platform: "slack" }, }); - const link = getDashboardRouteLink(SLACK_SIGN_IN_PATH); - if (!link) { - throw new Error("Slack sign-in requires the Junior dashboard"); - } - const client = getSlackClient(); - await withSlackRetries( - () => - client.chat.postEphemeral({ - channel: channelId, - user: userId, - text: `${formatSlackLink(link, `Sign in to use ${botConfig.userName}`)}. Sign in once with your work account, then send your message again.`, - ...(args.event.thread_ts - ? { thread_ts: args.event.thread_ts } - : undefined), - }), - 3, - { action: "chat.postEphemeral" }, - ); + const link = await createSlackSignInUrl({ + slackTeamId: teamId, + slackUserId: userId, + state: args.state, + }); + await deliverPrivateMessage({ + channelId, + ...(args.event.thread_ts ? { threadTs: args.event.thread_ts } : undefined), + userId, + text: `${formatSlackLink(link, `Sign in to use ${botConfig.userName}`)}. Sign in once with your work Google account, then send your message again.`, + }); return false; } diff --git a/packages/junior/src/chat/sign-in.ts b/packages/junior/src/chat/sign-in.ts new file mode 100644 index 0000000000..c66a628b71 --- /dev/null +++ b/packages/junior/src/chat/sign-in.ts @@ -0,0 +1,231 @@ +/** + * Google sign-in for people who use Junior. + * + * Junior owns this OAuth flow, so it works without the dashboard. Slack sends + * a person here the first time they mention Junior. The callback checks the + * Google account against the same allowlist as the dashboard. + */ +import { createHash, randomBytes } from "node:crypto"; +import type { StateAdapter } from "chat"; +import { z } from "zod"; +import { botConfig } from "@/chat/config"; +import { getSqlExecutor } from "@/chat/db"; +import { markSlackIdentitySignedIn } from "@/chat/identities/sql"; +import { resolveBaseUrl } from "@/chat/oauth-flow"; +import { escapeXml } from "@/chat/xml"; +import { htmlCallbackResponse } from "@/handlers/oauth-html"; + +export const SIGN_IN_CALLBACK_PATH = "/api/sign-in/callback"; + +const AUTHORIZE_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; +const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; +const GOOGLE_ISSUERS = new Set([ + "accounts.google.com", + "https://accounts.google.com", +]); +const SIGN_IN_STATE_TTL_MS = 10 * 60 * 1000; + +/** Google accounts that may sign in. Empty lists allow nobody. */ +export interface SignInAllowlist { + domains: string[]; + emails: string[]; +} + +const signInStateSchema = z.object({ + codeVerifier: z.string().min(1), + slackTeamId: z.string().min(1), + slackUserId: z.string().min(1), +}); + +const tokenResponseSchema = z.object({ id_token: z.string().min(1) }); + +const idTokenClaimsSchema = z.object({ + aud: z.string(), + email: z.string(), + email_verified: z.boolean().optional(), + iss: z.string(), +}); + +function stateKey(state: string): string { + return `sign-in:v1:${state}`; +} + +function clientCredentials(): { clientId: string; clientSecret: string } { + const clientId = process.env.GOOGLE_CLIENT_ID?.trim(); + const clientSecret = process.env.GOOGLE_CLIENT_SECRET?.trim(); + if (!clientId || !clientSecret) { + throw new Error( + "Sign-in requires GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET", + ); + } + return { clientId, clientSecret }; +} + +function redirectUri(): string { + const baseUrl = resolveBaseUrl(); + if (!baseUrl) { + throw new Error("Sign-in requires JUNIOR_BASE_URL or a Vercel deployment"); + } + return `${baseUrl}${SIGN_IN_CALLBACK_PATH}`; +} + +/** + * Resolve the sign-in allowlist. Use the same values as the dashboard: its + * options when set, else its environment variables. + */ +export function resolveSignInAllowlist( + options: { allowedEmails?: string[]; allowedGoogleDomains?: string[] } = {}, +): SignInAllowlist { + const list = (values: string[] | undefined, name: string) => + (values ?? (process.env[name] ?? "").split(",")) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + return { + domains: list( + options.allowedGoogleDomains, + "JUNIOR_DASHBOARD_GOOGLE_DOMAINS", + ), + emails: list(options.allowedEmails, "JUNIOR_DASHBOARD_ALLOWED_EMAILS"), + }; +} + +function isAllowed(email: string, allowlist: SignInAllowlist): boolean { + const domain = email.slice(email.lastIndexOf("@") + 1); + return allowlist.emails.includes(email) || allowlist.domains.includes(domain); +} + +/** Start one Google sign-in for a Slack user and return the consent URL. */ +export async function createSlackSignInUrl(args: { + slackTeamId: string; + slackUserId: string; + state: StateAdapter; +}): Promise { + const { clientId } = clientCredentials(); + const state = randomBytes(24).toString("base64url"); + const codeVerifier = randomBytes(32).toString("base64url"); + await args.state.set( + stateKey(state), + { + codeVerifier, + slackTeamId: args.slackTeamId, + slackUserId: args.slackUserId, + } satisfies z.input, + SIGN_IN_STATE_TTL_MS, + ); + const url = new URL(AUTHORIZE_ENDPOINT); + url.search = new URLSearchParams({ + client_id: clientId, + code_challenge: createHash("sha256") + .update(codeVerifier) + .digest("base64url"), + code_challenge_method: "S256", + prompt: "select_account", + redirect_uri: redirectUri(), + response_type: "code", + scope: "openid email", + state, + }).toString(); + return url.toString(); +} + +function page(title: string, message: string, status: number): Response { + return htmlCallbackResponse(escapeXml(title), escapeXml(message), status); +} + +// The token response comes straight from Google over TLS, so the identity +// token claims are trusted without a signature check (OpenID Connect Core +// 3.1.3.7). +async function verifiedGoogleEmail( + code: string, + codeVerifier: string, +): Promise { + const { clientId, clientSecret } = clientCredentials(); + const response = await fetch(TOKEN_ENDPOINT, { + body: new URLSearchParams({ + client_id: clientId, + client_secret: clientSecret, + code, + code_verifier: codeVerifier, + grant_type: "authorization_code", + redirect_uri: redirectUri(), + }), + headers: { "Content-Type": "application/x-www-form-urlencoded" }, + method: "POST", + }); + if (!response.ok) return undefined; + const tokens = tokenResponseSchema.safeParse(await response.json()); + if (!tokens.success) return undefined; + const payload = tokens.data.id_token.split(".")[1] ?? ""; + let claims: z.output; + try { + claims = idTokenClaimsSchema.parse( + JSON.parse(Buffer.from(payload, "base64url").toString("utf8")), + ); + } catch { + return undefined; + } + if ( + !GOOGLE_ISSUERS.has(claims.iss) || + claims.aud !== clientId || + claims.email_verified !== true + ) { + return undefined; + } + return claims.email.trim().toLowerCase(); +} + +/** Finish one Google sign-in and mark the Slack user who started it. */ +export async function handleSignInCallback(args: { + allowlist: SignInAllowlist; + request: Request; + state: StateAdapter; +}): Promise { + const url = new URL(args.request.url); + const stateParam = url.searchParams.get("state"); + const code = url.searchParams.get("code"); + const stored = stateParam + ? signInStateSchema.safeParse(await args.state.get(stateKey(stateParam))) + : undefined; + if (!stateParam || !code || !stored?.success) { + return page( + "Sign-in link expired", + `Mention ${botConfig.userName} in Slack again to get a new link.`, + 400, + ); + } + // One state starts one sign-in. + await args.state.delete(stateKey(stateParam)); + + const email = await verifiedGoogleEmail(code, stored.data.codeVerifier); + if (!email) { + return page( + "Sign-in failed", + "Google did not return a verified account. Try again.", + 400, + ); + } + if (!isAllowed(email, args.allowlist)) { + return page( + "Account not allowed", + `${email} cannot use ${botConfig.userName}. Sign in with your work account.`, + 403, + ); + } + const signedIn = await markSlackIdentitySignedIn(getSqlExecutor(), { + email, + slackTeamId: stored.data.slackTeamId, + slackUserId: stored.data.slackUserId, + }); + if (!signedIn) { + return page( + "Slack account does not match", + `Your Slack email is not ${email}. Sign in with the account that matches your Slack email.`, + 403, + ); + } + return page( + "You are signed in", + "Return to Slack and send your message again.", + 200, + ); +} diff --git a/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts b/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts index 03d636506b..d5fae2c34e 100644 --- a/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts +++ b/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts @@ -1,10 +1,9 @@ import { createMemoryState } from "@chat-adapter/state-memory"; import { afterEach, describe, expect, it, vi } from "vitest"; -import { handleSlackSignInPage, SLACK_SIGN_IN_PATH } from "@/api/slack-sign-in"; -import { setDashboardConversationLinkOptions } from "@/chat/dashboard-link"; +import { http, HttpResponse } from "msw"; import { closeDb, getConversationEventStore } from "@/chat/db"; import { setExperimentalFeatures } from "@/chat/experimental"; -import { resolveViewerUser } from "@/chat/plugins/viewer"; +import { handleSignInCallback } from "@/chat/sign-in"; import { JuniorChat } from "@/chat/ingress/junior-chat"; import { runWithWorkspaceTeamId } from "@/chat/ingress/workspace-membership"; import { getConversationWorkState } from "@/chat/task-execution/store"; @@ -29,6 +28,7 @@ import { queueSlackApiResponse, queueSlackApiError, } from "../../msw/handlers/slack-api"; +import { mswServer } from "../../msw/server"; const SIGNING_SECRET = "test-signing-secret"; @@ -260,71 +260,89 @@ describe("Slack webhook auth boundary", () => { await state.disconnect(); }); - it("asks Slack users to sign in before their first Turn", async () => { + it("asks Slack users to sign in with Google before their first Turn", async () => { setExperimentalFeatures({ ...SUITE_EXPERIMENTAL, "slack-sign-in": true }); - const previousLinks = setDashboardConversationLinkOptions({ - baseURL: "https://junior.example.com", - }); - try { - const state = createMemoryState(); - await state.connect(); - const adapter = createSlackAdapterFixture(); - const queue = createConversationWorkQueueTestAdapter(); - const services = { - getSlackAdapter: () => adapter, - queue, - runtime: createNoopSlackWebhookRuntime(), - state, - }; - let sequence = 1; - const send = async () => - await handleSlackWebhookAndFlush({ - request: slackWebhookRequest( - slackEventsApiEnvelope({ - eventType: "message", - channel: "D123", - user: "U123", - ts: `1712345.${String(sequence++).padStart(6, "0")}`, - threadTs: "1712345.000001", - text: "hello", - }), - ), - services, - }); - + vi.stubEnv("GOOGLE_CLIENT_ID", "google-client"); + vi.stubEnv("GOOGLE_CLIENT_SECRET", "google-secret"); + vi.stubEnv("JUNIOR_BASE_URL", "https://junior.example.com"); + let googleEmail = ""; + mswServer.use( + http.post("https://oauth2.googleapis.com/token", () => { + const claims = { + aud: "google-client", + email: googleEmail, + email_verified: true, + iss: "https://accounts.google.com", + }; + const payload = Buffer.from(JSON.stringify(claims)).toString( + "base64url", + ); + return HttpResponse.json({ id_token: `e30.${payload}.sig` }); + }), + ); + const state = createMemoryState(); + await state.connect(); + const adapter = createSlackAdapterFixture(); + const queue = createConversationWorkQueueTestAdapter(); + const services = { + getSlackAdapter: () => adapter, + queue, + runtime: createNoopSlackWebhookRuntime(), + state, + }; + let sequence = 1; + const send = async () => + await handleSlackWebhookAndFlush({ + request: slackWebhookRequest( + slackEventsApiEnvelope({ + eventType: "message", + channel: "D123", + user: "U123", + ts: `1712345.${String(sequence++).padStart(6, "0")}`, + threadTs: "1712345.000001", + text: "hello", + }), + ), + services, + }); + // Each blocked message sends a new private link. Finish it as one account. + const signInAs = async (email: string) => { expect((await send()).status).toBe(200); - expect(queue.queuedMessages()).toEqual([]); - const prompt = getCapturedSlackApiCalls("chat.postEphemeral").at(-1); - expect(prompt?.params).toMatchObject({ channel: "D123", user: "U123" }); - expect(String(prompt?.params.text)).toContain( - `https://junior.example.com${SLACK_SIGN_IN_PATH}`, + const prompt = getCapturedSlackApiCalls("chat.postMessage").at(-1); + expect(prompt?.params).toMatchObject({ channel: "D123" }); + const link = new URL( + /<(https:\/\/accounts\.google\.com[^|>]+)/ + .exec(String(prompt?.params.text))![1]! + .replaceAll("&", "&"), + ); + expect(link.searchParams.get("redirect_uri")).toBe( + "https://junior.example.com/api/sign-in/callback", + ); + googleEmail = email; + const callback = new URL( + "https://junior.example.com/api/sign-in/callback", ); + callback.searchParams.set("state", link.searchParams.get("state")!); + callback.searchParams.set("code", "google-code"); + return await handleSignInCallback({ + allowlist: { domains: ["example.com"], emails: [] }, + request: new Request(callback), + state, + }); + }; - // Another Google account cannot sign in this Slack account. - const other = await resolveViewerUser("someone@example.com"); - expect( - (await handleSlackSignInPage({ agentName: "junior", user: other! })) - .status, - ).toBe(403); - expect((await send()).status).toBe(200); - expect(queue.queuedMessages()).toEqual([]); + // Accounts outside the allowlist, or another person's account, fail. + expect((await signInAs("testuser@contractor.dev")).status).toBe(403); + expect((await signInAs("someone@example.com")).status).toBe(403); + expect(queue.queuedMessages()).toEqual([]); - // The Google account that matches the Slack email signs in once. - const owner = await resolveViewerUser("testuser@example.com"); - expect( - (await handleSlackSignInPage({ agentName: "junior", user: owner! })) - .status, - ).toBe(200); - const prompts = getCapturedSlackApiCalls("chat.postEphemeral").length; - expect((await send()).status).toBe(200); - expect(queue.queuedMessages()).toHaveLength(1); - expect(getCapturedSlackApiCalls("chat.postEphemeral")).toHaveLength( - prompts, - ); - await state.disconnect(); - } finally { - setDashboardConversationLinkOptions(previousLinks); - } + // The account that matches the Slack email signs in once. + expect((await signInAs("testuser@example.com")).status).toBe(200); + const prompts = getCapturedSlackApiCalls("chat.postMessage").length; + expect((await send()).status).toBe(200); + expect(queue.queuedMessages()).toHaveLength(1); + expect(getCapturedSlackApiCalls("chat.postMessage")).toHaveLength(prompts); + await state.disconnect(); }); it.each(["message", "factory"] as const)( From 7db64cd89f351b630290210b986e6bfbf1a8755c Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sat, 24 Oct 2026 01:59:53 +0000 Subject: [PATCH 03/10] ref(slack): Gate Slack use on a linked Google identity Drop the signed_in_at column and migration. Sign-in now links a verified Google identity to the user of the Slack identity that started it. Slack ingress checks that the Slack identity's user has that Google identity, so other features can reuse it. Refs #2219 Co-Authored-By: David Cramer --- .../content/docs/reference/config-and-env.md | 3 +- .../migrations/0060_identity_signed_in_at.sql | 1 - .../junior/migrations/meta/0060_snapshot.json | 3625 ----------------- packages/junior/migrations/meta/_journal.json | 7 - packages/junior/src/chat/identities/README.md | 15 +- packages/junior/src/chat/identities/sql.ts | 49 +- .../junior/src/chat/ingress/slack-sign-in.ts | 25 +- packages/junior/src/chat/sign-in.ts | 52 +- packages/junior/src/db/schema/identities.ts | 2 - .../slack/webhook-auth-boundary.test.ts | 3 +- 10 files changed, 88 insertions(+), 3694 deletions(-) delete mode 100644 packages/junior/migrations/0060_identity_signed_in_at.sql delete mode 100644 packages/junior/migrations/meta/0060_snapshot.json diff --git a/packages/docs/src/content/docs/reference/config-and-env.md b/packages/docs/src/content/docs/reference/config-and-env.md index f1c7b5c3d9..3dff949035 100644 --- a/packages/docs/src/content/docs/reference/config-and-env.md +++ b/packages/docs/src/content/docs/reference/config-and-env.md @@ -221,7 +221,8 @@ uses the same Google OAuth client and allowlist as the dashboard: `GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and `JUNIOR_DASHBOARD_GOOGLE_DOMAINS` or `JUNIOR_DASHBOARD_ALLOWED_EMAILS`. Add `/api/sign-in/callback` as an authorized redirect URI on the Google -OAuth client. The Google email must match the Slack profile email. Passive +OAuth client. The Google email must match the Slack profile email. Junior +links the Google identity to the user, so the check passes later. Passive thread replies are not checked yet. `operator-tools` adds `runOperatorSql` to non-public Conversations. The tool diff --git a/packages/junior/migrations/0060_identity_signed_in_at.sql b/packages/junior/migrations/0060_identity_signed_in_at.sql deleted file mode 100644 index 782a9bfcd0..0000000000 --- a/packages/junior/migrations/0060_identity_signed_in_at.sql +++ /dev/null @@ -1 +0,0 @@ -ALTER TABLE "junior_identities" ADD COLUMN "signed_in_at" timestamp with time zone; \ No newline at end of file diff --git a/packages/junior/migrations/meta/0060_snapshot.json b/packages/junior/migrations/meta/0060_snapshot.json deleted file mode 100644 index 62aedb74fb..0000000000 --- a/packages/junior/migrations/meta/0060_snapshot.json +++ /dev/null @@ -1,3625 +0,0 @@ -{ - "id": "f0350ff0-39ec-4d4d-9db6-10336f76077d", - "prevId": "6936b8a8-1c94-4be1-a259-10512747eb34", - "version": "7", - "dialect": "postgresql", - "tables": { - "public.junior_agent_bindings": { - "name": "junior_agent_bindings", - "schema": "", - "columns": { - "parent_conversation_id": { - "name": "parent_conversation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "child_conversation_id": { - "name": "child_conversation_id", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "junior_agent_bindings_child_idx": { - "name": "junior_agent_bindings_child_idx", - "columns": [ - { - "expression": "child_conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { - "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", - "tableFrom": "junior_agent_bindings", - "tableTo": "junior_conversations", - "columnsFrom": [ - "parent_conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { - "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", - "tableFrom": "junior_agent_bindings", - "tableTo": "junior_conversations", - "columnsFrom": [ - "child_conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "junior_agent_bindings_parent_conversation_id_name_pk": { - "name": "junior_agent_bindings_parent_conversation_id_name_pk", - "columns": [ - "parent_conversation_id", - "name" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_agent_invocations": { - "name": "junior_agent_invocations", - "schema": "", - "columns": { - "invocation_id": { - "name": "invocation_id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "parent_conversation_id": { - "name": "parent_conversation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "child_conversation_id": { - "name": "child_conversation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "agent_name": { - "name": "agent_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "input": { - "name": "input", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "actor_json": { - "name": "actor_json", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "credential_context_json": { - "name": "credential_context_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "source_json": { - "name": "source_json", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "destination_json": { - "name": "destination_json", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "destination_visibility": { - "name": "destination_visibility", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "model_profile": { - "name": "model_profile", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "reasoning_level": { - "name": "reasoning_level", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "parent_tool_call_id": { - "name": "parent_tool_call_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "mailbox_status": { - "name": "mailbox_status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "parent_mailbox_status": { - "name": "parent_mailbox_status", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "result": { - "name": "result", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "error_message": { - "name": "error_message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "terminal_at": { - "name": "terminal_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "junior_agent_invocations_child_idx": { - "name": "junior_agent_invocations_child_idx", - "columns": [ - { - "expression": "child_conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_agent_invocations_mailbox_idx": { - "name": "junior_agent_invocations_mailbox_idx", - "columns": [ - { - "expression": "mailbox_status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_agent_invocations_parent_mailbox_idx": { - "name": "junior_agent_invocations_parent_mailbox_idx", - "columns": [ - { - "expression": "parent_mailbox_status", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "terminal_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { - "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", - "tableFrom": "junior_agent_invocations", - "tableTo": "junior_conversations", - "columnsFrom": [ - "parent_conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { - "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", - "tableFrom": "junior_agent_invocations", - "tableTo": "junior_conversations", - "columnsFrom": [ - "child_conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_api_tokens": { - "name": "junior_api_tokens", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "owner_email_normalized": { - "name": "owner_email_normalized", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_hash": { - "name": "token_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "token_suffix": { - "name": "token_suffix", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "expires_at": { - "name": "expires_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "last_used_at": { - "name": "last_used_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "revoked_at": { - "name": "revoked_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "junior_api_tokens_token_hash_uidx": { - "name": "junior_api_tokens_token_hash_uidx", - "columns": [ - { - "expression": "token_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_api_tokens_owner_email_idx": { - "name": "junior_api_tokens_owner_email_idx", - "columns": [ - { - "expression": "owner_email_normalized", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_artifacts": { - "name": "junior_artifacts", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "conversation_id": { - "name": "conversation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "sha256": { - "name": "sha256", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "ext": { - "name": "ext", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_key": { - "name": "storage_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "content_type": { - "name": "content_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "bytes": { - "name": "bytes", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "public": { - "name": "public", - "type": "boolean", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "delete_requested_at": { - "name": "delete_requested_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "junior_artifacts_conversation_sha_uidx": { - "name": "junior_artifacts_conversation_sha_uidx", - "columns": [ - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "sha256", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_artifacts_gc_idx": { - "name": "junior_artifacts_gc_idx", - "columns": [ - { - "expression": "delete_requested_at", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_artifacts_conversation_idx": { - "name": "junior_artifacts_conversation_idx", - "columns": [ - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_attachments": { - "name": "junior_attachments", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "conversation_id": { - "name": "conversation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_provider": { - "name": "storage_provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "storage_key": { - "name": "storage_key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "filename": { - "name": "filename", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "content_type": { - "name": "content_type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "bytes": { - "name": "bytes", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "sha256": { - "name": "sha256", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "vision_summary": { - "name": "vision_summary", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "delete_requested_at": { - "name": "delete_requested_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "junior_attachments_conversation_idx": { - "name": "junior_attachments_conversation_idx", - "columns": [ - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_attachments_provider_idx": { - "name": "junior_attachments_provider_idx", - "columns": [ - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_attachments_gc_idx": { - "name": "junior_attachments_gc_idx", - "columns": [ - { - "expression": "storage_provider", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "delete_requested_at", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "junior_attachments_conversation_id_junior_conversations_conversation_id_fk": { - "name": "junior_attachments_conversation_id_junior_conversations_conversation_id_fk", - "tableFrom": "junior_attachments", - "tableTo": "junior_conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_automation_runs": { - "name": "junior_automation_runs", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "automation_id": { - "name": "automation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "conversation_id": { - "name": "conversation_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "automation_version": { - "name": "automation_version", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "scheduled_for_ms": { - "name": "scheduled_for_ms", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "attempt": { - "name": "attempt", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "claimed_at_ms": { - "name": "claimed_at_ms", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "dispatch_id": { - "name": "dispatch_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "started_at_ms": { - "name": "started_at_ms", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "completed_at_ms": { - "name": "completed_at_ms", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "error_message": { - "name": "error_message", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "result_message_ts": { - "name": "result_message_ts", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "junior_automation_runs_automation_idx": { - "name": "junior_automation_runs_automation_idx", - "columns": [ - { - "expression": "automation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "completed_at_ms", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_automation_runs_completed_idx": { - "name": "junior_automation_runs_completed_idx", - "columns": [ - { - "expression": "completed_at_ms", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_automation_runs_incomplete_idx": { - "name": "junior_automation_runs_incomplete_idx", - "columns": [ - { - "expression": "automation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "scheduled_for_ms", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"junior_automation_runs\".\"status\" in ('pending', 'running')", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "junior_automation_runs_automation_id_junior_automations_id_fk": { - "name": "junior_automation_runs_automation_id_junior_automations_id_fk", - "tableFrom": "junior_automation_runs", - "tableTo": "junior_automations", - "columnsFrom": [ - "automation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - }, - "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk": { - "name": "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk", - "tableFrom": "junior_automation_runs", - "tableTo": "junior_conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "junior_automation_runs_automation_version_fk": { - "name": "junior_automation_runs_automation_version_fk", - "tableFrom": "junior_automation_runs", - "tableTo": "junior_automation_versions", - "columnsFrom": [ - "automation_id", - "automation_version" - ], - "columnsTo": [ - "automation_id", - "version" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": { - "junior_automation_runs_conversation_id_unique": { - "name": "junior_automation_runs_conversation_id_unique", - "nullsNotDistinct": false, - "columns": [ - "conversation_id" - ] - } - }, - "policies": {}, - "checkConstraints": { - "junior_automation_runs_status_check": { - "name": "junior_automation_runs_status_check", - "value": "\"junior_automation_runs\".\"status\" in ('pending', 'running', 'completed', 'failed', 'blocked', 'skipped')" - } - }, - "isRLSEnabled": false - }, - "public.junior_automation_versions": { - "name": "junior_automation_versions", - "schema": "", - "columns": { - "automation_id": { - "name": "automation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "created_at_ms": { - "name": "created_at_ms", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "edited_by": { - "name": "edited_by", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "definition": { - "name": "definition", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "junior_automation_versions_automation_id_fk": { - "name": "junior_automation_versions_automation_id_fk", - "tableFrom": "junior_automation_versions", - "tableTo": "junior_automations", - "columnsFrom": [ - "automation_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "junior_automation_versions_pk": { - "name": "junior_automation_versions_pk", - "columns": [ - "automation_id", - "version" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_automations": { - "name": "junior_automations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "team_id": { - "name": "team_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "channel_id": { - "name": "channel_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "creator_slack_user_id": { - "name": "creator_slack_user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "creator_identity_id": { - "name": "creator_identity_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status_reason": { - "name": "status_reason", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "instruction": { - "name": "instruction", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at_ms": { - "name": "created_at_ms", - "type": "bigint", - "primaryKey": false, - "notNull": true - }, - "next_run_at_ms": { - "name": "next_run_at_ms", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "run_now_at_ms": { - "name": "run_now_at_ms", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "identifier": { - "name": "identifier", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "record": { - "name": "record", - "type": "jsonb", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "junior_automations_team_idx": { - "name": "junior_automations_team_idx", - "columns": [ - { - "expression": "team_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at_ms", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"junior_automations\".\"status\" <> 'deleted'", - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_automations_creator_identity_idx": { - "name": "junior_automations_creator_identity_idx", - "columns": [ - { - "expression": "creator_identity_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at_ms", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_automations_run_now_due_idx": { - "name": "junior_automations_run_now_due_idx", - "columns": [ - { - "expression": "run_now_at_ms", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at_ms", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"run_now_at_ms\" IS NOT NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_automations_next_run_due_idx": { - "name": "junior_automations_next_run_due_idx", - "columns": [ - { - "expression": "next_run_at_ms", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at_ms", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"next_run_at_ms\" IS NOT NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_automations_match_idx": { - "name": "junior_automations_match_idx", - "columns": [ - { - "expression": "namespace", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "identifier", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "created_at_ms", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"junior_automations\".\"status\" <> 'deleted'", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "junior_automations_creator_identity_id_junior_identities_id_fk": { - "name": "junior_automations_creator_identity_id_junior_identities_id_fk", - "tableFrom": "junior_automations", - "tableTo": "junior_identities", - "columnsFrom": [ - "creator_identity_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "junior_automations_kind_check": { - "name": "junior_automations_kind_check", - "value": "\"junior_automations\".\"kind\" in ('scheduled', 'event')" - }, - "junior_automations_status_check": { - "name": "junior_automations_status_check", - "value": "\"junior_automations\".\"status\" in ('active', 'blocked', 'paused', 'completed', 'deleted')" - }, - "junior_automations_record_check": { - "name": "junior_automations_record_check", - "value": "jsonb_typeof(\"junior_automations\".\"record\") = 'object'" - }, - "junior_automations_trigger_check": { - "name": "junior_automations_trigger_check", - "value": "(\"junior_automations\".\"kind\" = 'scheduled' and \"junior_automations\".\"namespace\" is null and \"junior_automations\".\"identifier\" is null) or (\"junior_automations\".\"kind\" = 'event' and \"junior_automations\".\"namespace\" is not null and \"junior_automations\".\"identifier\" is not null and \"junior_automations\".\"next_run_at_ms\" is null and \"junior_automations\".\"run_now_at_ms\" is null and \"junior_automations\".\"status\" <> 'completed')" - } - }, - "isRLSEnabled": false - }, - "public.junior_code_changes": { - "name": "junior_code_changes", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true - }, - "closed_at": { - "name": "closed_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "conversation_ids": { - "name": "conversation_ids", - "type": "text[]", - "primaryKey": false, - "notNull": true, - "default": "ARRAY[]::text[]" - }, - "merged_at": { - "name": "merged_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "number": { - "name": "number", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "opened_at": { - "name": "opened_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "repository_id": { - "name": "repository_id", - "type": "uuid", - "primaryKey": false, - "notNull": true - }, - "state": { - "name": "state", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "junior_code_changes_provider_id_idx": { - "name": "junior_code_changes_provider_id_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_code_changes_opened_at_idx": { - "name": "junior_code_changes_opened_at_idx", - "columns": [ - { - "expression": "opened_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_code_changes_merged_at_idx": { - "name": "junior_code_changes_merged_at_idx", - "columns": [ - { - "expression": "merged_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_code_changes_closed_at_idx": { - "name": "junior_code_changes_closed_at_idx", - "columns": [ - { - "expression": "closed_at", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_code_changes_open_idx": { - "name": "junior_code_changes_open_idx", - "columns": [ - { - "expression": "id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"junior_code_changes\".\"state\" = 'open'", - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "junior_code_changes_repository_id_junior_code_repositories_id_fk": { - "name": "junior_code_changes_repository_id_junior_code_repositories_id_fk", - "tableFrom": "junior_code_changes", - "tableTo": "junior_code_repositories", - "columnsFrom": [ - "repository_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_code_repositories": { - "name": "junior_code_repositories", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "uuid", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_id": { - "name": "provider_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "url": { - "name": "url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "junior_code_repositories_provider_id_idx": { - "name": "junior_code_repositories_provider_id_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_conversation_annotations": { - "name": "junior_conversation_annotations", - "schema": "", - "columns": { - "conversation_id": { - "name": "conversation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "plugin": { - "name": "plugin", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "annotation_json": { - "name": "annotation_json", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "junior_conversation_annotations_conversation_id_fk": { - "name": "junior_conversation_annotations_conversation_id_fk", - "tableFrom": "junior_conversation_annotations", - "tableTo": "junior_conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "junior_conversation_annotations_pk": { - "name": "junior_conversation_annotations_pk", - "columns": [ - "conversation_id", - "plugin", - "kind", - "key" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_conversation_bindings": { - "name": "junior_conversation_bindings", - "schema": "", - "columns": { - "conversation_id": { - "name": "conversation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_tenant_id": { - "name": "provider_tenant_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "provider_destination_id": { - "name": "provider_destination_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_conversation_id": { - "name": "provider_conversation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "junior_conversation_bindings_conversation_idx": { - "name": "junior_conversation_bindings_conversation_idx", - "columns": [ - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk": { - "name": "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk", - "tableFrom": "junior_conversation_bindings", - "tableTo": "junior_conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "junior_conversation_bindings_provider_conversation_pk": { - "name": "junior_conversation_bindings_provider_conversation_pk", - "columns": [ - "provider", - "provider_tenant_id", - "provider_destination_id", - "provider_conversation_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_conversation_briefs": { - "name": "junior_conversation_briefs", - "schema": "", - "columns": { - "conversation_id": { - "name": "conversation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "version": { - "name": "version", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "turn_id": { - "name": "turn_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "through_seq": { - "name": "through_seq", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "content": { - "name": "content", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "search_text": { - "name": "search_text", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "model_id": { - "name": "model_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "cost_usd": { - "name": "cost_usd", - "type": "double precision", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true, - "default": "now()" - } - }, - "indexes": { - "junior_conversation_briefs_conversation_turn_idx": { - "name": "junior_conversation_briefs_conversation_turn_idx", - "columns": [ - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "turn_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversation_briefs_conversation_version_idx": { - "name": "junior_conversation_briefs_conversation_version_idx", - "columns": [ - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "version", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversation_briefs_search_idx": { - "name": "junior_conversation_briefs_search_idx", - "columns": [ - { - "expression": "to_tsvector('english', \"search_text\")", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "gin", - "with": {} - } - }, - "foreignKeys": { - "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk": { - "name": "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk", - "tableFrom": "junior_conversation_briefs", - "tableTo": "junior_conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "junior_conversation_briefs_conversation_id_version_pk": { - "name": "junior_conversation_briefs_conversation_id_version_pk", - "columns": [ - "conversation_id", - "version" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_conversation_events": { - "name": "junior_conversation_events", - "schema": "", - "columns": { - "conversation_id": { - "name": "conversation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "seq": { - "name": "seq", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "history_version": { - "name": "history_version", - "type": "integer", - "primaryKey": false, - "notNull": true - }, - "schema_version": { - "name": "schema_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 1 - }, - "idempotency_key": { - "name": "idempotency_key", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "type": { - "name": "type", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "payload": { - "name": "payload", - "type": "jsonb", - "primaryKey": false, - "notNull": true - }, - "actor_identity_id": { - "name": "actor_identity_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "junior_conversation_events_history_version_idx": { - "name": "junior_conversation_events_history_version_idx", - "columns": [ - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "history_version", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "seq", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversation_events_type_idx": { - "name": "junior_conversation_events_type_idx", - "columns": [ - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "type", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "seq", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversation_events_actor_identity_idx": { - "name": "junior_conversation_events_actor_identity_idx", - "columns": [ - { - "expression": "actor_identity_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "seq", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversation_events_message_search_idx": { - "name": "junior_conversation_events_message_search_idx", - "columns": [ - { - "expression": "to_tsvector('english', \"payload\"->>'text')", - "asc": true, - "isExpression": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"junior_conversation_events\".\"type\" = 'message'", - "concurrently": false, - "method": "gin", - "with": {} - }, - "junior_conversation_events_idempotency_idx": { - "name": "junior_conversation_events_idempotency_idx", - "columns": [ - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "idempotency_key", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "junior_conversation_events_actor_identity_id_junior_identities_id_fk": { - "name": "junior_conversation_events_actor_identity_id_junior_identities_id_fk", - "tableFrom": "junior_conversation_events", - "tableTo": "junior_identities", - "columnsFrom": [ - "actor_identity_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { - "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", - "tableFrom": "junior_conversation_events", - "tableTo": "junior_conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "junior_conversation_events_conversation_id_seq_pk": { - "name": "junior_conversation_events_conversation_id_seq_pk", - "columns": [ - "conversation_id", - "seq" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_conversation_metrics": { - "name": "junior_conversation_metrics", - "schema": "", - "columns": { - "conversation_id": { - "name": "conversation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "run_id": { - "name": "run_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "metric": { - "name": "metric", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "double precision", - "primaryKey": false, - "notNull": true - }, - "occurred_at": { - "name": "occurred_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "junior_conversation_metrics_occurred_at_metric_idx": { - "name": "junior_conversation_metrics_occurred_at_metric_idx", - "columns": [ - { - "expression": "occurred_at", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "metric", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk": { - "name": "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk", - "tableFrom": "junior_conversation_metrics", - "tableTo": "junior_conversations", - "columnsFrom": [ - "conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "junior_conversation_metrics_conversation_id_run_id_metric_pk": { - "name": "junior_conversation_metrics_conversation_id_run_id_metric_pk", - "columns": [ - "conversation_id", - "run_id", - "metric" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_conversation_participants": { - "name": "junior_conversation_participants", - "schema": "", - "columns": { - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "root_conversation_id": { - "name": "root_conversation_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "last_message_at": { - "name": "last_message_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "archived_at": { - "name": "archived_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "junior_conversation_participants_user_activity_idx": { - "name": "junior_conversation_participants_user_activity_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "last_message_at", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversation_participants_root_idx": { - "name": "junior_conversation_participants_root_idx", - "columns": [ - { - "expression": "root_conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "junior_conversation_participants_user_id_junior_users_id_fk": { - "name": "junior_conversation_participants_user_id_junior_users_id_fk", - "tableFrom": "junior_conversation_participants", - "tableTo": "junior_users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk": { - "name": "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk", - "tableFrom": "junior_conversation_participants", - "tableTo": "junior_conversations", - "columnsFrom": [ - "root_conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "junior_conversation_participants_user_root_pk": { - "name": "junior_conversation_participants_user_root_pk", - "columns": [ - "user_id", - "root_conversation_id" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_conversations": { - "name": "junior_conversations", - "schema": "", - "columns": { - "conversation_id": { - "name": "conversation_id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "schema_version": { - "name": "schema_version", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 1 - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source_json": { - "name": "source_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "location_json": { - "name": "location_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "destination_id": { - "name": "destination_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "destination_json": { - "name": "destination_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "actor_identity_id": { - "name": "actor_identity_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "creator_identity_id": { - "name": "creator_identity_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "credential_subject_identity_id": { - "name": "credential_subject_identity_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "actor_json": { - "name": "actor_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "channel_name": { - "name": "channel_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "title": { - "name": "title", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "last_activity_at": { - "name": "last_activity_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "execution_updated_at": { - "name": "execution_updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "execution_status": { - "name": "execution_status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "run_id": { - "name": "run_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "last_checkpoint_at": { - "name": "last_checkpoint_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "last_enqueued_at": { - "name": "last_enqueued_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "parent_conversation_id": { - "name": "parent_conversation_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "forked_from_conversation_id": { - "name": "forked_from_conversation_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "root_conversation_id": { - "name": "root_conversation_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "transcript_purged_at": { - "name": "transcript_purged_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "stopped_at": { - "name": "stopped_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "duration_ms": { - "name": "duration_ms", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "usage_json": { - "name": "usage_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "execution_duration_ms": { - "name": "execution_duration_ms", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - }, - "execution_usage_json": { - "name": "execution_usage_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "metric_run_id": { - "name": "metric_run_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "archived_at": { - "name": "archived_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "junior_conversations_last_activity_idx": { - "name": "junior_conversations_last_activity_idx", - "columns": [ - { - "expression": "last_activity_at", - "isExpression": false, - "asc": false, - "nulls": "last" - }, - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversations_active_idx": { - "name": "junior_conversations_active_idx", - "columns": [ - { - "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", - "asc": true, - "isExpression": true, - "nulls": "last" - }, - { - "expression": "conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversations_destination_activity_idx": { - "name": "junior_conversations_destination_activity_idx", - "columns": [ - { - "expression": "destination_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "last_activity_at", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversations_actor_activity_idx": { - "name": "junior_conversations_actor_activity_idx", - "columns": [ - { - "expression": "actor_identity_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "last_activity_at", - "isExpression": false, - "asc": false, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversations_parent_idx": { - "name": "junior_conversations_parent_idx", - "columns": [ - { - "expression": "parent_conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversations_forked_from_idx": { - "name": "junior_conversations_forked_from_idx", - "columns": [ - { - "expression": "forked_from_conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_conversations_root_idx": { - "name": "junior_conversations_root_idx", - "columns": [ - { - "expression": "root_conversation_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "junior_conversations_destination_id_junior_destinations_id_fk": { - "name": "junior_conversations_destination_id_junior_destinations_id_fk", - "tableFrom": "junior_conversations", - "tableTo": "junior_destinations", - "columnsFrom": [ - "destination_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "junior_conversations_actor_identity_id_junior_identities_id_fk": { - "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", - "tableFrom": "junior_conversations", - "tableTo": "junior_identities", - "columnsFrom": [ - "actor_identity_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "junior_conversations_creator_identity_id_junior_identities_id_fk": { - "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", - "tableFrom": "junior_conversations", - "tableTo": "junior_identities", - "columnsFrom": [ - "creator_identity_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { - "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", - "tableFrom": "junior_conversations", - "tableTo": "junior_identities", - "columnsFrom": [ - "credential_subject_identity_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { - "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", - "tableFrom": "junior_conversations", - "tableTo": "junior_conversations", - "columnsFrom": [ - "parent_conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "no action", - "onUpdate": "no action" - }, - "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk": { - "name": "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk", - "tableFrom": "junior_conversations", - "tableTo": "junior_conversations", - "columnsFrom": [ - "forked_from_conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "set null", - "onUpdate": "no action" - }, - "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { - "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", - "tableFrom": "junior_conversations", - "tableTo": "junior_conversations", - "columnsFrom": [ - "root_conversation_id" - ], - "columnsTo": [ - "conversation_id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_destinations": { - "name": "junior_destinations", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_tenant_id": { - "name": "provider_tenant_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "provider_destination_id": { - "name": "provider_destination_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "parent_destination_id": { - "name": "parent_destination_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "visibility": { - "name": "visibility", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "'unknown'" - }, - "metadata_json": { - "name": "metadata_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "junior_destinations_provider_destination_uidx": { - "name": "junior_destinations_provider_destination_uidx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_tenant_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_destination_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_destinations_provider_kind_idx": { - "name": "junior_destinations_provider_kind_idx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "kind", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_identities": { - "name": "junior_identities", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "kind": { - "name": "kind", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider_tenant_id": { - "name": "provider_tenant_id", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "provider_subject_id": { - "name": "provider_subject_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "handle": { - "name": "handle", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "email": { - "name": "email", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "avatar_url": { - "name": "avatar_url", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "metadata_json": { - "name": "metadata_json", - "type": "jsonb", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "user_id": { - "name": "user_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "email_normalized": { - "name": "email_normalized", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "email_verified": { - "name": "email_verified", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "signed_in_at": { - "name": "signed_in_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - } - }, - "indexes": { - "junior_identities_provider_subject_uidx": { - "name": "junior_identities_provider_subject_uidx", - "columns": [ - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_tenant_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider_subject_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_identities_user_idx": { - "name": "junior_identities_user_idx", - "columns": [ - { - "expression": "user_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_identities_verified_email_idx": { - "name": "junior_identities_verified_email_idx", - "columns": [ - { - "expression": "email_normalized", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_identities_kind_provider_idx": { - "name": "junior_identities_kind_provider_idx", - "columns": [ - { - "expression": "kind", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "provider", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "junior_identities_user_id_junior_users_id_fk": { - "name": "junior_identities_user_id_junior_users_id_fk", - "tableFrom": "junior_identities", - "tableTo": "junior_users", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "no action", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_location_configurations": { - "name": "junior_location_configurations", - "schema": "", - "columns": { - "location_id": { - "name": "location_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "key": { - "name": "key", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "value": { - "name": "value", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_by": { - "name": "updated_by", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "source": { - "name": "source", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "expires_at": { - "name": "expires_at", - "type": "text", - "primaryKey": false, - "notNull": false - } - }, - "indexes": {}, - "foreignKeys": { - "junior_location_configurations_location_id_junior_destinations_id_fk": { - "name": "junior_location_configurations_location_id_junior_destinations_id_fk", - "tableFrom": "junior_location_configurations", - "tableTo": "junior_destinations", - "columnsFrom": [ - "location_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "junior_location_configurations_location_id_key_pk": { - "name": "junior_location_configurations_location_id_key_pk", - "columns": [ - "location_id", - "key" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_snapshots": { - "name": "junior_snapshots", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "profile_hash": { - "name": "profile_hash", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "status": { - "name": "status", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "snapshot_id": { - "name": "snapshot_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "build_duration_ms": { - "name": "build_duration_ms", - "type": "integer", - "primaryKey": false, - "notNull": false - }, - "size_bytes": { - "name": "size_bytes", - "type": "bigint", - "primaryKey": false, - "notNull": false - }, - "generated_at": { - "name": "generated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "build_started_at": { - "name": "build_started_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": false - }, - "build_phase": { - "name": "build_phase", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "build_sandbox_name": { - "name": "build_sandbox_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "build_command_id": { - "name": "build_command_id", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "build_error": { - "name": "build_error", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "junior_snapshots_snapshot_id_uidx": { - "name": "junior_snapshots_snapshot_id_uidx", - "columns": [ - { - "expression": "snapshot_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_snapshots_workspace_idx": { - "name": "junior_snapshots_workspace_idx", - "columns": [ - { - "expression": "workspace_id", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_snapshots_workspace_profile_status_idx": { - "name": "junior_snapshots_workspace_profile_status_idx", - "columns": [ - { - "expression": "workspace_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "profile_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_snapshots_active_build_uidx": { - "name": "junior_snapshots_active_build_uidx", - "columns": [ - { - "expression": "workspace_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "profile_hash", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "where": "\"junior_snapshots\".\"status\" = 'building'", - "concurrently": false, - "method": "btree", - "with": {} - }, - "junior_snapshots_workspace_status_idx": { - "name": "junior_snapshots_workspace_status_idx", - "columns": [ - { - "expression": "workspace_id", - "isExpression": false, - "asc": true, - "nulls": "last" - }, - { - "expression": "status", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": false, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": { - "junior_snapshots_workspace_id_junior_workspaces_id_fk": { - "name": "junior_snapshots_workspace_id_junior_workspaces_id_fk", - "tableFrom": "junior_snapshots", - "tableTo": "junior_workspaces", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": { - "junior_snapshots_status_check": { - "name": "junior_snapshots_status_check", - "value": "\"junior_snapshots\".\"status\" in ('building', 'failed', 'ready')" - }, - "junior_snapshots_build_phase_check": { - "name": "junior_snapshots_build_phase_check", - "value": "\"junior_snapshots\".\"build_phase\" is null or \"junior_snapshots\".\"build_phase\" in ('created', 'dependencies_installed', 'repositories_prepared')" - }, - "junior_snapshots_ready_fields_check": { - "name": "junior_snapshots_ready_fields_check", - "value": "\"junior_snapshots\".\"status\" <> 'ready' or (\"junior_snapshots\".\"snapshot_id\" is not null and \"junior_snapshots\".\"build_duration_ms\" is not null and \"junior_snapshots\".\"generated_at\" is not null)" - }, - "junior_snapshots_build_fields_check": { - "name": "junior_snapshots_build_fields_check", - "value": "\"junior_snapshots\".\"status\" = 'ready' or (\"junior_snapshots\".\"build_started_at\" is not null and \"junior_snapshots\".\"build_phase\" is not null)" - }, - "junior_snapshots_build_duration_check": { - "name": "junior_snapshots_build_duration_check", - "value": "\"junior_snapshots\".\"build_duration_ms\" is null or \"junior_snapshots\".\"build_duration_ms\" >= 0" - }, - "junior_snapshots_size_bytes_check": { - "name": "junior_snapshots_size_bytes_check", - "value": "\"junior_snapshots\".\"size_bytes\" is null or \"junior_snapshots\".\"size_bytes\" >= 0" - } - }, - "isRLSEnabled": false - }, - "public.junior_stats": { - "name": "junior_stats", - "schema": "", - "columns": { - "date": { - "name": "date", - "type": "date", - "primaryKey": false, - "notNull": true - }, - "namespace": { - "name": "namespace", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "metric": { - "name": "metric", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "count": { - "name": "count", - "type": "integer", - "primaryKey": false, - "notNull": true, - "default": 0 - } - }, - "indexes": {}, - "foreignKeys": {}, - "compositePrimaryKeys": { - "junior_stats_date_namespace_metric_name_pk": { - "name": "junior_stats_date_namespace_metric_name_pk", - "columns": [ - "date", - "namespace", - "metric", - "name" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_users": { - "name": "junior_users", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "primary_email": { - "name": "primary_email", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "primary_email_normalized": { - "name": "primary_email_normalized", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "display_name": { - "name": "display_name", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "timezone": { - "name": "timezone", - "type": "text", - "primaryKey": false, - "notNull": false - }, - "is_admin": { - "name": "is_admin", - "type": "boolean", - "primaryKey": false, - "notNull": true, - "default": false - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "junior_users_primary_email_normalized_uidx": { - "name": "junior_users_primary_email_normalized_uidx", - "columns": [ - { - "expression": "primary_email_normalized", - "isExpression": false, - "asc": true, - "nulls": "last" - } - ], - "isUnique": true, - "concurrently": false, - "method": "btree", - "with": {} - } - }, - "foreignKeys": {}, - "compositePrimaryKeys": {}, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_workspace_repos": { - "name": "junior_workspace_repos", - "schema": "", - "columns": { - "workspace_id": { - "name": "workspace_id", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "provider": { - "name": "provider", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "repo": { - "name": "repo", - "type": "text", - "primaryKey": false, - "notNull": true - } - }, - "indexes": {}, - "foreignKeys": { - "junior_workspace_repos_workspace_id_junior_workspaces_id_fk": { - "name": "junior_workspace_repos_workspace_id_junior_workspaces_id_fk", - "tableFrom": "junior_workspace_repos", - "tableTo": "junior_workspaces", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], - "onDelete": "cascade", - "onUpdate": "no action" - } - }, - "compositePrimaryKeys": { - "junior_workspace_repos_workspace_id_provider_repo_pk": { - "name": "junior_workspace_repos_workspace_id_provider_repo_pk", - "columns": [ - "workspace_id", - "provider", - "repo" - ] - } - }, - "uniqueConstraints": {}, - "policies": {}, - "checkConstraints": {}, - "isRLSEnabled": false - }, - "public.junior_workspaces": { - "name": "junior_workspaces", - "schema": "", - "columns": { - "id": { - "name": "id", - "type": "text", - "primaryKey": true, - "notNull": true - }, - "name": { - "name": "name", - "type": "text", - "primaryKey": false, - "notNull": true - }, - "setup_script": { - "name": "setup_script", - "type": "text", - "primaryKey": false, - "notNull": true, - "default": "''" - }, - "created_at": { - "name": "created_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - }, - "updated_at": { - "name": "updated_at", - "type": "timestamp with time zone", - "primaryKey": false, - "notNull": true - } - }, - "indexes": { - "junior_workspaces_name_idx": { - "name": "junior_workspaces_name_idx", - "columns": [ - { - "expression": "name", - "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": {} - } -} \ No newline at end of file diff --git a/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 0ff1803f66..4cb48d53b8 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -421,13 +421,6 @@ "when": 1792739994091, "tag": "0059_automation_run_version", "breakpoints": true - }, - { - "idx": 60, - "version": "7", - "when": 1792804814768, - "tag": "0060_identity_signed_in_at", - "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/junior/src/chat/identities/README.md b/packages/junior/src/chat/identities/README.md index 2b8263c79c..1da8a64ef8 100644 --- a/packages/junior/src/chat/identities/README.md +++ b/packages/junior/src/chat/identities/README.md @@ -41,14 +41,13 @@ authority. ## Slack Sign-In -With the experimental `slack-sign-in` feature, a Slack identity must have -`signed_in_at` before Slack ingress accepts its mentions and DMs. -`chat/sign-in.ts` owns the Google sign-in that sets it. It does not need the -dashboard. +With the experimental `slack-sign-in` feature, Slack ingress accepts mentions +and DMs only when the user of the Slack identity has a verified Google +identity. `chat/sign-in.ts` owns the Google sign-in that links it. It does not +need the dashboard, and other features can reuse the Google identity. - Ingress saves the Slack identity with its Slack profile email. Then it stores one sign-in state for that Slack identity and sends a private Google link. -- The callback checks the dashboard allowlist. Then it marks only that Slack - identity, and only when it already links to the user with the Google email. - Sign-in never links a new identity, so a forwarded link cannot sign in - another Slack account. +- The callback checks the dashboard allowlist. Then it links the Google + identity only when the user of that Slack identity has the same email. A + forwarded link cannot give another person's Slack account access. diff --git a/packages/junior/src/chat/identities/sql.ts b/packages/junior/src/chat/identities/sql.ts index 01b16be036..779a61cff5 100644 --- a/packages/junior/src/chat/identities/sql.ts +++ b/packages/junior/src/chat/identities/sql.ts @@ -315,15 +315,15 @@ export async function deleteProviderIdentityForUser( ); } -/** Return whether one Slack identity has completed dashboard sign-in. */ -export async function isSlackIdentitySignedIn( +/** Read the user linked to one Slack identity. */ +export async function findSlackIdentityUserId( executor: JuniorSqlDatabase, slackTeamId: string, slackUserId: string, -): Promise { +): Promise { const rows = await executor .db() - .select({ signedInAt: juniorIdentities.signedInAt }) + .select({ userId: juniorIdentities.userId }) .from(juniorIdentities) .where( and( @@ -334,44 +334,35 @@ export async function isSlackIdentitySignedIn( ), ) .limit(1); - return Boolean(rows[0]?.signedInAt); + return rows[0]?.userId ?? undefined; } /** - * Mark one Slack identity as signed in when its user has this verified email. - * - * The Slack identity must already link to the user whose primary email is the - * signed-in email. Sign-in never links a new identity, so a forwarded sign-in - * link cannot sign in another Slack account. Return whether a row matched. + * Return whether the user of one Slack identity has a verified identity from + * another provider. */ -export async function markSlackIdentitySignedIn( +export async function slackUserHasVerifiedIdentity( executor: JuniorSqlDatabase, - args: { email: string; slackTeamId: string; slackUserId: string }, + args: { provider: string; slackTeamId: string; slackUserId: string }, ): Promise { - const email = normalizeIdentityEmail(args.email); - if (!email) return false; - const now = new Date(); + const userId = await findSlackIdentityUserId( + executor, + args.slackTeamId, + args.slackUserId, + ); + if (!userId) return false; const rows = await executor .db() - .update(juniorIdentities) - .set({ signedInAt: now, updatedAt: now }) + .select({ id: juniorIdentities.id }) + .from(juniorIdentities) .where( and( eq(juniorIdentities.kind, "user"), - eq(juniorIdentities.provider, "slack"), - eq(juniorIdentities.providerTenantId, args.slackTeamId), - eq(juniorIdentities.providerSubjectId, args.slackUserId), + eq(juniorIdentities.provider, args.provider), + eq(juniorIdentities.userId, userId), eq(juniorIdentities.emailVerified, true), - inArray( - juniorIdentities.userId, - executor - .db() - .select({ id: juniorUsers.id }) - .from(juniorUsers) - .where(eq(juniorUsers.primaryEmailNormalized, email)), - ), ), ) - .returning({ id: juniorIdentities.id }); + .limit(1); return rows.length > 0; } diff --git a/packages/junior/src/chat/ingress/slack-sign-in.ts b/packages/junior/src/chat/ingress/slack-sign-in.ts index afcb361249..db42d62d84 100644 --- a/packages/junior/src/chat/ingress/slack-sign-in.ts +++ b/packages/junior/src/chat/ingress/slack-sign-in.ts @@ -3,21 +3,28 @@ import { botConfig } from "@/chat/config"; import { getSqlExecutor } from "@/chat/db"; import { isExperimentalFeatureEnabled } from "@/chat/experimental"; import type { SlackInboundEvent } from "@/chat/ingress/slack-payload"; -import { isSlackIdentitySignedIn, upsertIdentity } from "@/chat/identities/sql"; +import { + slackUserHasVerifiedIdentity, + upsertIdentity, +} from "@/chat/identities/sql"; import type { SlackInstallationContext } from "@/chat/slack/adapter-context"; import { parseSlackUserId } from "@/chat/slack/ids"; import { formatSlackLink } from "@/chat/slack/mrkdwn"; import { lookupSlackUserProfile } from "@/chat/slack/users"; import { deliverPrivateMessage } from "@/chat/oauth-flow"; -import { createSlackSignInUrl } from "@/chat/sign-in"; +import { + createSlackSignInUrl, + SIGN_IN_IDENTITY_PROVIDER, +} from "@/chat/sign-in"; import type { StateAdapter } from "chat"; /** * Return whether this Slack user may start work. * - * When the user has not signed in, save the Slack identity with the email from - * the Slack profile, so the sign-in callback can match it. Then send the user a - * private sign-in link and drop the message. + * The user of the Slack identity must have a linked Google identity. When it + * does not, save the Slack identity with the email from the Slack profile, so + * the sign-in callback can match it. Then send the user a private sign-in link + * and drop the message. */ export async function hasSlackSignIn(args: { event: SlackInboundEvent; @@ -30,7 +37,13 @@ export async function hasSlackSignIn(args: { const userId = parseSlackUserId(args.event.user); if (!teamId || !channelId || !userId) return false; const executor = getSqlExecutor(); - if (await isSlackIdentitySignedIn(executor, teamId, userId)) { + if ( + await slackUserHasVerifiedIdentity(executor, { + provider: SIGN_IN_IDENTITY_PROVIDER, + slackTeamId: teamId, + slackUserId: userId, + }) + ) { return true; } diff --git a/packages/junior/src/chat/sign-in.ts b/packages/junior/src/chat/sign-in.ts index c66a628b71..91b9673fb8 100644 --- a/packages/junior/src/chat/sign-in.ts +++ b/packages/junior/src/chat/sign-in.ts @@ -3,20 +3,28 @@ * * Junior owns this OAuth flow, so it works without the dashboard. Slack sends * a person here the first time they mention Junior. The callback checks the - * Google account against the same allowlist as the dashboard. + * Google account against the same allowlist as the dashboard. Then it links + * the Google identity to the person's user, so later checks can reuse it. */ import { createHash, randomBytes } from "node:crypto"; import type { StateAdapter } from "chat"; import { z } from "zod"; import { botConfig } from "@/chat/config"; import { getSqlExecutor } from "@/chat/db"; -import { markSlackIdentitySignedIn } from "@/chat/identities/sql"; +import { + findSlackIdentityUserId, + upsertLinkedIdentity, +} from "@/chat/identities/sql"; +import { findUserByEmailFromSql } from "@/chat/plugins/viewer"; import { resolveBaseUrl } from "@/chat/oauth-flow"; import { escapeXml } from "@/chat/xml"; import { htmlCallbackResponse } from "@/handlers/oauth-html"; export const SIGN_IN_CALLBACK_PATH = "/api/sign-in/callback"; +/** Provider of the identity that a sign-in links to the user. */ +export const SIGN_IN_IDENTITY_PROVIDER = "google"; + const AUTHORIZE_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; const GOOGLE_ISSUERS = new Set([ @@ -44,6 +52,7 @@ const idTokenClaimsSchema = z.object({ email: z.string(), email_verified: z.boolean().optional(), iss: z.string(), + sub: z.string().min(1), }); function stateKey(state: string): string { @@ -135,10 +144,10 @@ function page(title: string, message: string, status: number): Response { // The token response comes straight from Google over TLS, so the identity // token claims are trusted without a signature check (OpenID Connect Core // 3.1.3.7). -async function verifiedGoogleEmail( +async function verifiedGoogleAccount( code: string, codeVerifier: string, -): Promise { +): Promise<{ email: string; subject: string } | undefined> { const { clientId, clientSecret } = clientCredentials(); const response = await fetch(TOKEN_ENDPOINT, { body: new URLSearchParams({ @@ -171,10 +180,13 @@ async function verifiedGoogleEmail( ) { return undefined; } - return claims.email.trim().toLowerCase(); + return { email: claims.email.trim().toLowerCase(), subject: claims.sub }; } -/** Finish one Google sign-in and mark the Slack user who started it. */ +/** + * Finish one Google sign-in and link the Google identity to the user of the + * Slack identity that started it. + */ export async function handleSignInCallback(args: { allowlist: SignInAllowlist; request: Request; @@ -196,14 +208,15 @@ export async function handleSignInCallback(args: { // One state starts one sign-in. await args.state.delete(stateKey(stateParam)); - const email = await verifiedGoogleEmail(code, stored.data.codeVerifier); - if (!email) { + const account = await verifiedGoogleAccount(code, stored.data.codeVerifier); + if (!account) { return page( "Sign-in failed", "Google did not return a verified account. Try again.", 400, ); } + const { email } = account; if (!isAllowed(email, args.allowlist)) { return page( "Account not allowed", @@ -211,18 +224,29 @@ export async function handleSignInCallback(args: { 403, ); } - const signedIn = await markSlackIdentitySignedIn(getSqlExecutor(), { - email, - slackTeamId: stored.data.slackTeamId, - slackUserId: stored.data.slackUserId, - }); - if (!signedIn) { + // Link only when the Slack identity's user owns this Google email. A + // forwarded link then cannot give another person's Slack account access. + const executor = getSqlExecutor(); + const slackUserId = await findSlackIdentityUserId( + executor, + stored.data.slackTeamId, + stored.data.slackUserId, + ); + const user = await findUserByEmailFromSql(executor.db(), email); + if (!slackUserId || user?.id !== slackUserId) { return page( "Slack account does not match", `Your Slack email is not ${email}. Sign in with the account that matches your Slack email.`, 403, ); } + await upsertLinkedIdentity(executor, slackUserId, { + kind: "user", + provider: SIGN_IN_IDENTITY_PROVIDER, + providerSubjectId: account.subject, + email, + emailVerified: true, + }); return page( "You are signed in", "Return to Slack and send your message again.", diff --git a/packages/junior/src/db/schema/identities.ts b/packages/junior/src/db/schema/identities.ts index a48ab55e75..7a40695ced 100644 --- a/packages/junior/src/db/schema/identities.ts +++ b/packages/junior/src/db/schema/identities.ts @@ -29,8 +29,6 @@ export const juniorIdentities = pgTable( userId: text("user_id").references(() => juniorUsers.id), emailNormalized: text("email_normalized"), emailVerified: boolean("email_verified").notNull().default(false), - /** When the linked user confirmed this identity through dashboard sign-in. */ - signedInAt: timestamptz("signed_in_at"), }, (table) => [ uniqueIndex("junior_identities_provider_subject_uidx").on( diff --git a/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts b/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts index d5fae2c34e..fb46af2168 100644 --- a/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts +++ b/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts @@ -273,6 +273,7 @@ describe("Slack webhook auth boundary", () => { email: googleEmail, email_verified: true, iss: "https://accounts.google.com", + sub: `google-${googleEmail}`, }; const payload = Buffer.from(JSON.stringify(claims)).toString( "base64url", @@ -336,7 +337,7 @@ describe("Slack webhook auth boundary", () => { expect((await signInAs("someone@example.com")).status).toBe(403); expect(queue.queuedMessages()).toEqual([]); - // The account that matches the Slack email signs in once. + // The matching account links a Google identity that later checks reuse. expect((await signInAs("testuser@example.com")).status).toBe(200); const prompts = getCapturedSlackApiCalls("chat.postMessage").length; expect((await send()).status).toBe(200); From af1033d16c7c76f6f6ed3e21c5ce377387cba600 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 15:12:20 +0000 Subject: [PATCH 04/10] ref(identities): Make Google a native core identity Core owns the slack and google identity providers. Google sign-in links the google identity with an empty tenant and the OIDC sub as its subject. Plugin OAuth accounts no longer link under a native provider name, so a plugin cannot satisfy the Slack sign-in check by connecting its own Google account. Record the exception in the provider boundaries policy. Co-Authored-By: David Cramer --- packages/junior/src/chat/identities/README.md | 17 ++++++++++ .../junior/src/chat/identities/identity.ts | 17 ++++++++++ .../junior/src/chat/ingress/slack-sign-in.ts | 8 ++--- packages/junior/src/chat/sign-in.ts | 6 ++-- .../junior/src/handlers/oauth-callback.ts | 2 ++ .../tests/fixtures/eval-oauth-account.ts | 4 ++- .../integration/oauth-identity-link.test.ts | 32 +++++++++++++++++++ policies/provider-boundaries.md | 3 ++ 8 files changed, 79 insertions(+), 10 deletions(-) diff --git a/packages/junior/src/chat/identities/README.md b/packages/junior/src/chat/identities/README.md index 1da8a64ef8..2fec9fe6d5 100644 --- a/packages/junior/src/chat/identities/README.md +++ b/packages/junior/src/chat/identities/README.md @@ -16,6 +16,21 @@ timezone to this column. The scheduler and plugin tools use it, through `ctx.users.resolveTimezone()`. See the Timezones section in `chat/scheduled-automations/README.md`. +## Native Identities + +Core owns two identity providers: `slack` and `google`. Other checks trust +these identities, so only core links them. `isNativeIdentityProvider()` in +`identity.ts` lists them. + +- A Slack identity has the Slack team ID as its tenant and the Slack user ID as + its subject. +- A Google identity has an empty tenant and the Google OIDC `sub` as its + subject. Google sign-in in `chat/sign-in.ts` links it, with the verified + Google email. One Google account is one row, so every feature that reads the + `google` identity sees the same account. +- The Google Workspace plugin is not a Google identity. It stores Junior's own + Workspace account in its own table. + ## OAuth Account Links When a person connects a provider account, the OAuth callback links the @@ -32,6 +47,8 @@ the account. The person must start the connection from Slack or the web UI. - A verified email that matches the linked user is saved on the provider identity. Other verified emails are not saved. Verified emails are never stored with the OAuth tokens. +- A plugin OAuth account never links under a native identity provider name. + The tokens are still stored. - Linking is best effort. A failure keeps the stored tokens and logs `oauth.callback.identity_link.failed`. diff --git a/packages/junior/src/chat/identities/identity.ts b/packages/junior/src/chat/identities/identity.ts index a5c416f22b..33ad13abba 100644 --- a/packages/junior/src/chat/identities/identity.ts +++ b/packages/junior/src/chat/identities/identity.ts @@ -1,3 +1,20 @@ +/** + * Provider of the native Google identity. Core links it through Google + * sign-in. Its tenant is empty and its subject is the Google OIDC `sub`. + */ +export const GOOGLE_IDENTITY_PROVIDER = "google"; + +const NATIVE_IDENTITY_PROVIDERS = new Set(["slack", GOOGLE_IDENTITY_PROVIDER]); + +/** + * Return whether only core may link identities for this provider. Plugin + * OAuth accounts must not link under these names, because other checks trust + * them. + */ +export function isNativeIdentityProvider(provider: string): boolean { + return NATIVE_IDENTITY_PROVIDERS.has(provider); +} + export type IdentityKind = "service" | "system" | "user"; export interface IdentityUpsert { diff --git a/packages/junior/src/chat/ingress/slack-sign-in.ts b/packages/junior/src/chat/ingress/slack-sign-in.ts index db42d62d84..eddead32a7 100644 --- a/packages/junior/src/chat/ingress/slack-sign-in.ts +++ b/packages/junior/src/chat/ingress/slack-sign-in.ts @@ -3,6 +3,7 @@ import { botConfig } from "@/chat/config"; import { getSqlExecutor } from "@/chat/db"; import { isExperimentalFeatureEnabled } from "@/chat/experimental"; import type { SlackInboundEvent } from "@/chat/ingress/slack-payload"; +import { GOOGLE_IDENTITY_PROVIDER } from "@/chat/identities/identity"; import { slackUserHasVerifiedIdentity, upsertIdentity, @@ -12,10 +13,7 @@ import { parseSlackUserId } from "@/chat/slack/ids"; import { formatSlackLink } from "@/chat/slack/mrkdwn"; import { lookupSlackUserProfile } from "@/chat/slack/users"; import { deliverPrivateMessage } from "@/chat/oauth-flow"; -import { - createSlackSignInUrl, - SIGN_IN_IDENTITY_PROVIDER, -} from "@/chat/sign-in"; +import { createSlackSignInUrl } from "@/chat/sign-in"; import type { StateAdapter } from "chat"; /** @@ -39,7 +37,7 @@ export async function hasSlackSignIn(args: { const executor = getSqlExecutor(); if ( await slackUserHasVerifiedIdentity(executor, { - provider: SIGN_IN_IDENTITY_PROVIDER, + provider: GOOGLE_IDENTITY_PROVIDER, slackTeamId: teamId, slackUserId: userId, }) diff --git a/packages/junior/src/chat/sign-in.ts b/packages/junior/src/chat/sign-in.ts index 91b9673fb8..3b1ae5abeb 100644 --- a/packages/junior/src/chat/sign-in.ts +++ b/packages/junior/src/chat/sign-in.ts @@ -11,6 +11,7 @@ import type { StateAdapter } from "chat"; import { z } from "zod"; import { botConfig } from "@/chat/config"; import { getSqlExecutor } from "@/chat/db"; +import { GOOGLE_IDENTITY_PROVIDER } from "@/chat/identities/identity"; import { findSlackIdentityUserId, upsertLinkedIdentity, @@ -22,9 +23,6 @@ import { htmlCallbackResponse } from "@/handlers/oauth-html"; export const SIGN_IN_CALLBACK_PATH = "/api/sign-in/callback"; -/** Provider of the identity that a sign-in links to the user. */ -export const SIGN_IN_IDENTITY_PROVIDER = "google"; - const AUTHORIZE_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; const GOOGLE_ISSUERS = new Set([ @@ -242,7 +240,7 @@ export async function handleSignInCallback(args: { } await upsertLinkedIdentity(executor, slackUserId, { kind: "user", - provider: SIGN_IN_IDENTITY_PROVIDER, + provider: GOOGLE_IDENTITY_PROVIDER, providerSubjectId: account.subject, email, emailVerified: true, diff --git a/packages/junior/src/handlers/oauth-callback.ts b/packages/junior/src/handlers/oauth-callback.ts index 6425f26530..77b6956010 100644 --- a/packages/junior/src/handlers/oauth-callback.ts +++ b/packages/junior/src/handlers/oauth-callback.ts @@ -54,6 +54,7 @@ import { upsertLinkedIdentity, } from "@/chat/identities/sql"; import { identityFromActor } from "@/chat/conversations/sql/actor-identity"; +import { isNativeIdentityProvider } from "@/chat/identities/identity"; import type { SlackActor, WebActor } from "@/chat/actor"; import type { PluginProviderAccount } from "@sentry/junior-plugin-api"; import { lookupSlackUserProfile } from "@/chat/slack/users"; @@ -410,6 +411,7 @@ export async function GET( const linkActor = stored.actor; if ( account && + !isNativeIdentityProvider(provider) && (linkActor?.platform === "slack" || linkActor?.platform === "web") ) { await runBestEffort( diff --git a/packages/junior/tests/fixtures/eval-oauth-account.ts b/packages/junior/tests/fixtures/eval-oauth-account.ts index 001bdb3921..3c41384eb2 100644 --- a/packages/junior/tests/fixtures/eval-oauth-account.ts +++ b/packages/junior/tests/fixtures/eval-oauth-account.ts @@ -6,16 +6,18 @@ import { /** * Register the eval OAuth provider with a `resolveOAuthAccount` hook through * the public app API. Its endpoints match the shared eval-oauth MSW handlers. + * Pass `name` to register the same provider under another plugin name. */ export async function createEvalOAuthAccountApp( account: PluginProviderAccount, + name = "eval-oauth", ): Promise { const { createApp, defineJuniorPlugins } = await import("@/app"); await createApp({ plugins: defineJuniorPlugins([ defineJuniorPlugin({ manifest: { - name: "eval-oauth", + name, displayName: "Eval OAuth", description: "Eval OAuth fixture", credentials: { diff --git a/packages/junior/tests/integration/oauth-identity-link.test.ts b/packages/junior/tests/integration/oauth-identity-link.test.ts index 8b08ed33f4..d1f8f72305 100644 --- a/packages/junior/tests/integration/oauth-identity-link.test.ts +++ b/packages/junior/tests/integration/oauth-identity-link.test.ts @@ -74,4 +74,36 @@ describe("oauth identity link", () => { ]), ); }); + + it("does not link a plugin OAuth account as a native Google identity", async () => { + await createEvalOAuthAccountApp( + { id: "google-sub-1", verifiedEmails: ["person@example.com"] }, + "google", + ); + await resolveViewerUser("person@example.com"); + queueSlackApiResponse("users.info", { + body: usersInfoOk({ userId: "U0TEST", email: "person@example.com" }), + }); + await getStateAdapter().set("oauth-state:google-plugin-link", { + actor: { platform: "slack", teamId: "T0TEST", userId: "U0TEST" }, + provider: "google", + userId: "U0TEST", + }); + + const response = await handleOAuthCallback( + new Request( + "https://junior.example.com/api/oauth/callback/google?state=google-plugin-link&code=eval-oauth-code", + ), + "google", + testWaitUntil, + {}, + ); + + expect(response.status).toBe(200); + const providers = await getSqlExecutor() + .db() + .select({ provider: juniorIdentities.provider }) + .from(juniorIdentities); + expect(providers).not.toContainEqual({ provider: "google" }); + }); }); diff --git a/policies/provider-boundaries.md b/policies/provider-boundaries.md index b6d040f617..edb8310aa7 100644 --- a/policies/provider-boundaries.md +++ b/policies/provider-boundaries.md @@ -35,5 +35,8 @@ runtime and the provider-neutral contracts that plugins use. must not perform the provider action itself. - Slack runtime and delivery code remains in core until Slack is a plugin. Keep Slack behavior in Slack-owned modules and out of shared services. +- Core owns the native Slack and Google identities and the Google sign-in that + links a Google identity. Plugins must not link identities under these + provider names. See `packages/junior/src/chat/identities/README.md`. - Existing domain-specific core behavior is debt, not a pattern for new code. Move it to the owning plugin when the touched contract makes that practical. From 4cc29bd595f0b370337ca0b5d6b4389680dc6c8d Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 20:12:59 +0000 Subject: [PATCH 05/10] ref(slack): Check the Google identity in one query Replace the provider-generic identity check with slackUserHasGoogleIdentity, which joins the Slack and Google identities in one query. Name the linked user userId in the sign-in callback, and list every Slack entry point that skips the check in the docs. Co-Authored-By: David Cramer --- .../content/docs/reference/config-and-env.md | 2 +- packages/junior/src/chat/identities/README.md | 2 +- packages/junior/src/chat/identities/sql.ts | 35 ++++++++++--------- .../junior/src/chat/ingress/slack-sign-in.ts | 13 ++----- packages/junior/src/chat/sign-in.ts | 6 ++-- 5 files changed, 26 insertions(+), 32 deletions(-) diff --git a/packages/docs/src/content/docs/reference/config-and-env.md b/packages/docs/src/content/docs/reference/config-and-env.md index 3dff949035..b39c84da40 100644 --- a/packages/docs/src/content/docs/reference/config-and-env.md +++ b/packages/docs/src/content/docs/reference/config-and-env.md @@ -223,7 +223,7 @@ uses the same Google OAuth client and allowlist as the dashboard: `/api/sign-in/callback` as an authorized redirect URI on the Google OAuth client. The Google email must match the Slack profile email. Junior links the Google identity to the user, so the check passes later. Passive -thread replies are not checked yet. +thread replies, slash commands, and Slack actions are not checked yet. `operator-tools` adds `runOperatorSql` to non-public Conversations. The tool runs one SQL statement against the app database and returns up to 200 rows. diff --git a/packages/junior/src/chat/identities/README.md b/packages/junior/src/chat/identities/README.md index 2fec9fe6d5..1d857259d5 100644 --- a/packages/junior/src/chat/identities/README.md +++ b/packages/junior/src/chat/identities/README.md @@ -61,7 +61,7 @@ authority. With the experimental `slack-sign-in` feature, Slack ingress accepts mentions and DMs only when the user of the Slack identity has a verified Google identity. `chat/sign-in.ts` owns the Google sign-in that links it. It does not -need the dashboard, and other features can reuse the Google identity. +need the dashboard. - Ingress saves the Slack identity with its Slack profile email. Then it stores one sign-in state for that Slack identity and sends a private Google link. diff --git a/packages/junior/src/chat/identities/sql.ts b/packages/junior/src/chat/identities/sql.ts index 779a61cff5..0d9396ca48 100644 --- a/packages/junior/src/chat/identities/sql.ts +++ b/packages/junior/src/chat/identities/sql.ts @@ -1,8 +1,10 @@ import { randomUUID } from "node:crypto"; import { and, eq, inArray, sql } from "drizzle-orm"; +import { alias } from "drizzle-orm/pg-core"; import type { JuniorDatabase, JuniorSqlDatabase } from "@/db/db"; import { juniorIdentities, juniorUsers } from "@/db/schema"; import { + GOOGLE_IDENTITY_PROVIDER, normalizeIdentityEmail, type IdentityUpsert, type StoredIdentity, @@ -10,6 +12,8 @@ import { type IdentityRow = typeof juniorIdentities.$inferSelect; +const googleIdentity = alias(juniorIdentities, "google_identity"); + function dateFromMs(ms: number): Date { return new Date(ms); } @@ -337,30 +341,29 @@ export async function findSlackIdentityUserId( return rows[0]?.userId ?? undefined; } -/** - * Return whether the user of one Slack identity has a verified identity from - * another provider. - */ -export async function slackUserHasVerifiedIdentity( +/** Return whether the user of one Slack identity has a verified Google identity. */ +export async function slackUserHasGoogleIdentity( executor: JuniorSqlDatabase, - args: { provider: string; slackTeamId: string; slackUserId: string }, + slackTeamId: string, + slackUserId: string, ): Promise { - const userId = await findSlackIdentityUserId( - executor, - args.slackTeamId, - args.slackUserId, - ); - if (!userId) return false; const rows = await executor .db() - .select({ id: juniorIdentities.id }) + .select({ id: googleIdentity.id }) .from(juniorIdentities) + .innerJoin( + googleIdentity, + eq(googleIdentity.userId, juniorIdentities.userId), + ) .where( and( eq(juniorIdentities.kind, "user"), - eq(juniorIdentities.provider, args.provider), - eq(juniorIdentities.userId, userId), - eq(juniorIdentities.emailVerified, true), + eq(juniorIdentities.provider, "slack"), + eq(juniorIdentities.providerTenantId, slackTeamId), + eq(juniorIdentities.providerSubjectId, slackUserId), + eq(googleIdentity.kind, "user"), + eq(googleIdentity.provider, GOOGLE_IDENTITY_PROVIDER), + eq(googleIdentity.emailVerified, true), ), ) .limit(1); diff --git a/packages/junior/src/chat/ingress/slack-sign-in.ts b/packages/junior/src/chat/ingress/slack-sign-in.ts index eddead32a7..6425d3816e 100644 --- a/packages/junior/src/chat/ingress/slack-sign-in.ts +++ b/packages/junior/src/chat/ingress/slack-sign-in.ts @@ -3,9 +3,8 @@ import { botConfig } from "@/chat/config"; import { getSqlExecutor } from "@/chat/db"; import { isExperimentalFeatureEnabled } from "@/chat/experimental"; import type { SlackInboundEvent } from "@/chat/ingress/slack-payload"; -import { GOOGLE_IDENTITY_PROVIDER } from "@/chat/identities/identity"; import { - slackUserHasVerifiedIdentity, + slackUserHasGoogleIdentity, upsertIdentity, } from "@/chat/identities/sql"; import type { SlackInstallationContext } from "@/chat/slack/adapter-context"; @@ -35,15 +34,7 @@ export async function hasSlackSignIn(args: { const userId = parseSlackUserId(args.event.user); if (!teamId || !channelId || !userId) return false; const executor = getSqlExecutor(); - if ( - await slackUserHasVerifiedIdentity(executor, { - provider: GOOGLE_IDENTITY_PROVIDER, - slackTeamId: teamId, - slackUserId: userId, - }) - ) { - return true; - } + if (await slackUserHasGoogleIdentity(executor, teamId, userId)) return true; const profile = await lookupSlackUserProfile(userId); await upsertIdentity(executor, { diff --git a/packages/junior/src/chat/sign-in.ts b/packages/junior/src/chat/sign-in.ts index 3b1ae5abeb..23ceb4993b 100644 --- a/packages/junior/src/chat/sign-in.ts +++ b/packages/junior/src/chat/sign-in.ts @@ -225,20 +225,20 @@ export async function handleSignInCallback(args: { // Link only when the Slack identity's user owns this Google email. A // forwarded link then cannot give another person's Slack account access. const executor = getSqlExecutor(); - const slackUserId = await findSlackIdentityUserId( + const userId = await findSlackIdentityUserId( executor, stored.data.slackTeamId, stored.data.slackUserId, ); const user = await findUserByEmailFromSql(executor.db(), email); - if (!slackUserId || user?.id !== slackUserId) { + if (!userId || user?.id !== userId) { return page( "Slack account does not match", `Your Slack email is not ${email}. Sign in with the account that matches your Slack email.`, 403, ); } - await upsertLinkedIdentity(executor, slackUserId, { + await upsertLinkedIdentity(executor, userId, { kind: "user", provider: GOOGLE_IDENTITY_PROVIDER, providerSubjectId: account.subject, From 27882de8f7a71084ceef0adcf1104c1aaa39dce7 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Sun, 25 Oct 2026 21:09:34 +0000 Subject: [PATCH 06/10] fix(slack): Read JSON array allowlists for sign-in The dashboard accepts JUNIOR_DASHBOARD_GOOGLE_DOMAINS and JUNIOR_DASHBOARD_ALLOWED_EMAILS as a JSON string array or comma separated values. Sign-in only split on commas, so a JSON value became one bogus entry that rejected every account. Parse both formats. Co-Authored-By: David Cramer --- packages/junior/src/chat/sign-in.ts | 18 ++++++++++- .../tests/unit/chat/sign-in-allowlist.test.ts | 32 +++++++++++++++++++ 2 files changed, 49 insertions(+), 1 deletion(-) create mode 100644 packages/junior/tests/unit/chat/sign-in-allowlist.test.ts diff --git a/packages/junior/src/chat/sign-in.ts b/packages/junior/src/chat/sign-in.ts index 23ceb4993b..a54dec5d22 100644 --- a/packages/junior/src/chat/sign-in.ts +++ b/packages/junior/src/chat/sign-in.ts @@ -76,6 +76,22 @@ function redirectUri(): string { return `${baseUrl}${SIGN_IN_CALLBACK_PATH}`; } +// Read a list the same way as the dashboard: a JSON string array or comma +// separated values. +function readEnvList(name: string): string[] { + const value = process.env[name]?.trim() ?? ""; + if (!value.startsWith("[")) return value.split(","); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new Error(`${name} must be a JSON string array`, { cause: error }); + } + const list = z.array(z.string()).safeParse(parsed); + if (!list.success) throw new Error(`${name} must be a JSON string array`); + return list.data; +} + /** * Resolve the sign-in allowlist. Use the same values as the dashboard: its * options when set, else its environment variables. @@ -84,7 +100,7 @@ export function resolveSignInAllowlist( options: { allowedEmails?: string[]; allowedGoogleDomains?: string[] } = {}, ): SignInAllowlist { const list = (values: string[] | undefined, name: string) => - (values ?? (process.env[name] ?? "").split(",")) + (values ?? readEnvList(name)) .map((value) => value.trim().toLowerCase()) .filter(Boolean); return { diff --git a/packages/junior/tests/unit/chat/sign-in-allowlist.test.ts b/packages/junior/tests/unit/chat/sign-in-allowlist.test.ts new file mode 100644 index 0000000000..fbbdb8e356 --- /dev/null +++ b/packages/junior/tests/unit/chat/sign-in-allowlist.test.ts @@ -0,0 +1,32 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveSignInAllowlist } from "@/chat/sign-in"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("sign-in allowlist", () => { + it("reads dashboard env lists as JSON arrays or comma separated values", () => { + vi.stubEnv( + "JUNIOR_DASHBOARD_GOOGLE_DOMAINS", + '["Sentry.io", "example.com"]', + ); + vi.stubEnv( + "JUNIOR_DASHBOARD_ALLOWED_EMAILS", + "a@example.net, b@example.net", + ); + + expect(resolveSignInAllowlist()).toEqual({ + domains: ["sentry.io", "example.com"], + emails: ["a@example.net", "b@example.net"], + }); + }); + + it("rejects an env list that is not a JSON string array", () => { + vi.stubEnv("JUNIOR_DASHBOARD_GOOGLE_DOMAINS", "[1]"); + + expect(() => resolveSignInAllowlist()).toThrow( + "JUNIOR_DASHBOARD_GOOGLE_DOMAINS must be a JSON string array", + ); + }); +}); From 9f4173ed0c8dbae8120fc5df1e84ba1cf2ef5f58 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:02:13 +0000 Subject: [PATCH 07/10] feat(slack): Share the Google sign-in session with the dashboard Move Better Auth into core so Slack sign-in and the dashboard use one session and one Google callback (/api/auth/callback/google). Each allowed Google sign-in, from either surface, saves the native Google identity with signed_in_at. Slack ingress now requires a sign-in within slack.signInMaxAgeDays (default 7). The Slack sign-in page reads the shared session and starts one Google sign-in only when there is no recent one. It still requires the session email to match the Slack identity's user. Also set Google's hd hint only when the allowlist is exactly one domain, so allowlisted emails outside that domain can sign in, and move Slack form handling to slack-form.ts to keep slack-webhook.ts under the file length limit. Co-Authored-By: David Cramer --- .../src/content/docs/operate/dashboard.md | 2 +- .../content/docs/reference/config-and-env.md | 26 +- packages/junior-dashboard/README.md | 5 +- packages/junior-dashboard/package.json | 1 - packages/junior-dashboard/src/app.ts | 24 +- packages/junior-dashboard/src/auth.ts | 128 +- .../tests/auth-config.test.ts | 123 +- packages/junior-dashboard/tsup.config.ts | 1 - .../migrations/0060_identity_signed_in_at.sql | 1 + .../junior/migrations/meta/0060_snapshot.json | 3465 +++++++++++++++++ packages/junior/migrations/meta/_journal.json | 9 +- packages/junior/package.json | 1 + packages/junior/src/app.ts | 94 +- packages/junior/src/chat/identities/README.md | 29 +- packages/junior/src/chat/identities/sql.ts | 46 +- .../junior/src/chat/ingress/slack-form.ts | 216 + .../junior/src/chat/ingress/slack-sign-in.ts | 31 +- .../junior/src/chat/ingress/slack-webhook.ts | 206 +- packages/junior/src/chat/sign-in.ts | 269 -- packages/junior/src/chat/sign-in/allowlist.ts | 56 + packages/junior/src/chat/sign-in/auth.ts | 217 ++ packages/junior/src/chat/sign-in/slack.ts | 163 + packages/junior/src/dashboard-routes.ts | 7 + packages/junior/src/db/schema/identities.ts | 2 + .../slack/webhook-auth-boundary.test.ts | 152 +- .../tests/unit/chat/sign-in-allowlist.test.ts | 32 - .../tests/unit/chat/sign-in-auth.test.ts | 65 + pnpm-lock.yaml | 478 ++- 28 files changed, 4917 insertions(+), 932 deletions(-) create mode 100644 packages/junior/migrations/0060_identity_signed_in_at.sql create mode 100644 packages/junior/migrations/meta/0060_snapshot.json create mode 100644 packages/junior/src/chat/ingress/slack-form.ts delete mode 100644 packages/junior/src/chat/sign-in.ts create mode 100644 packages/junior/src/chat/sign-in/allowlist.ts create mode 100644 packages/junior/src/chat/sign-in/auth.ts create mode 100644 packages/junior/src/chat/sign-in/slack.ts delete mode 100644 packages/junior/tests/unit/chat/sign-in-allowlist.test.ts create mode 100644 packages/junior/tests/unit/chat/sign-in-auth.test.ts diff --git a/packages/docs/src/content/docs/operate/dashboard.md b/packages/docs/src/content/docs/operate/dashboard.md index aaabab6ccc..0cbd13be79 100644 --- a/packages/docs/src/content/docs/operate/dashboard.md +++ b/packages/docs/src/content/docs/operate/dashboard.md @@ -13,7 +13,7 @@ related: - /start-here/verify-and-troubleshoot/ --- -Use `@sentry/junior-dashboard` when you want browser access to Junior runtime diagnostics without exposing plugin, skill, or filesystem discovery publicly. The dashboard mounts into the same Nitro deployment as Junior, but its Better Auth session only protects dashboard routes. +Use `@sentry/junior-dashboard` when you want browser access to Junior runtime diagnostics without exposing plugin, skill, or filesystem discovery publicly. The dashboard mounts into the same Nitro deployment as Junior. Junior core owns its Better Auth session. With the experimental `slack-sign-in` feature, Slack uses the same session, so a dashboard sign-in also counts as a recent Slack sign-in. ## Install diff --git a/packages/docs/src/content/docs/reference/config-and-env.md b/packages/docs/src/content/docs/reference/config-and-env.md index b39c84da40..4f2971ae30 100644 --- a/packages/docs/src/content/docs/reference/config-and-env.md +++ b/packages/docs/src/content/docs/reference/config-and-env.md @@ -168,7 +168,7 @@ const app = await createApp({ // database directly and skip Conversation privacy checks. Enable them only // on isolated deployments, such as Previews behind deployment protection. "operator-tools": true, - // Ask each Slack user to sign in with Google once before Junior + // Ask each Slack user for a recent Google sign-in before Junior // replies to their mentions and DMs. "slack-sign-in": true, }, @@ -214,16 +214,20 @@ pre-stable surface. `passive-routing` turns on replies to non-mention messages in threads Junior already joined. Leave it unset in production unless you are testing that path. -`slack-sign-in` makes each Slack user sign in once with Google before Junior -starts work from their mentions or DMs. Until then, Junior drops the message and -sends the user a private sign-in link. The dashboard is not required. Sign-in -uses the same Google OAuth client and allowlist as the dashboard: -`GOOGLE_CLIENT_ID`, `GOOGLE_CLIENT_SECRET`, and -`JUNIOR_DASHBOARD_GOOGLE_DOMAINS` or `JUNIOR_DASHBOARD_ALLOWED_EMAILS`. Add -`/api/sign-in/callback` as an authorized redirect URI on the Google -OAuth client. The Google email must match the Slack profile email. Junior -links the Google identity to the user, so the check passes later. Passive -thread replies, slash commands, and Slack actions are not checked yet. +`slack-sign-in` makes each Slack user sign in with Google before Junior starts +work from their mentions or DMs. Until then, Junior drops the message and sends +the user a private sign-in link. A sign-in counts for 7 days. Set +`slack.signInMaxAgeDays` to change this number. A dashboard sign-in counts too: +Slack and the dashboard use one Better Auth session, so a person who signed in +to one of them does not sign in again for the other. + +The dashboard is not required. Sign-in uses the same Google OAuth client, +allowlist, and callback as the dashboard: `GOOGLE_CLIENT_ID`, +`GOOGLE_CLIENT_SECRET`, `JUNIOR_DASHBOARD_GOOGLE_DOMAINS` or +`JUNIOR_DASHBOARD_ALLOWED_EMAILS`, and `/api/auth/callback/google` as +an authorized redirect URI. The Google email must match the Slack profile +email. Passive thread replies, slash commands, and Slack actions are not checked +yet. `operator-tools` adds `runOperatorSql` to non-public Conversations. The tool runs one SQL statement against the app database and returns up to 200 rows. diff --git a/packages/junior-dashboard/README.md b/packages/junior-dashboard/README.md index 1def392527..760ba020df 100644 --- a/packages/junior-dashboard/README.md +++ b/packages/junior-dashboard/README.md @@ -9,8 +9,9 @@ name, through dashboard settings. - `createDashboardApp` mounts the dashboard routes and receives host configuration through `JuniorDashboardOptions`. -- Better Auth owns authentication; dashboard routes fail closed when identity - or required configuration is missing. +- Core owns the Better Auth session and passes it in as `auth`. Slack sign-in + uses the same session. Dashboard routes fail closed when identity or required + configuration is missing. - The installable shell is static only: a public web app manifest, theme color, and install icon. There is no service worker and no offline app shell cache. - API schemas under `src/api/` define the client/server boundary. diff --git a/packages/junior-dashboard/package.json b/packages/junior-dashboard/package.json index c3587608f2..bce5fdf34f 100644 --- a/packages/junior-dashboard/package.json +++ b/packages/junior-dashboard/package.json @@ -38,7 +38,6 @@ "@sentry/junior": "workspace:*", "@sentry/junior-plugin-api": "workspace:*", "@tanstack/react-query": "^5.100.14", - "better-auth": "^1.6.22", "hono": "^4.12.27", "lucide-react": "^1.17.0", "nitro": "3.0.260522-beta", diff --git a/packages/junior-dashboard/src/app.ts b/packages/junior-dashboard/src/app.ts index 40093f8897..6f107abbbb 100644 --- a/packages/junior-dashboard/src/app.ts +++ b/packages/junior-dashboard/src/app.ts @@ -25,11 +25,9 @@ import { dashboardProfileUpdateSchema, } from "./api/schema"; import { - createDashboardAuth, dashboardBearerSession, dashboardPersonalBearerToken, dashboardSessionIsAuthorized, - resolveGoogleHostedDomainHint, sanitizeDashboardSession, verifiedDashboardSessionEmail, type DashboardAuth, @@ -82,8 +80,6 @@ export interface JuniorDashboardOptions { authRequired?: boolean; allowedGoogleDomains?: string[]; allowedEmails?: string[]; - sessionMaxAgeSeconds?: number; - trustedOrigins?: string[]; auth?: DashboardAuth; componentGallery?: boolean; mockConversations?: boolean; @@ -169,8 +165,6 @@ function resolveDashboardOptions( readEnvList("JUNIOR_DASHBOARD_GOOGLE_DOMAINS"), allowedEmails: options.allowedEmails ?? readEnvList("JUNIOR_DASHBOARD_ALLOWED_EMAILS"), - trustedOrigins: - options.trustedOrigins ?? readEnvList("JUNIOR_DASHBOARD_TRUSTED_ORIGINS"), componentGallery: options.componentGallery ?? readEnvFlag("JUNIOR_DASHBOARD_COMPONENT_GALLERY"), @@ -431,17 +425,11 @@ export function createDashboardApp( ); } - const auth = authRequired - ? (options.auth ?? - createDashboardAuth({ - agentName, - authPath, - baseURL: options.baseURL, - trustedOrigins: options.trustedOrigins ?? [], - googleHostedDomain: resolveGoogleHostedDomainHint(allowedDomains), - sessionMaxAgeSeconds: options.sessionMaxAgeSeconds, - })) - : undefined; + // Core owns the Google sign-in session and shares it with Slack. + const auth = options.auth; + if (authRequired && !auth) { + throw new Error("Junior dashboard auth requires the core sign-in session"); + } const app = new Hono<{ Variables: Variables }>(); const authenticatedRoutes = options.authenticatedRoutes ?? []; app.use("*", async (c, next) => { @@ -456,7 +444,7 @@ export function createDashboardApp( authenticatedRoutes, componentGallery: options.componentGallery, }); - if (!auth) { + if (!authRequired || !auth) { return Response.redirect(returnUrl, 302); } const session = await auth.getSession(c.req.raw); diff --git a/packages/junior-dashboard/src/auth.ts b/packages/junior-dashboard/src/auth.ts index 1b6f47bf20..a8cc683c72 100644 --- a/packages/junior-dashboard/src/auth.ts +++ b/packages/junior-dashboard/src/auth.ts @@ -1,25 +1,10 @@ -import { betterAuth } from "better-auth/minimal"; import { dashboardSessionSchema } from "./api/schema"; import type { DashboardSession } from "./api/schema"; -import { resolveDashboardBaseURL } from "./url"; - -const DEFAULT_SESSION_MAX_AGE_SECONDS = 60 * 60 * 8; export type DashboardUser = DashboardSession["user"]; export type { DashboardSession }; -export interface DashboardAuthConfig { - agentName?: string; - baseURL?: string; - authPath: string; - trustedOrigins: string[]; - secret?: string; - googleClientId?: string; - googleClientSecret?: string; - googleHostedDomain?: string; - sessionMaxAgeSeconds?: number; -} - +/** Google sign-in session that core shares with Slack. */ export interface DashboardAuth { handler(request: Request): Promise; getSession(request: Request): Promise; @@ -78,114 +63,3 @@ export function sanitizeDashboardSession( }, }); } - -function required(value: string | undefined, name: string): string { - if (!value?.trim()) { - throw new Error(`${name} is required for Junior dashboard auth`); - } - return value.trim(); -} - -function firstHostedDomain(domains: string[]): string | undefined { - return domains.length === 1 ? domains[0] : undefined; -} - -/** Create the Better Auth bridge used by dashboard browser routes. */ -export function createDashboardAuth( - config: DashboardAuthConfig, -): DashboardAuth { - const secret = required( - config.secret ?? - process.env.BETTER_AUTH_SECRET ?? - process.env.JUNIOR_SECRET, - "JUNIOR_SECRET or BETTER_AUTH_SECRET", - ); - const baseURL = resolveDashboardBaseURL({ baseURL: config.baseURL }); - const googleClientId = required( - config.googleClientId ?? process.env.GOOGLE_CLIENT_ID, - "GOOGLE_CLIENT_ID", - ); - const googleClientSecret = required( - config.googleClientSecret ?? process.env.GOOGLE_CLIENT_SECRET, - "GOOGLE_CLIENT_SECRET", - ); - - const auth = betterAuth({ - appName: `${config.agentName?.trim() || "Junior"} Dashboard`, - baseURL, - basePath: config.authPath, - secret, - trustedOrigins: config.trustedOrigins, - socialProviders: { - google: { - clientId: googleClientId, - clientSecret: googleClientSecret, - hd: config.googleHostedDomain, - prompt: "select_account", - mapProfileToUser(profile) { - return { - email: profile.email, - emailVerified: profile.email_verified, - image: profile.picture, - name: profile.name, - }; - }, - }, - }, - account: { - storeStateStrategy: "cookie", - storeAccountCookie: false, - updateAccountOnSignIn: false, - }, - session: { - expiresIn: config.sessionMaxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS, - disableSessionRefresh: true, - cookieCache: { - enabled: true, - strategy: "jwe", - maxAge: config.sessionMaxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS, - refreshCache: false, - }, - }, - }); - - return { - handler(request) { - return auth.handler(request); - }, - async getSession(request) { - const session = await auth.api.getSession({ headers: request.headers }); - if (!session) { - return null; - } - return sanitizeDashboardSession(session as DashboardSession); - }, - async signInWithGoogle(request, callbackURL) { - const result = await auth.api.signInSocial({ - body: { - provider: "google", - callbackURL, - }, - headers: request.headers, - returnHeaders: true, - }); - - if (!("url" in result.response) || !result.response.url) { - throw new Error("Google sign-in did not return a redirect URL"); - } - - result.headers.set("location", result.response.url); - return new Response(null, { - status: 302, - headers: result.headers, - }); - }, - }; -} - -/** Resolve a Google hosted-domain login hint when it is unambiguous. */ -export function resolveGoogleHostedDomainHint( - domains: string[], -): string | undefined { - return firstHostedDomain(domains.map((domain) => domain.toLowerCase())); -} diff --git a/packages/junior-dashboard/tests/auth-config.test.ts b/packages/junior-dashboard/tests/auth-config.test.ts index 5952f64c2e..c5e29ec5e7 100644 --- a/packages/junior-dashboard/tests/auth-config.test.ts +++ b/packages/junior-dashboard/tests/auth-config.test.ts @@ -1,13 +1,7 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { describe, expect, it } from "vitest"; import { dashboardSessionSchema } from "../src/api/schema"; -import { resetDashboardEnv } from "./dashboard-test-helpers"; describe("dashboard auth config", () => { - afterEach(() => { - vi.doUnmock("better-auth/minimal"); - vi.resetModules(); - }); - it("requires a valid email for every dashboard session", () => { expect( dashboardSessionSchema.parse({ @@ -21,119 +15,4 @@ describe("dashboard auth config", () => { ).toBe(false); } }); - - it("keeps Google account tokens out of persistent dashboard cookies", async () => { - let capturedOptions: unknown; - - vi.doMock("better-auth/minimal", () => ({ - betterAuth(options: unknown) { - capturedOptions = options; - return { - handler: vi.fn(async () => new Response(null)), - api: { - getSession: vi.fn(async () => null), - signInSocial: vi.fn(async () => ({ - headers: new Headers(), - response: { url: "https://accounts.google.com/o/oauth2/v2/auth" }, - })), - }, - }; - }, - })); - - const { createDashboardAuth } = await import("../src/auth"); - - createDashboardAuth({ - authPath: "/api/auth", - googleClientId: "google-client-id", - googleClientSecret: "google-client-secret", - secret: "0123456789abcdef0123456789abcdef", - trustedOrigins: [], - }); - - expect(capturedOptions).toMatchObject({ - account: { - storeAccountCookie: false, - storeStateStrategy: "cookie", - updateAccountOnSignIn: false, - }, - session: { - cookieCache: { - strategy: "jwe", - }, - }, - }); - expect(capturedOptions).not.toHaveProperty("database"); - }); -}); - -describe("dashboard auth setup", () => { - beforeEach(() => { - resetDashboardEnv(); - }); - - afterEach(() => { - resetDashboardEnv(); - }); - - it("uses JUNIOR_SECRET as the default Better Auth secret", async () => { - process.env.JUNIOR_SECRET = "junior-secret"; - const { createDashboardAuth } = await import("../src/auth"); - - expect(() => - createDashboardAuth({ - authPath: "/api/auth", - trustedOrigins: [], - }), - ).toThrow("GOOGLE_CLIENT_ID is required for Junior dashboard auth"); - }); - - it("defaults dashboard auth to the local development URL", async () => { - process.env.JUNIOR_SECRET = "junior-secret"; - process.env.GOOGLE_CLIENT_ID = "google-client-id"; - process.env.GOOGLE_CLIENT_SECRET = "google-client-secret"; - const { createDashboardAuth } = await import("../src/auth"); - - expect(() => - createDashboardAuth({ - authPath: "/api/auth", - trustedOrigins: [], - }), - ).not.toThrow(); - }); - - it("derives the Better Auth base URL from Junior deployment env", async () => { - process.env.JUNIOR_SECRET = "junior-secret"; - process.env.GOOGLE_CLIENT_ID = "google-client-id"; - process.env.GOOGLE_CLIENT_SECRET = "google-client-secret"; - process.env.JUNIOR_BASE_URL = "https://junior.example.com"; - const { createDashboardAuth } = await import("../src/auth"); - - expect(() => - createDashboardAuth({ - authPath: "/api/auth", - trustedOrigins: [], - }), - ).not.toThrow(); - }); - - it("preserves the Better Auth OAuth state cookie during Google sign-in", async () => { - const { createDashboardAuth } = await import("../src/auth"); - const auth = createDashboardAuth({ - authPath: "/api/auth", - googleClientId: "google-client-id", - googleClientSecret: "google-client-secret", - secret: "0123456789abcdef0123456789abcdef", - trustedOrigins: [], - }); - - const response = await auth.signInWithGoogle( - new Request("http://localhost/auth/login"), - "http://localhost/", - ); - - expect(response.status).toBe(302); - expect(response.headers.get("location")).toContain("accounts.google.com"); - expect(response.headers.get("set-cookie")).toContain("oauth_state"); - }); }); diff --git a/packages/junior-dashboard/tsup.config.ts b/packages/junior-dashboard/tsup.config.ts index d72e2006bb..3a82fe2580 100644 --- a/packages/junior-dashboard/tsup.config.ts +++ b/packages/junior-dashboard/tsup.config.ts @@ -114,7 +114,6 @@ export default defineConfig({ "@sentry/junior/api", "@sentry/junior/api/schema", "@sentry/junior/instrumentation", - "better-auth", "hono", ], }); diff --git a/packages/junior/migrations/0060_identity_signed_in_at.sql b/packages/junior/migrations/0060_identity_signed_in_at.sql new file mode 100644 index 0000000000..782a9bfcd0 --- /dev/null +++ b/packages/junior/migrations/0060_identity_signed_in_at.sql @@ -0,0 +1 @@ +ALTER TABLE "junior_identities" ADD COLUMN "signed_in_at" timestamp with time zone; \ No newline at end of file diff --git a/packages/junior/migrations/meta/0060_snapshot.json b/packages/junior/migrations/meta/0060_snapshot.json new file mode 100644 index 0000000000..1dbf5a7333 --- /dev/null +++ b/packages/junior/migrations/meta/0060_snapshot.json @@ -0,0 +1,3465 @@ +{ + "id": "f2dd4b37-6caf-4453-9552-3ffafb9ffe5c", + "prevId": "6936b8a8-1c94-4be1-a259-10512747eb34", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.junior_agent_bindings": { + "name": "junior_agent_bindings", + "schema": "", + "columns": { + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_agent_bindings_child_idx": { + "name": "junior_agent_bindings_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_bindings_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_agent_bindings_parent_conversation_id_name_pk": { + "name": "junior_agent_bindings_parent_conversation_id_name_pk", + "columns": ["parent_conversation_id", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_agent_invocations": { + "name": "junior_agent_invocations", + "schema": "", + "columns": { + "invocation_id": { + "name": "invocation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_conversation_id": { + "name": "child_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_name": { + "name": "agent_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "input": { + "name": "input", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "credential_context_json": { + "name": "credential_context_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_visibility": { + "name": "destination_visibility", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model_profile": { + "name": "model_profile", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "reasoning_level": { + "name": "reasoning_level", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_tool_call_id": { + "name": "parent_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mailbox_status": { + "name": "mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_mailbox_status": { + "name": "parent_mailbox_status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result": { + "name": "result", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "terminal_at": { + "name": "terminal_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_agent_invocations_child_idx": { + "name": "junior_agent_invocations_child_idx", + "columns": [ + { + "expression": "child_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_mailbox_idx": { + "name": "junior_agent_invocations_mailbox_idx", + "columns": [ + { + "expression": "mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_agent_invocations_parent_mailbox_idx": { + "name": "junior_agent_invocations_parent_mailbox_idx", + "columns": [ + { + "expression": "parent_mailbox_status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "terminal_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_agent_invocations_child_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_agent_invocations", + "tableTo": "junior_conversations", + "columnsFrom": ["child_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_api_tokens": { + "name": "junior_api_tokens", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "owner_email_normalized": { + "name": "owner_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "token_suffix": { + "name": "token_suffix", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_used_at": { + "name": "last_used_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "revoked_at": { + "name": "revoked_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_api_tokens_token_hash_uidx": { + "name": "junior_api_tokens_token_hash_uidx", + "columns": [ + { + "expression": "token_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_api_tokens_owner_email_idx": { + "name": "junior_api_tokens_owner_email_idx", + "columns": [ + { + "expression": "owner_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_artifacts": { + "name": "junior_artifacts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "ext": { + "name": "ext", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "public": { + "name": "public", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_artifacts_conversation_sha_uidx": { + "name": "junior_artifacts_conversation_sha_uidx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sha256", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_gc_idx": { + "name": "junior_artifacts_gc_idx", + "columns": [ + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_artifacts_conversation_idx": { + "name": "junior_artifacts_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_attachments": { + "name": "junior_attachments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_provider": { + "name": "storage_provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "bytes": { + "name": "bytes", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "sha256": { + "name": "sha256", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "vision_summary": { + "name": "vision_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "delete_requested_at": { + "name": "delete_requested_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_attachments_conversation_idx": { + "name": "junior_attachments_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_provider_idx": { + "name": "junior_attachments_provider_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_attachments_gc_idx": { + "name": "junior_attachments_gc_idx", + "columns": [ + { + "expression": "storage_provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "delete_requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_attachments_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_attachments_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_attachments", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automation_runs": { + "name": "junior_automation_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "automation_version": { + "name": "automation_version", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "scheduled_for_ms": { + "name": "scheduled_for_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "attempt": { + "name": "attempt", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "claimed_at_ms": { + "name": "claimed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "dispatch_id": { + "name": "dispatch_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at_ms": { + "name": "started_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "completed_at_ms": { + "name": "completed_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_message_ts": { + "name": "result_message_ts", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_automation_runs_automation_idx": { + "name": "junior_automation_runs_automation_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_completed_idx": { + "name": "junior_automation_runs_completed_idx", + "columns": [ + { + "expression": "completed_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automation_runs_incomplete_idx": { + "name": "junior_automation_runs_incomplete_idx", + "columns": [ + { + "expression": "automation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "scheduled_for_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automation_runs\".\"status\" in ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automation_runs_automation_id_junior_automations_id_fk": { + "name": "junior_automation_runs_automation_id_junior_automations_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_automation_runs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_automation_runs_automation_version_fk": { + "name": "junior_automation_runs_automation_version_fk", + "tableFrom": "junior_automation_runs", + "tableTo": "junior_automation_versions", + "columnsFrom": ["automation_id", "automation_version"], + "columnsTo": ["automation_id", "version"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "junior_automation_runs_conversation_id_unique": { + "name": "junior_automation_runs_conversation_id_unique", + "nullsNotDistinct": false, + "columns": ["conversation_id"] + } + }, + "policies": {}, + "checkConstraints": { + "junior_automation_runs_status_check": { + "name": "junior_automation_runs_status_check", + "value": "\"junior_automation_runs\".\"status\" in ('pending', 'running', 'completed', 'failed', 'blocked', 'skipped')" + } + }, + "isRLSEnabled": false + }, + "public.junior_automation_versions": { + "name": "junior_automation_versions", + "schema": "", + "columns": { + "automation_id": { + "name": "automation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "edited_by": { + "name": "edited_by", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "definition": { + "name": "definition", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_automation_versions_automation_id_fk": { + "name": "junior_automation_versions_automation_id_fk", + "tableFrom": "junior_automation_versions", + "tableTo": "junior_automations", + "columnsFrom": ["automation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_automation_versions_pk": { + "name": "junior_automation_versions_pk", + "columns": ["automation_id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_automations": { + "name": "junior_automations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "team_id": { + "name": "team_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_slack_user_id": { + "name": "creator_slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status_reason": { + "name": "status_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "instruction": { + "name": "instruction", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at_ms": { + "name": "created_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": true + }, + "next_run_at_ms": { + "name": "next_run_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "run_now_at_ms": { + "name": "run_now_at_ms", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "record": { + "name": "record", + "type": "jsonb", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_automations_team_idx": { + "name": "junior_automations_team_idx", + "columns": [ + { + "expression": "team_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_creator_identity_idx": { + "name": "junior_automations_creator_identity_idx", + "columns": [ + { + "expression": "creator_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_run_now_due_idx": { + "name": "junior_automations_run_now_due_idx", + "columns": [ + { + "expression": "run_now_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"run_now_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_next_run_due_idx": { + "name": "junior_automations_next_run_due_idx", + "columns": [ + { + "expression": "next_run_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" = 'active' AND \"junior_automations\".\"next_run_at_ms\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_automations_match_idx": { + "name": "junior_automations_match_idx", + "columns": [ + { + "expression": "namespace", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at_ms", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_automations\".\"status\" <> 'deleted'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_automations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_automations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_automations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_automations_kind_check": { + "name": "junior_automations_kind_check", + "value": "\"junior_automations\".\"kind\" in ('scheduled', 'event')" + }, + "junior_automations_status_check": { + "name": "junior_automations_status_check", + "value": "\"junior_automations\".\"status\" in ('active', 'blocked', 'paused', 'completed', 'deleted')" + }, + "junior_automations_record_check": { + "name": "junior_automations_record_check", + "value": "jsonb_typeof(\"junior_automations\".\"record\") = 'object'" + }, + "junior_automations_trigger_check": { + "name": "junior_automations_trigger_check", + "value": "(\"junior_automations\".\"kind\" = 'scheduled' and \"junior_automations\".\"namespace\" is null and \"junior_automations\".\"identifier\" is null) or (\"junior_automations\".\"kind\" = 'event' and \"junior_automations\".\"namespace\" is not null and \"junior_automations\".\"identifier\" is not null and \"junior_automations\".\"next_run_at_ms\" is null and \"junior_automations\".\"run_now_at_ms\" is null and \"junior_automations\".\"status\" <> 'completed')" + } + }, + "isRLSEnabled": false + }, + "public.junior_code_changes": { + "name": "junior_code_changes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "closed_at": { + "name": "closed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "conversation_ids": { + "name": "conversation_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "ARRAY[]::text[]" + }, + "merged_at": { + "name": "merged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "number": { + "name": "number", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "opened_at": { + "name": "opened_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repository_id": { + "name": "repository_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_code_changes_provider_id_idx": { + "name": "junior_code_changes_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_opened_at_idx": { + "name": "junior_code_changes_opened_at_idx", + "columns": [ + { + "expression": "opened_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_merged_at_idx": { + "name": "junior_code_changes_merged_at_idx", + "columns": [ + { + "expression": "merged_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_closed_at_idx": { + "name": "junior_code_changes_closed_at_idx", + "columns": [ + { + "expression": "closed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_code_changes_open_idx": { + "name": "junior_code_changes_open_idx", + "columns": [ + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_code_changes\".\"state\" = 'open'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_code_changes_repository_id_junior_code_repositories_id_fk": { + "name": "junior_code_changes_repository_id_junior_code_repositories_id_fk", + "tableFrom": "junior_code_changes", + "tableTo": "junior_code_repositories", + "columnsFrom": ["repository_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_code_repositories": { + "name": "junior_code_repositories", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_code_repositories_provider_id_idx": { + "name": "junior_code_repositories_provider_id_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_annotations": { + "name": "junior_conversation_annotations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "plugin": { + "name": "plugin", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "annotation_json": { + "name": "annotation_json", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_conversation_annotations_conversation_id_fk": { + "name": "junior_conversation_annotations_conversation_id_fk", + "tableFrom": "junior_conversation_annotations", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_annotations_pk": { + "name": "junior_conversation_annotations_pk", + "columns": ["conversation_id", "plugin", "kind", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_bindings": { + "name": "junior_conversation_bindings", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_conversation_id": { + "name": "provider_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_bindings_conversation_idx": { + "name": "junior_conversation_bindings_conversation_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_bindings_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_bindings", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_bindings_provider_conversation_pk": { + "name": "junior_conversation_bindings_provider_conversation_pk", + "columns": [ + "provider", + "provider_tenant_id", + "provider_destination_id", + "provider_conversation_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_briefs": { + "name": "junior_conversation_briefs", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "turn_id": { + "name": "turn_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "through_seq": { + "name": "through_seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "search_text": { + "name": "search_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "model_id": { + "name": "model_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "cost_usd": { + "name": "cost_usd", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "junior_conversation_briefs_conversation_turn_idx": { + "name": "junior_conversation_briefs_conversation_turn_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "turn_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_conversation_version_idx": { + "name": "junior_conversation_briefs_conversation_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_briefs_search_idx": { + "name": "junior_conversation_briefs_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"search_text\")", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_briefs_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_briefs", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_briefs_conversation_id_version_pk": { + "name": "junior_conversation_briefs_conversation_id_version_pk", + "columns": ["conversation_id", "version"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_events": { + "name": "junior_conversation_events", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "history_version": { + "name": "history_version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_events_history_version_idx": { + "name": "junior_conversation_events_history_version_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "history_version", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_type_idx": { + "name": "junior_conversation_events_type_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_actor_identity_idx": { + "name": "junior_conversation_events_actor_identity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_events_message_search_idx": { + "name": "junior_conversation_events_message_search_idx", + "columns": [ + { + "expression": "to_tsvector('english', \"payload\"->>'text')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversation_events\".\"type\" = 'message'", + "concurrently": false, + "method": "gin", + "with": {} + }, + "junior_conversation_events_idempotency_idx": { + "name": "junior_conversation_events_idempotency_idx", + "columns": [ + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_events_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversation_events_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_events_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_events", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_events_conversation_id_seq_pk": { + "name": "junior_conversation_events_conversation_id_seq_pk", + "columns": ["conversation_id", "seq"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_metrics": { + "name": "junior_conversation_metrics", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "double precision", + "primaryKey": false, + "notNull": true + }, + "occurred_at": { + "name": "occurred_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_conversation_metrics_occurred_at_metric_idx": { + "name": "junior_conversation_metrics_occurred_at_metric_idx", + "columns": [ + { + "expression": "occurred_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "metric", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_metrics_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_metrics", + "tableTo": "junior_conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_metrics_conversation_id_run_id_metric_pk": { + "name": "junior_conversation_metrics_conversation_id_run_id_metric_pk", + "columns": ["conversation_id", "run_id", "metric"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversation_participants": { + "name": "junior_conversation_participants", + "schema": "", + "columns": { + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "last_message_at": { + "name": "last_message_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversation_participants_user_activity_idx": { + "name": "junior_conversation_participants_user_activity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_message_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversation_participants_root_idx": { + "name": "junior_conversation_participants_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversation_participants_user_id_junior_users_id_fk": { + "name": "junior_conversation_participants_user_id_junior_users_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversation_participants_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversation_participants", + "tableTo": "junior_conversations", + "columnsFrom": ["root_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_conversation_participants_user_root_pk": { + "name": "junior_conversation_participants_user_root_pk", + "columns": ["user_id", "root_conversation_id"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_conversations": { + "name": "junior_conversations", + "schema": "", + "columns": { + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schema_version": { + "name": "schema_version", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1 + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_json": { + "name": "source_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "location_json": { + "name": "location_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "destination_id": { + "name": "destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "destination_json": { + "name": "destination_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "actor_identity_id": { + "name": "actor_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "creator_identity_id": { + "name": "creator_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_subject_identity_id": { + "name": "credential_subject_identity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_json": { + "name": "actor_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "channel_name": { + "name": "channel_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "last_activity_at": { + "name": "last_activity_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "execution_updated_at": { + "name": "execution_updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "execution_status": { + "name": "execution_status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "run_id": { + "name": "run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_checkpoint_at": { + "name": "last_checkpoint_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_enqueued_at": { + "name": "last_enqueued_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "parent_conversation_id": { + "name": "parent_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "forked_from_conversation_id": { + "name": "forked_from_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "root_conversation_id": { + "name": "root_conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transcript_purged_at": { + "name": "transcript_purged_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "stopped_at": { + "name": "stopped_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "usage_json": { + "name": "usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "execution_duration_ms": { + "name": "execution_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "execution_usage_json": { + "name": "execution_usage_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metric_run_id": { + "name": "metric_run_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_conversations_last_activity_idx": { + "name": "junior_conversations_last_activity_idx", + "columns": [ + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_active_idx": { + "name": "junior_conversations_active_idx", + "columns": [ + { + "expression": "coalesce(\"execution_updated_at\", \"updated_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_conversations\".\"execution_status\" <> 'idle'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_destination_activity_idx": { + "name": "junior_conversations_destination_activity_idx", + "columns": [ + { + "expression": "destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_actor_activity_idx": { + "name": "junior_conversations_actor_activity_idx", + "columns": [ + { + "expression": "actor_identity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_activity_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_parent_idx": { + "name": "junior_conversations_parent_idx", + "columns": [ + { + "expression": "parent_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_forked_from_idx": { + "name": "junior_conversations_forked_from_idx", + "columns": [ + { + "expression": "forked_from_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_conversations_root_idx": { + "name": "junior_conversations_root_idx", + "columns": [ + { + "expression": "root_conversation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_conversations_destination_id_junior_destinations_id_fk": { + "name": "junior_conversations_destination_id_junior_destinations_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_destinations", + "columnsFrom": ["destination_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_actor_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_actor_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["actor_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_creator_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_creator_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["creator_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_credential_subject_identity_id_junior_identities_id_fk": { + "name": "junior_conversations_credential_subject_identity_id_junior_identities_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_identities", + "columnsFrom": ["credential_subject_identity_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_parent_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["parent_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + }, + "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_forked_from_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["forked_from_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "set null", + "onUpdate": "no action" + }, + "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk": { + "name": "junior_conversations_root_conversation_id_junior_conversations_conversation_id_fk", + "tableFrom": "junior_conversations", + "tableTo": "junior_conversations", + "columnsFrom": ["root_conversation_id"], + "columnsTo": ["conversation_id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_destinations": { + "name": "junior_destinations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_destination_id": { + "name": "provider_destination_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_destination_id": { + "name": "parent_destination_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "visibility": { + "name": "visibility", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'unknown'" + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_destinations_provider_destination_uidx": { + "name": "junior_destinations_provider_destination_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_destination_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_destinations_provider_kind_idx": { + "name": "junior_destinations_provider_kind_idx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_identities": { + "name": "junior_identities", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_tenant_id": { + "name": "provider_tenant_id", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "provider_subject_id": { + "name": "provider_subject_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "handle": { + "name": "handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata_json": { + "name": "metadata_json", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_normalized": { + "name": "email_normalized", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "signed_in_at": { + "name": "signed_in_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "junior_identities_provider_subject_uidx": { + "name": "junior_identities_provider_subject_uidx", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_tenant_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_subject_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_user_idx": { + "name": "junior_identities_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_verified_email_idx": { + "name": "junior_identities_verified_email_idx", + "columns": [ + { + "expression": "email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"junior_identities\".\"email_verified\" = true AND \"junior_identities\".\"email_normalized\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_identities_kind_provider_idx": { + "name": "junior_identities_kind_provider_idx", + "columns": [ + { + "expression": "kind", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_identities_user_id_junior_users_id_fk": { + "name": "junior_identities_user_id_junior_users_id_fk", + "tableFrom": "junior_identities", + "tableTo": "junior_users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_location_configurations": { + "name": "junior_location_configurations", + "schema": "", + "columns": { + "location_id": { + "name": "location_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_by": { + "name": "updated_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source": { + "name": "source", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "junior_location_configurations_location_id_junior_destinations_id_fk": { + "name": "junior_location_configurations_location_id_junior_destinations_id_fk", + "tableFrom": "junior_location_configurations", + "tableTo": "junior_destinations", + "columnsFrom": ["location_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_location_configurations_location_id_key_pk": { + "name": "junior_location_configurations_location_id_key_pk", + "columns": ["location_id", "key"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_snapshots": { + "name": "junior_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "profile_hash": { + "name": "profile_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "snapshot_id": { + "name": "snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_duration_ms": { + "name": "build_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "size_bytes": { + "name": "size_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": false + }, + "generated_at": { + "name": "generated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_started_at": { + "name": "build_started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "build_phase": { + "name": "build_phase", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_sandbox_name": { + "name": "build_sandbox_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_command_id": { + "name": "build_command_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "build_error": { + "name": "build_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_snapshots_snapshot_id_uidx": { + "name": "junior_snapshots_snapshot_id_uidx", + "columns": [ + { + "expression": "snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_idx": { + "name": "junior_snapshots_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_profile_status_idx": { + "name": "junior_snapshots_workspace_profile_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_active_build_uidx": { + "name": "junior_snapshots_active_build_uidx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "profile_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"junior_snapshots\".\"status\" = 'building'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "junior_snapshots_workspace_status_idx": { + "name": "junior_snapshots_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "junior_snapshots_workspace_id_junior_workspaces_id_fk": { + "name": "junior_snapshots_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_snapshots", + "tableTo": "junior_workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "junior_snapshots_status_check": { + "name": "junior_snapshots_status_check", + "value": "\"junior_snapshots\".\"status\" in ('building', 'failed', 'ready')" + }, + "junior_snapshots_build_phase_check": { + "name": "junior_snapshots_build_phase_check", + "value": "\"junior_snapshots\".\"build_phase\" is null or \"junior_snapshots\".\"build_phase\" in ('created', 'dependencies_installed', 'repositories_prepared')" + }, + "junior_snapshots_ready_fields_check": { + "name": "junior_snapshots_ready_fields_check", + "value": "\"junior_snapshots\".\"status\" <> 'ready' or (\"junior_snapshots\".\"snapshot_id\" is not null and \"junior_snapshots\".\"build_duration_ms\" is not null and \"junior_snapshots\".\"generated_at\" is not null)" + }, + "junior_snapshots_build_fields_check": { + "name": "junior_snapshots_build_fields_check", + "value": "\"junior_snapshots\".\"status\" = 'ready' or (\"junior_snapshots\".\"build_started_at\" is not null and \"junior_snapshots\".\"build_phase\" is not null)" + }, + "junior_snapshots_build_duration_check": { + "name": "junior_snapshots_build_duration_check", + "value": "\"junior_snapshots\".\"build_duration_ms\" is null or \"junior_snapshots\".\"build_duration_ms\" >= 0" + }, + "junior_snapshots_size_bytes_check": { + "name": "junior_snapshots_size_bytes_check", + "value": "\"junior_snapshots\".\"size_bytes\" is null or \"junior_snapshots\".\"size_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.junior_stats": { + "name": "junior_stats", + "schema": "", + "columns": { + "date": { + "name": "date", + "type": "date", + "primaryKey": false, + "notNull": true + }, + "namespace": { + "name": "namespace", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metric": { + "name": "metric", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "count": { + "name": "count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": { + "junior_stats_date_namespace_metric_name_pk": { + "name": "junior_stats_date_namespace_metric_name_pk", + "columns": ["date", "namespace", "metric", "name"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_users": { + "name": "junior_users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "primary_email": { + "name": "primary_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "primary_email_normalized": { + "name": "primary_email_normalized", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_admin": { + "name": "is_admin", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_users_primary_email_normalized_uidx": { + "name": "junior_users_primary_email_normalized_uidx", + "columns": [ + { + "expression": "primary_email_normalized", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspace_repos": { + "name": "junior_workspace_repos", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "repo": { + "name": "repo", + "type": "text", + "primaryKey": false, + "notNull": true + } + }, + "indexes": {}, + "foreignKeys": { + "junior_workspace_repos_workspace_id_junior_workspaces_id_fk": { + "name": "junior_workspace_repos_workspace_id_junior_workspaces_id_fk", + "tableFrom": "junior_workspace_repos", + "tableTo": "junior_workspaces", + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "junior_workspace_repos_workspace_id_provider_repo_pk": { + "name": "junior_workspace_repos_workspace_id_provider_repo_pk", + "columns": ["workspace_id", "provider", "repo"] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.junior_workspaces": { + "name": "junior_workspaces", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "setup_script": { + "name": "setup_script", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "junior_workspaces_name_idx": { + "name": "junior_workspaces_name_idx", + "columns": [ + { + "expression": "name", + "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/packages/junior/migrations/meta/_journal.json b/packages/junior/migrations/meta/_journal.json index 4cb48d53b8..e9ed0af0fc 100644 --- a/packages/junior/migrations/meta/_journal.json +++ b/packages/junior/migrations/meta/_journal.json @@ -421,6 +421,13 @@ "when": 1792739994091, "tag": "0059_automation_run_version", "breakpoints": true + }, + { + "idx": 60, + "version": "7", + "when": 1792971097553, + "tag": "0060_identity_signed_in_at", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/packages/junior/package.json b/packages/junior/package.json index 30ec8596ae..e06bce612a 100644 --- a/packages/junior/package.json +++ b/packages/junior/package.json @@ -92,6 +92,7 @@ "@vercel/queue": "^0.2.0", "@vercel/sandbox": "2.8.0", "ai": "^6.0.190", + "better-auth": "^1.6.22", "chat": "4.29.0", "commander": "^14.0.3", "diff": "^8.0.4", diff --git a/packages/junior/src/app.ts b/packages/junior/src/app.ts index f1eb5d2270..08d9c17bc0 100644 --- a/packages/junior/src/app.ts +++ b/packages/junior/src/app.ts @@ -117,11 +117,17 @@ import { handleAcpAuthorizationPage, } from "@/api/acp/authorization-page"; import { - handleSignInCallback, - resolveSignInAllowlist, - SIGN_IN_CALLBACK_PATH, - type SignInAllowlist, -} from "@/chat/sign-in"; + createSignInAuth, + resolveSignInAuthConfig, + type SignInAuth, + type SignInAuthConfig, +} from "@/chat/sign-in/auth"; +import { + DEFAULT_SLACK_SIGN_IN_MAX_AGE_DAYS, + handleSlackSignIn, + SLACK_SIGN_IN_PATH, + type SlackSignIn, +} from "@/chat/sign-in/slack"; import { JUNIOR_VERSION } from "./version"; import { createDashboardRouteRegistrations, @@ -177,6 +183,12 @@ export interface JuniorAppOptions extends BotModelConfig { * `JUNIOR_CROSS_ACTOR_MID_RUN_MODE` or `follow_up`. */ crossActorMidRunMode?: CrossActorMidRunMode; + /** + * With `experimental["slack-sign-in"]`, how many days a Google sign-in, + * in Slack or the dashboard, lets a person use Junior in Slack. + * Defaults to 7. + */ + signInMaxAgeDays?: number; }; /** Install-wide provider defaults. Unregistered `provider.key` entries warn at startup. */ configDefaults?: Record; @@ -442,7 +454,8 @@ export async function createApp(options?: JuniorAppOptions): Promise { }), }); let sandboxEgressTracePropagationDomains: string[] = []; - let signInAllowlist: SignInAllowlist | undefined; + let signInConfig: SignInAuthConfig | undefined; + let slackSignIn: SlackSignIn | undefined; try { sandboxEgressTracePropagationDomains = normalizeSandboxEgressTracePropagationDomains( @@ -450,14 +463,6 @@ export async function createApp(options?: JuniorAppOptions): Promise { ); setSandboxResourceConfig(options?.sandbox); setExperimentalFeatures(options?.experimental); - if (isExperimentalFeatureEnabled("slack-sign-in")) { - signInAllowlist = resolveSignInAllowlist(dashboard); - if (!signInAllowlist.domains.length && !signInAllowlist.emails.length) { - throw new Error( - "experimental.slack-sign-in requires allowed Google domains or emails", - ); - } - } setConfigDefaults(options?.configDefaults); warnUnregisteredConfigDefaults(options?.configDefaults); // Start from the defaults so this app cannot inherit an earlier app's @@ -472,6 +477,27 @@ export async function createApp(options?: JuniorAppOptions): Promise { if (options?.slack?.crossActorMidRunMode) { setCrossActorMidRunMode(options.slack.crossActorMidRunMode); } + const dashboardMounted = Boolean(dashboard && !dashboard.disabled); + if (isExperimentalFeatureEnabled("slack-sign-in") || dashboardMounted) { + signInConfig = resolveSignInAuthConfig(botConfig.userName, dashboard); + } + if (signInConfig && isExperimentalFeatureEnabled("slack-sign-in")) { + const { allowlist } = signInConfig; + if (!allowlist.domains.length && !allowlist.emails.length) { + throw new Error( + "experimental.slack-sign-in requires allowed Google domains or emails", + ); + } + const maxAgeDays = + options?.slack?.signInMaxAgeDays ?? DEFAULT_SLACK_SIGN_IN_MAX_AGE_DAYS; + if (!(maxAgeDays > 0)) { + throw new Error("slack.signInMaxAgeDays must be greater than 0"); + } + slackSignIn = { + baseURL: signInConfig.baseURL, + maxAgeMs: maxAgeDays * 24 * 60 * 60 * 1000, + }; + } if (shouldValidatePluginCatalog) { pluginCatalogRuntime.getSignature(); validatePluginRegistrations(configuredPlugins?.registrations ?? []); @@ -586,10 +612,17 @@ export async function createApp(options?: JuniorAppOptions): Promise { restoreRuntimeConfig(); throw error; } - const slackWebhookServices = createProductionSlackWebhookServices({ - queue: conversationWorkQueue, - services: runtimeServiceOverrides, - }); + // One Google sign-in session for the dashboard and Slack. + const signInAuth: SignInAuth | undefined = signInConfig + ? createSignInAuth(signInConfig) + : undefined; + const slackWebhookServices = { + ...createProductionSlackWebhookServices({ + queue: conversationWorkQueue, + services: runtimeServiceOverrides, + }), + ...(slackSignIn ? { signIn: slackSignIn } : undefined), + }; const app = new Hono(); @@ -613,9 +646,14 @@ export async function createApp(options?: JuniorAppOptions): Promise { mountRoutes( app, await createDashboardRouteRegistrations({ + auth: signInAuth, authenticatedRoutes, conversationWorkQueue, - dashboard, + // Keep dashboard redirects on the origin of the sign-in callback. + dashboard: + dashboard && signInConfig + ? { ...dashboard, baseURL: signInConfig.baseURL } + : dashboard, createDashboardApp: virtualConfig?.createDashboardApp, pluginRoutes: pluginApiRoutes, }), @@ -644,11 +682,21 @@ export async function createApp(options?: JuniorAppOptions): Promise { }); }); - if (signInAllowlist) { - const allowlist = signInAllowlist; - app.get(SIGN_IN_CALLBACK_PATH, (c) => - handleSignInCallback({ + if (signInAuth && signInConfig && slackSignIn) { + const auth = signInAuth; + // The dashboard serves the sign-in routes when it is mounted. + if (!dashboard || dashboard.disabled) { + app.on(["GET", "POST"], `${signInConfig.authPath}/*`, (c) => + auth.handler(c.req.raw), + ); + } + const { allowlist } = signInConfig; + const signIn = slackSignIn; + app.get(SLACK_SIGN_IN_PATH, (c) => + handleSlackSignIn({ allowlist, + auth, + signIn, request: c.req.raw, state: getStateAdapter(), }), diff --git a/packages/junior/src/chat/identities/README.md b/packages/junior/src/chat/identities/README.md index 1d857259d5..78c8703abf 100644 --- a/packages/junior/src/chat/identities/README.md +++ b/packages/junior/src/chat/identities/README.md @@ -25,9 +25,10 @@ these identities, so only core links them. `isNativeIdentityProvider()` in - A Slack identity has the Slack team ID as its tenant and the Slack user ID as its subject. - A Google identity has an empty tenant and the Google OIDC `sub` as its - subject. Google sign-in in `chat/sign-in.ts` links it, with the verified - Google email. One Google account is one row, so every feature that reads the - `google` identity sees the same account. + subject. Each allowed Google sign-in, from Slack or the dashboard, saves it + with the verified Google email and sets `signed_in_at`. One Google account is + one row, so every feature that reads the `google` identity sees the same + account. - The Google Workspace plugin is not a Google identity. It stores Junior's own Workspace account in its own table. @@ -59,12 +60,20 @@ authority. ## Slack Sign-In With the experimental `slack-sign-in` feature, Slack ingress accepts mentions -and DMs only when the user of the Slack identity has a verified Google -identity. `chat/sign-in.ts` owns the Google sign-in that links it. It does not -need the dashboard. +and DMs only when the user of the Slack identity has a Google identity with a +recent `signed_in_at`. The default limit is 7 days +(`slack.signInMaxAgeDays`). `chat/sign-in/` owns the sign-in. It does not need +the dashboard. +- `chat/sign-in/auth.ts` owns the one Better Auth instance. Slack and the + dashboard use it, so they share one browser session and one Google callback. + Core mounts its routes when the dashboard is not mounted. +- After each Google callback, a Better Auth hook saves the Google identity when + the verified email is on the dashboard allowlist. - Ingress saves the Slack identity with its Slack profile email. Then it stores - one sign-in state for that Slack identity and sends a private Google link. -- The callback checks the dashboard allowlist. Then it links the Google - identity only when the user of that Slack identity has the same email. A - forwarded link cannot give another person's Slack account access. + one sign-in state for that Slack identity and sends a private link to + `/api/sign-in/slack`. +- That page reads the shared session. It starts one Google sign-in when there is + no session or no recent sign-in. It succeeds only when the user of the Slack + identity has the session email. A forwarded link cannot give another + person's Slack account access. diff --git a/packages/junior/src/chat/identities/sql.ts b/packages/junior/src/chat/identities/sql.ts index 0d9396ca48..a8f36e477d 100644 --- a/packages/junior/src/chat/identities/sql.ts +++ b/packages/junior/src/chat/identities/sql.ts @@ -1,5 +1,5 @@ import { randomUUID } from "node:crypto"; -import { and, eq, inArray, sql } from "drizzle-orm"; +import { and, eq, gte, inArray, sql } from "drizzle-orm"; import { alias } from "drizzle-orm/pg-core"; import type { JuniorDatabase, JuniorSqlDatabase } from "@/db/db"; import { juniorIdentities, juniorUsers } from "@/db/schema"; @@ -341,11 +341,42 @@ export async function findSlackIdentityUserId( return rows[0]?.userId ?? undefined; } -/** Return whether the user of one Slack identity has a verified Google identity. */ -export async function slackUserHasGoogleIdentity( +/** + * Save one Google sign-in. Link the Google identity to the user of its + * verified email, and set its sign-in time. + */ +export async function recordGoogleSignIn( executor: JuniorSqlDatabase, - slackTeamId: string, - slackUserId: string, + account: { displayName?: string; email: string; subject: string }, + nowMs: number = Date.now(), +): Promise { + const stored = await upsertIdentity( + executor, + { + kind: "user", + provider: GOOGLE_IDENTITY_PROVIDER, + providerSubjectId: account.subject, + email: account.email, + emailVerified: true, + displayName: account.displayName, + }, + nowMs, + ); + await executor + .db() + .update(juniorIdentities) + .set({ signedInAt: dateFromMs(nowMs) }) + .where(eq(juniorIdentities.id, stored.id)); + return stored; +} + +/** + * Return whether the user of one Slack identity signed in with Google at or + * after `signedInSince`. + */ +export async function slackUserHasGoogleSignIn( + executor: JuniorSqlDatabase, + args: { signedInSince: Date; slackTeamId: string; slackUserId: string }, ): Promise { const rows = await executor .db() @@ -359,11 +390,12 @@ export async function slackUserHasGoogleIdentity( and( eq(juniorIdentities.kind, "user"), eq(juniorIdentities.provider, "slack"), - eq(juniorIdentities.providerTenantId, slackTeamId), - eq(juniorIdentities.providerSubjectId, slackUserId), + eq(juniorIdentities.providerTenantId, args.slackTeamId), + eq(juniorIdentities.providerSubjectId, args.slackUserId), eq(googleIdentity.kind, "user"), eq(googleIdentity.provider, GOOGLE_IDENTITY_PROVIDER), eq(googleIdentity.emailVerified, true), + gte(googleIdentity.signedInAt, args.signedInSince), ), ) .limit(1); diff --git a/packages/junior/src/chat/ingress/slack-form.ts b/packages/junior/src/chat/ingress/slack-form.ts new file mode 100644 index 0000000000..49eeda6eb1 --- /dev/null +++ b/packages/junior/src/chat/ingress/slack-form.ts @@ -0,0 +1,216 @@ +/** Slack slash command and interactive payload forms. */ +import type { SlackAdapter } from "@chat-adapter/slack"; +import { ChannelImpl, type StateAdapter } from "chat"; +import { parseActorUserId } from "@/chat/actor"; +import type { UserTokenStore } from "@/chat/credentials/user-token-store"; +import { unlinkProvider } from "@/chat/credentials/unlink-provider"; +import { handleSlashCommand } from "@/chat/ingress/slash-command"; +import { logException, withLogContext, withSpan } from "@/chat/logging"; +import { + runWithSlackInstallation, + type SlackInstallationContext, +} from "@/chat/slack/adapter-context"; +import { publishAppHomeView } from "@/chat/slack/app-home"; +import { getSlackClient } from "@/chat/slack/client"; +import { + getWorkspaceTeamId, + runWithWorkspaceTeamId, +} from "@/chat/slack/workspace-context"; +import type { WaitUntilFn } from "@/handlers/types"; +import { + slackInteractivePayloadSchema, + slackSlashCommandSchema, + type SlackInteractivePayload, + type SlackSlashCommandForm, +} from "./slack-payload"; + +function parseJson(body: string): unknown { + try { + return JSON.parse(body); + } catch { + return undefined; + } +} + +function requireSlackPayloadUserId( + value: string | null | undefined, + source: string, +): string { + const userId = parseActorUserId(value); + if (!userId) { + throw new Error(`${source} is missing a Slack user id`); + } + return userId; +} + +async function handleSlashCommandForm(args: { + adapter: SlackAdapter; + form: SlackSlashCommandForm; + state: StateAdapter; +}): Promise { + const { channel_id: channelId, team_id: teamId, user_id: userId } = args.form; + const channel = new ChannelImpl({ + id: `slack:${channelId}`, + adapter: args.adapter, + stateAdapter: args.state, + }); + await withSpan( + "chat.slash_command", + "chat.slash_command", + { userId: userId }, + async () => { + await handleSlashCommand({ + channel, + channelId, + teamId, + text: args.form.text, + userId, + }); + }, + ); +} + +async function handleInteractivePayload(args: { + payload: SlackInteractivePayload; + userTokenStore: UserTokenStore; +}): Promise { + if (args.payload.type !== "block_actions") { + return; + } + const action = args.payload.actions?.find( + (candidate) => candidate.action_id === "app_home_disconnect", + ); + const provider = action?.selected_option?.value ?? action?.value; + if (!provider) { + return; + } + const userId = requireSlackPayloadUserId( + args.payload.user?.id, + "Slack app home disconnect payload", + ); + + await withSpan( + "chat.app_home_disconnect", + "chat.app_home_disconnect", + { userId: userId }, + async () => { + try { + await unlinkProvider( + userId, + provider, + args.userTokenStore, + getWorkspaceTeamId(), + ); + } catch (error) { + logException(error, "app_home.disconnect_unlink.failed", { + "app.credential.provider": provider, + }); + } + + try { + await publishAppHomeView(getSlackClient(), userId, args.userTokenStore); + } catch (error) { + logException(error, "app_home.disconnect_publish.failed", { + "app.credential.provider": provider, + }); + } + }, + ); +} + +function installationFromInteractive( + payload: SlackInteractivePayload, +): SlackInstallationContext { + return { + teamId: payload.team?.id ?? payload.user?.team_id, + }; +} + +/** Handle a Slack slash command or interactive payload form. */ +export async function handleSlackForm(args: { + adapter: SlackAdapter; + body: string; + getUserTokenStore: () => UserTokenStore; + state: StateAdapter; + waitUntil: WaitUntilFn; +}): Promise { + const params = new URLSearchParams(args.body); + const { adapter, state } = args; + await state.connect(); + + if (params.has("command") && !params.has("payload")) { + const result = slackSlashCommandSchema.safeParse( + Object.fromEntries(params), + ); + if (!result.success) { + return new Response("Invalid slash command payload", { status: 400 }); + } + const form = result.data; + const installation: SlackInstallationContext = { + teamId: form.team_id, + enterpriseId: form.enterprise_id, + isEnterpriseInstall: form.is_enterprise_install === "true", + }; + args.waitUntil( + withLogContext( + { + platform: "slack", + userId: form.user_id, + }, + () => + runWithWorkspaceTeamId(installation.teamId, () => + runWithSlackInstallation({ + adapter, + installation, + state, + task: () => + handleSlashCommandForm({ + adapter, + form, + state, + }), + }), + ).catch((error) => { + logException(error, "slash_command.failed"); + }), + ), + ); + return new Response("", { status: 200 }); + } + + const rawPayload = params.get("payload"); + if (!rawPayload) { + return new Response("Missing payload", { status: 400 }); + } + const result = slackInteractivePayloadSchema.safeParse(parseJson(rawPayload)); + if (!result.success) { + return new Response("Invalid payload JSON", { status: 400 }); + } + const payload = result.data; + const installation = installationFromInteractive(payload); + + args.waitUntil( + withLogContext( + { + platform: "slack", + userId: payload.user?.id?.trim() || undefined, + }, + () => + runWithWorkspaceTeamId(installation.teamId, () => + runWithSlackInstallation({ + adapter, + installation, + state, + task: () => + handleInteractivePayload({ + payload, + userTokenStore: args.getUserTokenStore(), + }), + }), + ).catch((error) => { + logException(error, "slack.interactive.payload.failed"); + }), + ), + ); + return new Response("", { status: 200 }); +} diff --git a/packages/junior/src/chat/ingress/slack-sign-in.ts b/packages/junior/src/chat/ingress/slack-sign-in.ts index 6425d3816e..36c4edd388 100644 --- a/packages/junior/src/chat/ingress/slack-sign-in.ts +++ b/packages/junior/src/chat/ingress/slack-sign-in.ts @@ -1,10 +1,9 @@ -/** Require Google sign-in before a Slack user can start a Turn. */ +/** Require a recent Google sign-in before a Slack user can start a Turn. */ import { botConfig } from "@/chat/config"; import { getSqlExecutor } from "@/chat/db"; -import { isExperimentalFeatureEnabled } from "@/chat/experimental"; import type { SlackInboundEvent } from "@/chat/ingress/slack-payload"; import { - slackUserHasGoogleIdentity, + slackUserHasGoogleSignIn, upsertIdentity, } from "@/chat/identities/sql"; import type { SlackInstallationContext } from "@/chat/slack/adapter-context"; @@ -12,29 +11,38 @@ import { parseSlackUserId } from "@/chat/slack/ids"; import { formatSlackLink } from "@/chat/slack/mrkdwn"; import { lookupSlackUserProfile } from "@/chat/slack/users"; import { deliverPrivateMessage } from "@/chat/oauth-flow"; -import { createSlackSignInUrl } from "@/chat/sign-in"; +import { createSlackSignInUrl, type SlackSignIn } from "@/chat/sign-in/slack"; import type { StateAdapter } from "chat"; /** * Return whether this Slack user may start work. * - * The user of the Slack identity must have a linked Google identity. When it - * does not, save the Slack identity with the email from the Slack profile, so - * the sign-in callback can match it. Then send the user a private sign-in link - * and drop the message. + * The user of the Slack identity must have signed in with Google, in Slack or + * in the dashboard, within `signIn.maxAgeMs`. When it has not, save the Slack + * identity with the email from the Slack profile, so the sign-in page can match + * it. Then send the user a private sign-in link and drop the message. */ export async function hasSlackSignIn(args: { event: SlackInboundEvent; installation: SlackInstallationContext; + signIn: SlackSignIn | undefined; state: StateAdapter; }): Promise { - if (!isExperimentalFeatureEnabled("slack-sign-in")) return true; + if (!args.signIn) return true; const teamId = args.installation.teamId; const channelId = args.event.channel; const userId = parseSlackUserId(args.event.user); if (!teamId || !channelId || !userId) return false; const executor = getSqlExecutor(); - if (await slackUserHasGoogleIdentity(executor, teamId, userId)) return true; + if ( + await slackUserHasGoogleSignIn(executor, { + signedInSince: new Date(Date.now() - args.signIn.maxAgeMs), + slackTeamId: teamId, + slackUserId: userId, + }) + ) { + return true; + } const profile = await lookupSlackUserProfile(userId); await upsertIdentity(executor, { @@ -51,6 +59,7 @@ export async function hasSlackSignIn(args: { }); const link = await createSlackSignInUrl({ + baseURL: args.signIn.baseURL, slackTeamId: teamId, slackUserId: userId, state: args.state, @@ -59,7 +68,7 @@ export async function hasSlackSignIn(args: { channelId, ...(args.event.thread_ts ? { threadTs: args.event.thread_ts } : undefined), userId, - text: `${formatSlackLink(link, `Sign in to use ${botConfig.userName}`)}. Sign in once with your work Google account, then send your message again.`, + text: `${formatSlackLink(link, `Sign in to use ${botConfig.userName}`)}. Sign in with your work Google account, then send your message again.`, }); return false; } diff --git a/packages/junior/src/chat/ingress/slack-webhook.ts b/packages/junior/src/chat/ingress/slack-webhook.ts index ae767f5be8..b7afc5544a 100644 --- a/packages/junior/src/chat/ingress/slack-webhook.ts +++ b/packages/junior/src/chat/ingress/slack-webhook.ts @@ -3,14 +3,11 @@ import type { SlackAdapter } from "@chat-adapter/slack"; import { slackAssistantThreadSchema, slackEventEnvelopeSchema, - slackInteractivePayloadSchema, - slackSlashCommandSchema, - type SlackSlashCommandForm, type SlackEventEnvelope, type SlackInboundEvent, - type SlackInteractivePayload, } from "./slack-payload"; -import { ChannelImpl, ThreadImpl, type Message, type StateAdapter } from "chat"; +import { handleSlackForm } from "./slack-form"; +import { ThreadImpl, type Message, type StateAdapter } from "chat"; import type { SlackTurnRuntime } from "@/chat/providers/slack/runtime"; import { THREAD_OPTOUT_ACK } from "@/chat/providers/slack/runtime"; import { @@ -63,17 +60,13 @@ import { } from "@/chat/ingress/message-router"; import { isSlackWorkspaceMember } from "@/chat/ingress/workspace-membership"; import { hasSlackSignIn } from "@/chat/ingress/slack-sign-in"; +import type { SlackSignIn } from "@/chat/sign-in/slack"; import { slackMessageAttributes } from "./slack-message-telemetry"; -import { - getWorkspaceTeamId, - runWithWorkspaceTeamId, -} from "@/chat/slack/workspace-context"; +import { runWithWorkspaceTeamId } from "@/chat/slack/workspace-context"; import { parseSlackThreadId } from "@/chat/slack/context"; import { getStateAdapter } from "@/chat/state/adapter"; -import { handleSlashCommand } from "@/chat/ingress/slash-command"; import { parseActorUserId } from "@/chat/actor"; import { createUserTokenStore } from "@/chat/capabilities/factory"; -import { unlinkProvider } from "@/chat/credentials/unlink-provider"; import type { UserTokenStore } from "@/chat/credentials/user-token-store"; import { publishAppHomeView } from "@/chat/slack/app-home"; import { presentSlackAutomationDetails } from "@/chat/slack/automation-details"; @@ -143,6 +136,8 @@ export interface SlackWebhookServices { getSlackAdapter: () => SlackAdapter; queue: ConversationWorkQueue; conversationStore?: ConversationStore; + /** Set when Slack use needs a recent Google sign-in. */ + signIn?: SlackSignIn; runtime: Pick< SlackTurnRuntime, | "handleAssistantContextChanged" @@ -454,6 +449,7 @@ async function routeParsedMessage(args: { conversationStore?: ConversationStore; queue: ConversationWorkQueue; receivedAtMs: number; + signIn: SlackSignIn | undefined; state: StateAdapter; }): Promise { if (shouldIgnoreMessage(args.message)) { @@ -715,6 +711,7 @@ async function handleSlackEvent(args: { conversationStore: args.services.conversationStore, queue: args.services.queue, receivedAtMs, + signIn: args.services.signIn, state, }), { keepAlive: true, waitMs: 10_000 }, @@ -728,189 +725,6 @@ async function handleSlackEvent(args: { ); } -function requireSlackPayloadUserId( - value: string | null | undefined, - source: string, -): string { - const userId = parseActorUserId(value); - if (!userId) { - throw new Error(`${source} is missing a Slack user id`); - } - return userId; -} - -async function handleSlashCommandForm(args: { - adapter: SlackAdapter; - form: SlackSlashCommandForm; - state: StateAdapter; -}): Promise { - const { channel_id: channelId, team_id: teamId, user_id: userId } = args.form; - const channel = new ChannelImpl({ - id: `slack:${channelId}`, - adapter: args.adapter, - stateAdapter: args.state, - }); - await withSpan( - "chat.slash_command", - "chat.slash_command", - { userId: userId }, - async () => { - await handleSlashCommand({ - channel, - channelId, - teamId, - text: args.form.text, - userId, - }); - }, - ); -} - -async function handleInteractivePayload(args: { - payload: SlackInteractivePayload; - userTokenStore: UserTokenStore; -}): Promise { - if (args.payload.type !== "block_actions") { - return; - } - const action = args.payload.actions?.find( - (candidate) => candidate.action_id === "app_home_disconnect", - ); - const provider = action?.selected_option?.value ?? action?.value; - if (!provider) { - return; - } - const userId = requireSlackPayloadUserId( - args.payload.user?.id, - "Slack app home disconnect payload", - ); - - await withSpan( - "chat.app_home_disconnect", - "chat.app_home_disconnect", - { userId: userId }, - async () => { - try { - await unlinkProvider( - userId, - provider, - args.userTokenStore, - getWorkspaceTeamId(), - ); - } catch (error) { - logException(error, "app_home.disconnect_unlink.failed", { - "app.credential.provider": provider, - }); - } - - try { - await publishAppHomeView(getSlackClient(), userId, args.userTokenStore); - } catch (error) { - logException(error, "app_home.disconnect_publish.failed", { - "app.credential.provider": provider, - }); - } - }, - ); -} - -function installationFromInteractive( - payload: SlackInteractivePayload, -): SlackInstallationContext { - return { - teamId: payload.team?.id ?? payload.user?.team_id, - }; -} - -async function handleSlackForm(args: { - body: string; - services: SlackWebhookServices; - waitUntil: WaitUntilFn; -}): Promise { - const params = new URLSearchParams(args.body); - const adapter = args.services.getSlackAdapter(); - const state = args.services.state ?? getStateAdapter(); - await state.connect(); - - if (params.has("command") && !params.has("payload")) { - const result = slackSlashCommandSchema.safeParse( - Object.fromEntries(params), - ); - if (!result.success) { - return new Response("Invalid slash command payload", { status: 400 }); - } - const form = result.data; - const installation: SlackInstallationContext = { - teamId: form.team_id, - enterpriseId: form.enterprise_id, - isEnterpriseInstall: form.is_enterprise_install === "true", - }; - enqueue( - args.waitUntil, - withLogContext( - { - platform: "slack", - userId: form.user_id, - }, - () => - runWithWorkspaceTeamId(installation.teamId, () => - runWithSlackInstallation({ - adapter, - installation, - state, - task: () => - handleSlashCommandForm({ - adapter, - form, - state, - }), - }), - ).catch((error) => { - logException(error, "slash_command.failed"); - }), - ), - ); - return new Response("", { status: 200 }); - } - - const rawPayload = params.get("payload"); - if (!rawPayload) { - return new Response("Missing payload", { status: 400 }); - } - const result = slackInteractivePayloadSchema.safeParse(parseJson(rawPayload)); - if (!result.success) { - return new Response("Invalid payload JSON", { status: 400 }); - } - const payload = result.data; - const installation = installationFromInteractive(payload); - - enqueue( - args.waitUntil, - withLogContext( - { - platform: "slack", - userId: payload.user?.id?.trim() || undefined, - }, - () => - runWithWorkspaceTeamId(installation.teamId, () => - runWithSlackInstallation({ - adapter, - installation, - state, - task: () => - handleInteractivePayload({ - payload, - userTokenStore: getUserTokenStore(args.services), - }), - }), - ).catch((error) => { - logException(error, "slack.interactive.payload.failed"); - }), - ), - ); - return new Response("", { status: 200 }); -} - /** Handle Slack webhooks by enqueueing durable conversation work. */ export async function handleSlackWebhook(args: { request: Request; @@ -927,8 +741,10 @@ export async function handleSlackWebhook(args: { const contentType = args.request.headers.get("content-type") || ""; if (contentType.includes("application/x-www-form-urlencoded")) { return await handleSlackForm({ + adapter, body, - services: args.services, + getUserTokenStore: () => getUserTokenStore(args.services), + state: args.services.state ?? getStateAdapter(), waitUntil: args.waitUntil, }); } diff --git a/packages/junior/src/chat/sign-in.ts b/packages/junior/src/chat/sign-in.ts deleted file mode 100644 index a54dec5d22..0000000000 --- a/packages/junior/src/chat/sign-in.ts +++ /dev/null @@ -1,269 +0,0 @@ -/** - * Google sign-in for people who use Junior. - * - * Junior owns this OAuth flow, so it works without the dashboard. Slack sends - * a person here the first time they mention Junior. The callback checks the - * Google account against the same allowlist as the dashboard. Then it links - * the Google identity to the person's user, so later checks can reuse it. - */ -import { createHash, randomBytes } from "node:crypto"; -import type { StateAdapter } from "chat"; -import { z } from "zod"; -import { botConfig } from "@/chat/config"; -import { getSqlExecutor } from "@/chat/db"; -import { GOOGLE_IDENTITY_PROVIDER } from "@/chat/identities/identity"; -import { - findSlackIdentityUserId, - upsertLinkedIdentity, -} from "@/chat/identities/sql"; -import { findUserByEmailFromSql } from "@/chat/plugins/viewer"; -import { resolveBaseUrl } from "@/chat/oauth-flow"; -import { escapeXml } from "@/chat/xml"; -import { htmlCallbackResponse } from "@/handlers/oauth-html"; - -export const SIGN_IN_CALLBACK_PATH = "/api/sign-in/callback"; - -const AUTHORIZE_ENDPOINT = "https://accounts.google.com/o/oauth2/v2/auth"; -const TOKEN_ENDPOINT = "https://oauth2.googleapis.com/token"; -const GOOGLE_ISSUERS = new Set([ - "accounts.google.com", - "https://accounts.google.com", -]); -const SIGN_IN_STATE_TTL_MS = 10 * 60 * 1000; - -/** Google accounts that may sign in. Empty lists allow nobody. */ -export interface SignInAllowlist { - domains: string[]; - emails: string[]; -} - -const signInStateSchema = z.object({ - codeVerifier: z.string().min(1), - slackTeamId: z.string().min(1), - slackUserId: z.string().min(1), -}); - -const tokenResponseSchema = z.object({ id_token: z.string().min(1) }); - -const idTokenClaimsSchema = z.object({ - aud: z.string(), - email: z.string(), - email_verified: z.boolean().optional(), - iss: z.string(), - sub: z.string().min(1), -}); - -function stateKey(state: string): string { - return `sign-in:v1:${state}`; -} - -function clientCredentials(): { clientId: string; clientSecret: string } { - const clientId = process.env.GOOGLE_CLIENT_ID?.trim(); - const clientSecret = process.env.GOOGLE_CLIENT_SECRET?.trim(); - if (!clientId || !clientSecret) { - throw new Error( - "Sign-in requires GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET", - ); - } - return { clientId, clientSecret }; -} - -function redirectUri(): string { - const baseUrl = resolveBaseUrl(); - if (!baseUrl) { - throw new Error("Sign-in requires JUNIOR_BASE_URL or a Vercel deployment"); - } - return `${baseUrl}${SIGN_IN_CALLBACK_PATH}`; -} - -// Read a list the same way as the dashboard: a JSON string array or comma -// separated values. -function readEnvList(name: string): string[] { - const value = process.env[name]?.trim() ?? ""; - if (!value.startsWith("[")) return value.split(","); - let parsed: unknown; - try { - parsed = JSON.parse(value); - } catch (error) { - throw new Error(`${name} must be a JSON string array`, { cause: error }); - } - const list = z.array(z.string()).safeParse(parsed); - if (!list.success) throw new Error(`${name} must be a JSON string array`); - return list.data; -} - -/** - * Resolve the sign-in allowlist. Use the same values as the dashboard: its - * options when set, else its environment variables. - */ -export function resolveSignInAllowlist( - options: { allowedEmails?: string[]; allowedGoogleDomains?: string[] } = {}, -): SignInAllowlist { - const list = (values: string[] | undefined, name: string) => - (values ?? readEnvList(name)) - .map((value) => value.trim().toLowerCase()) - .filter(Boolean); - return { - domains: list( - options.allowedGoogleDomains, - "JUNIOR_DASHBOARD_GOOGLE_DOMAINS", - ), - emails: list(options.allowedEmails, "JUNIOR_DASHBOARD_ALLOWED_EMAILS"), - }; -} - -function isAllowed(email: string, allowlist: SignInAllowlist): boolean { - const domain = email.slice(email.lastIndexOf("@") + 1); - return allowlist.emails.includes(email) || allowlist.domains.includes(domain); -} - -/** Start one Google sign-in for a Slack user and return the consent URL. */ -export async function createSlackSignInUrl(args: { - slackTeamId: string; - slackUserId: string; - state: StateAdapter; -}): Promise { - const { clientId } = clientCredentials(); - const state = randomBytes(24).toString("base64url"); - const codeVerifier = randomBytes(32).toString("base64url"); - await args.state.set( - stateKey(state), - { - codeVerifier, - slackTeamId: args.slackTeamId, - slackUserId: args.slackUserId, - } satisfies z.input, - SIGN_IN_STATE_TTL_MS, - ); - const url = new URL(AUTHORIZE_ENDPOINT); - url.search = new URLSearchParams({ - client_id: clientId, - code_challenge: createHash("sha256") - .update(codeVerifier) - .digest("base64url"), - code_challenge_method: "S256", - prompt: "select_account", - redirect_uri: redirectUri(), - response_type: "code", - scope: "openid email", - state, - }).toString(); - return url.toString(); -} - -function page(title: string, message: string, status: number): Response { - return htmlCallbackResponse(escapeXml(title), escapeXml(message), status); -} - -// The token response comes straight from Google over TLS, so the identity -// token claims are trusted without a signature check (OpenID Connect Core -// 3.1.3.7). -async function verifiedGoogleAccount( - code: string, - codeVerifier: string, -): Promise<{ email: string; subject: string } | undefined> { - const { clientId, clientSecret } = clientCredentials(); - const response = await fetch(TOKEN_ENDPOINT, { - body: new URLSearchParams({ - client_id: clientId, - client_secret: clientSecret, - code, - code_verifier: codeVerifier, - grant_type: "authorization_code", - redirect_uri: redirectUri(), - }), - headers: { "Content-Type": "application/x-www-form-urlencoded" }, - method: "POST", - }); - if (!response.ok) return undefined; - const tokens = tokenResponseSchema.safeParse(await response.json()); - if (!tokens.success) return undefined; - const payload = tokens.data.id_token.split(".")[1] ?? ""; - let claims: z.output; - try { - claims = idTokenClaimsSchema.parse( - JSON.parse(Buffer.from(payload, "base64url").toString("utf8")), - ); - } catch { - return undefined; - } - if ( - !GOOGLE_ISSUERS.has(claims.iss) || - claims.aud !== clientId || - claims.email_verified !== true - ) { - return undefined; - } - return { email: claims.email.trim().toLowerCase(), subject: claims.sub }; -} - -/** - * Finish one Google sign-in and link the Google identity to the user of the - * Slack identity that started it. - */ -export async function handleSignInCallback(args: { - allowlist: SignInAllowlist; - request: Request; - state: StateAdapter; -}): Promise { - const url = new URL(args.request.url); - const stateParam = url.searchParams.get("state"); - const code = url.searchParams.get("code"); - const stored = stateParam - ? signInStateSchema.safeParse(await args.state.get(stateKey(stateParam))) - : undefined; - if (!stateParam || !code || !stored?.success) { - return page( - "Sign-in link expired", - `Mention ${botConfig.userName} in Slack again to get a new link.`, - 400, - ); - } - // One state starts one sign-in. - await args.state.delete(stateKey(stateParam)); - - const account = await verifiedGoogleAccount(code, stored.data.codeVerifier); - if (!account) { - return page( - "Sign-in failed", - "Google did not return a verified account. Try again.", - 400, - ); - } - const { email } = account; - if (!isAllowed(email, args.allowlist)) { - return page( - "Account not allowed", - `${email} cannot use ${botConfig.userName}. Sign in with your work account.`, - 403, - ); - } - // Link only when the Slack identity's user owns this Google email. A - // forwarded link then cannot give another person's Slack account access. - const executor = getSqlExecutor(); - const userId = await findSlackIdentityUserId( - executor, - stored.data.slackTeamId, - stored.data.slackUserId, - ); - const user = await findUserByEmailFromSql(executor.db(), email); - if (!userId || user?.id !== userId) { - return page( - "Slack account does not match", - `Your Slack email is not ${email}. Sign in with the account that matches your Slack email.`, - 403, - ); - } - await upsertLinkedIdentity(executor, userId, { - kind: "user", - provider: GOOGLE_IDENTITY_PROVIDER, - providerSubjectId: account.subject, - email, - emailVerified: true, - }); - return page( - "You are signed in", - "Return to Slack and send your message again.", - 200, - ); -} diff --git a/packages/junior/src/chat/sign-in/allowlist.ts b/packages/junior/src/chat/sign-in/allowlist.ts new file mode 100644 index 0000000000..d0089fe6a3 --- /dev/null +++ b/packages/junior/src/chat/sign-in/allowlist.ts @@ -0,0 +1,56 @@ +/** Google accounts that may sign in. The dashboard uses the same list. */ +import { z } from "zod"; + +/** Google accounts that may sign in. Empty lists allow nobody. */ +export interface SignInAllowlist { + domains: string[]; + emails: string[]; +} + +/** + * Read a list env var the same way as the dashboard: a JSON string array or + * comma-separated values. + */ +export function readEnvList(name: string): string[] | undefined { + const value = process.env[name]?.trim(); + if (!value) return undefined; + if (!value.startsWith("[")) return value.split(","); + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new Error(`${name} must be a JSON string array`, { cause: error }); + } + const list = z.array(z.string()).safeParse(parsed); + if (!list.success) throw new Error(`${name} must be a JSON string array`); + return list.data; +} + +/** + * Resolve the sign-in allowlist. Use the same values as the dashboard: its + * options when set, else its environment variables. + */ +export function resolveSignInAllowlist( + options: { allowedEmails?: string[]; allowedGoogleDomains?: string[] } = {}, +): SignInAllowlist { + const list = (values: string[] | undefined, name: string) => + (values ?? readEnvList(name) ?? []) + .map((value) => value.trim().toLowerCase()) + .filter(Boolean); + return { + domains: list( + options.allowedGoogleDomains, + "JUNIOR_DASHBOARD_GOOGLE_DOMAINS", + ), + emails: list(options.allowedEmails, "JUNIOR_DASHBOARD_ALLOWED_EMAILS"), + }; +} + +/** Return whether a normalized Google email is on the allowlist. */ +export function isSignInAllowed( + email: string, + allowlist: SignInAllowlist, +): boolean { + const domain = email.slice(email.lastIndexOf("@") + 1); + return allowlist.emails.includes(email) || allowlist.domains.includes(domain); +} diff --git a/packages/junior/src/chat/sign-in/auth.ts b/packages/junior/src/chat/sign-in/auth.ts new file mode 100644 index 0000000000..81bd3316fa --- /dev/null +++ b/packages/junior/src/chat/sign-in/auth.ts @@ -0,0 +1,217 @@ +/** + * Google sign-in session shared by the dashboard and Slack. + * + * Better Auth runs the Google OAuth flow and keeps the browser session in an + * encrypted cookie. Each sign-in also saves the native Google identity with its + * sign-in time, so Slack can check a recent sign-in without the browser. + */ +import { createAuthMiddleware } from "better-auth/api"; +import { betterAuth } from "better-auth/minimal"; +import { getSqlExecutor } from "@/chat/db"; +import { normalizeIdentityEmail } from "@/chat/identities/identity"; +import { recordGoogleSignIn } from "@/chat/identities/sql"; +import { resolveBaseUrl } from "@/chat/oauth-flow"; +import { + isSignInAllowed, + readEnvList, + resolveSignInAllowlist, + type SignInAllowlist, +} from "./allowlist"; + +const DEFAULT_AUTH_PATH = "/api/auth"; +const DEFAULT_SESSION_MAX_AGE_SECONDS = 60 * 60 * 8; + +/** Verified Google account of one browser session. */ +export interface SignInSession { + user: { email: string; emailVerified: boolean; name?: string }; +} + +/** Browser sign-in routes and session reads. */ +export interface SignInAuth { + handler(request: Request): Promise; + getSession(request: Request): Promise; + signInWithGoogle( + request: Request, + callbackURL: string, + errorCallbackURL?: string, + ): Promise; +} + +/** Settings for the shared Google sign-in session. */ +export interface SignInAuthConfig { + agentName: string; + allowlist: SignInAllowlist; + authPath: string; + baseURL: string; + googleClientId?: string; + googleClientSecret?: string; + secret?: string; + sessionMaxAgeSeconds?: number; + trustedOrigins: string[]; +} + +/** Dashboard options that also configure the sign-in session. */ +export interface SignInAuthOptions { + allowedEmails?: string[]; + allowedGoogleDomains?: string[]; + authPath?: string; + baseURL?: string; + sessionMaxAgeSeconds?: number; + trustedOrigins?: string[]; +} + +function withHttps(host: string): string { + return /^https?:\/\//.test(host) ? host : `https://${host}`; +} + +/** Resolve sign-in settings from dashboard options and environment. */ +export function resolveSignInAuthConfig( + agentName: string, + options: SignInAuthOptions = {}, +): SignInAuthConfig { + const authPath = `/${(options.authPath?.trim() || DEFAULT_AUTH_PATH).replace(/^\/+|\/+$/g, "")}`; + const baseURL = withHttps( + options.baseURL?.trim() || resolveBaseUrl() || "http://localhost:3000", + ).replace(/\/+$/, ""); + return { + agentName, + allowlist: resolveSignInAllowlist(options), + authPath, + baseURL, + sessionMaxAgeSeconds: options.sessionMaxAgeSeconds, + trustedOrigins: + options.trustedOrigins ?? + readEnvList("JUNIOR_DASHBOARD_TRUSTED_ORIGINS") ?? + [], + }; +} + +function required(value: string | undefined, name: string): string { + if (!value?.trim()) { + throw new Error(`${name} is required for Junior sign-in`); + } + return value.trim(); +} + +function createBetterAuth(config: SignInAuthConfig) { + const secret = required( + config.secret ?? + process.env.BETTER_AUTH_SECRET ?? + process.env.JUNIOR_SECRET, + "JUNIOR_SECRET or BETTER_AUTH_SECRET", + ); + const sessionMaxAgeSeconds = + config.sessionMaxAgeSeconds ?? DEFAULT_SESSION_MAX_AGE_SECONDS; + // Google rejects other accounts when `hd` is set, so set it only when the + // allowlist is one domain. + const { domains, emails } = config.allowlist; + const hostedDomain = + domains.length === 1 && emails.length === 0 ? domains[0] : undefined; + return betterAuth({ + appName: config.agentName, + baseURL: config.baseURL, + basePath: config.authPath, + secret, + trustedOrigins: config.trustedOrigins, + socialProviders: { + google: { + clientId: required( + config.googleClientId ?? process.env.GOOGLE_CLIENT_ID, + "GOOGLE_CLIENT_ID", + ), + clientSecret: required( + config.googleClientSecret ?? process.env.GOOGLE_CLIENT_SECRET, + "GOOGLE_CLIENT_SECRET", + ), + hd: hostedDomain, + prompt: "select_account", + mapProfileToUser(profile) { + return { + email: profile.email, + emailVerified: profile.email_verified, + image: profile.picture, + name: profile.name, + }; + }, + }, + }, + account: { + storeStateStrategy: "cookie", + storeAccountCookie: false, + updateAccountOnSignIn: false, + }, + session: { + expiresIn: sessionMaxAgeSeconds, + disableSessionRefresh: true, + cookieCache: { + enabled: true, + strategy: "jwe", + maxAge: sessionMaxAgeSeconds, + refreshCache: false, + }, + }, + hooks: { + // Save each allowed Google sign-in, so Slack can check it later. + after: createAuthMiddleware(async (ctx) => { + const created = ctx.context.newSession; + if (!created || !ctx.path.startsWith("/callback/")) return; + const email = normalizeIdentityEmail(created.user.email); + if ( + !created.user.emailVerified || + !email || + !isSignInAllowed(email, config.allowlist) + ) { + return; + } + const accounts = await ctx.context.internalAdapter.findAccounts( + created.user.id, + ); + const google = accounts.find( + (account) => account.providerId === "google", + ); + if (!google) { + throw new Error("Google sign-in has no Google account"); + } + await recordGoogleSignIn(getSqlExecutor(), { + email, + subject: google.accountId, + displayName: created.user.name || undefined, + }); + }), + }, + }); +} + +/** + * Create the shared Google sign-in session. Better Auth starts on first use, + * so apps that never sign in need no Google credentials. + */ +export function createSignInAuth(config: SignInAuthConfig): SignInAuth { + let auth: ReturnType | undefined; + const getAuth = () => (auth ??= createBetterAuth(config)); + return { + handler(request) { + return getAuth().handler(request); + }, + async getSession(request) { + const session = await getAuth().api.getSession({ + headers: request.headers, + }); + if (!session) return null; + const { email, emailVerified, name } = session.user; + return { user: { email, emailVerified, name } }; + }, + async signInWithGoogle(request, callbackURL, errorCallbackURL) { + const result = await getAuth().api.signInSocial({ + body: { provider: "google", callbackURL, errorCallbackURL }, + headers: request.headers, + returnHeaders: true, + }); + if (!("url" in result.response) || !result.response.url) { + throw new Error("Google sign-in did not return a redirect URL"); + } + result.headers.set("location", result.response.url); + return new Response(null, { status: 302, headers: result.headers }); + }, + }; +} diff --git a/packages/junior/src/chat/sign-in/slack.ts b/packages/junior/src/chat/sign-in/slack.ts new file mode 100644 index 0000000000..6de94e44a3 --- /dev/null +++ b/packages/junior/src/chat/sign-in/slack.ts @@ -0,0 +1,163 @@ +/** + * Slack sign-in through the shared Google sign-in session. + * + * Slack sends a person here when they have no recent Google sign-in. The page + * uses the browser session that the dashboard also uses. When the person has + * no session, or an old one, it starts a Google sign-in and comes back here. + * The sign-in counts only when its Google email belongs to the user of the + * Slack identity that got the link. + */ +import { randomBytes } from "node:crypto"; +import type { StateAdapter } from "chat"; +import { z } from "zod"; +import { botConfig } from "@/chat/config"; +import { getSqlExecutor } from "@/chat/db"; +import { normalizeIdentityEmail } from "@/chat/identities/identity"; +import { + findSlackIdentityUserId, + slackUserHasGoogleSignIn, +} from "@/chat/identities/sql"; +import { findUserByEmailFromSql } from "@/chat/plugins/viewer"; +import { escapeXml } from "@/chat/xml"; +import { htmlCallbackResponse } from "@/handlers/oauth-html"; +import { isSignInAllowed, type SignInAllowlist } from "./allowlist"; +import type { SignInAuth } from "./auth"; + +export const SLACK_SIGN_IN_PATH = "/api/sign-in/slack"; + +/** Default time a Google sign-in lets a Slack user use Junior. */ +export const DEFAULT_SLACK_SIGN_IN_MAX_AGE_DAYS = 7; + +const SIGN_IN_STATE_TTL_MS = 10 * 60 * 1000; + +/** What Slack ingress needs to check and request sign-in. */ +export interface SlackSignIn { + baseURL: string; + maxAgeMs: number; +} + +const signInStateSchema = z.object({ + // Set after one Google sign-in, so a failed match cannot loop. + redirected: z.boolean(), + slackTeamId: z.string().min(1), + slackUserId: z.string().min(1), +}); + +function stateKey(state: string): string { + return `sign-in:v2:${state}`; +} + +function page(title: string, message: string, status: number): Response { + return htmlCallbackResponse(escapeXml(title), escapeXml(message), status); +} + +/** Store one Slack sign-in request and return its link. */ +export async function createSlackSignInUrl(args: { + baseURL: string; + slackTeamId: string; + slackUserId: string; + state: StateAdapter; +}): Promise { + const state = randomBytes(24).toString("base64url"); + await args.state.set( + stateKey(state), + { + redirected: false, + slackTeamId: args.slackTeamId, + slackUserId: args.slackUserId, + } satisfies z.input, + SIGN_IN_STATE_TTL_MS, + ); + const url = new URL(SLACK_SIGN_IN_PATH, args.baseURL); + url.searchParams.set("state", state); + return url.toString(); +} + +/** + * Finish a Slack sign-in when the browser has a recent Google sign-in for the + * user of the Slack identity. Otherwise start one Google sign-in. + */ +export async function handleSlackSignIn(args: { + allowlist: SignInAllowlist; + auth: SignInAuth; + request: Request; + signIn: SlackSignIn; + state: StateAdapter; +}): Promise { + const token = new URL(args.request.url).searchParams.get("state"); + const parsed = token + ? signInStateSchema.safeParse(await args.state.get(stateKey(token))) + : undefined; + if (!token || !parsed?.success) { + return page( + "Sign-in link expired", + `Mention ${botConfig.userName} in Slack again to get a new link.`, + 400, + ); + } + const stored = parsed.data; + const session = await args.auth.getSession(args.request); + const email = session?.user.emailVerified + ? normalizeIdentityEmail(session.user.email) + : undefined; + let failure: Response | undefined; + if (email && !isSignInAllowed(email, args.allowlist)) { + failure = page( + "Account not allowed", + `${email} cannot use ${botConfig.userName}. Sign in with your work account.`, + 403, + ); + } else if (email) { + const executor = getSqlExecutor(); + const userId = await findSlackIdentityUserId( + executor, + stored.slackTeamId, + stored.slackUserId, + ); + const user = await findUserByEmailFromSql(executor.db(), email); + if (!userId || user?.id !== userId) { + failure = page( + "Slack account does not match", + `Your Slack email is not ${email}. Sign in with the account that matches your Slack email.`, + 403, + ); + } else if ( + await slackUserHasGoogleSignIn(executor, { + signedInSince: new Date(Date.now() - args.signIn.maxAgeMs), + slackTeamId: stored.slackTeamId, + slackUserId: stored.slackUserId, + }) + ) { + await args.state.delete(stateKey(token)); + return page( + "You are signed in", + "Return to Slack and send your message again.", + 200, + ); + } + } + if (stored.redirected) { + return ( + failure ?? + page( + "Sign-in failed", + "Google sign-in did not finish. Sign in with your work account.", + 400, + ) + ); + } + // No session, another account, or an old sign-in: sign in with Google once. + await args.state.set( + stateKey(token), + { ...stored, redirected: true } satisfies z.input, + SIGN_IN_STATE_TTL_MS, + ); + // Come back to the public URL, not the URL this server received. + const returnUrl = new URL(SLACK_SIGN_IN_PATH, args.signIn.baseURL); + returnUrl.searchParams.set("state", token); + return await args.auth.signInWithGoogle( + args.request, + returnUrl.toString(), + returnUrl.toString(), + ); +} diff --git a/packages/junior/src/dashboard-routes.ts b/packages/junior/src/dashboard-routes.ts index a7a6591e25..3b003b326b 100644 --- a/packages/junior/src/dashboard-routes.ts +++ b/packages/junior/src/dashboard-routes.ts @@ -4,6 +4,7 @@ import { createRequire } from "node:module"; import { pathToFileURL } from "node:url"; import type { PluginRouteMethod, User } from "@sentry/junior-plugin-api"; import { botConfig } from "@/chat/config"; +import type { SignInAuth } from "@/chat/sign-in/auth"; import type { PluginApiRouteRegistration, PluginRouteRegistration, @@ -37,6 +38,8 @@ export interface JuniorDashboardOptions { export interface JuniorDashboardRuntimeOptions extends JuniorDashboardOptions { agentName?: string; + /** Google sign-in session that the dashboard shares with Slack. */ + auth?: SignInAuth; authenticatedRoutes?: readonly AuthenticatedRoute[]; conversationWorkQueue?: ConversationWorkQueue; pluginRoutes?: PluginApiRouteRegistration[]; @@ -66,6 +69,7 @@ const DASHBOARD_PACKAGE_NAME = "@sentry/junior-dashboard"; /** Mount the dashboard app on the dashboard-owned host paths. */ export async function createDashboardRouteRegistrations(args: { + auth: SignInAuth | undefined; authenticatedRoutes: readonly AuthenticatedRoute[]; conversationWorkQueue: ConversationWorkQueue; dashboard: JuniorDashboardOptions | undefined; @@ -79,6 +83,7 @@ export async function createDashboardRouteRegistrations(args: { const createDashboardApp = args.createDashboardApp ?? (await loadDashboardAppFactory()); return dashboardRouteRegistrations({ + auth: args.auth, authenticatedRoutes: args.authenticatedRoutes, conversationWorkQueue: args.conversationWorkQueue, dashboard: args.dashboard, @@ -305,6 +310,7 @@ function dashboardOwnedRoutePath( } function dashboardRouteRegistrations(args: { + auth: SignInAuth | undefined; authenticatedRoutes: readonly AuthenticatedRoute[]; conversationWorkQueue: ConversationWorkQueue; dashboard: JuniorDashboardOptions; @@ -316,6 +322,7 @@ function dashboardRouteRegistrations(args: { const dashboardOptions: JuniorDashboardRuntimeOptions = { ...args.dashboard, agentName: botConfig.userName, + ...(args.auth ? { auth: args.auth } : undefined), authenticatedRoutes: args.authenticatedRoutes, conversationWorkQueue: args.conversationWorkQueue, pluginRoutes: args.pluginRoutes, diff --git a/packages/junior/src/db/schema/identities.ts b/packages/junior/src/db/schema/identities.ts index 7a40695ced..72f960c4d3 100644 --- a/packages/junior/src/db/schema/identities.ts +++ b/packages/junior/src/db/schema/identities.ts @@ -29,6 +29,8 @@ export const juniorIdentities = pgTable( userId: text("user_id").references(() => juniorUsers.id), emailNormalized: text("email_normalized"), emailVerified: boolean("email_verified").notNull().default(false), + // Last Google sign-in for a native Google identity. Null otherwise. + signedInAt: timestamptz("signed_in_at"), }, (table) => [ uniqueIndex("junior_identities_provider_subject_uidx").on( diff --git a/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts b/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts index fb46af2168..0505b72c04 100644 --- a/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts +++ b/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts @@ -3,7 +3,8 @@ import { afterEach, describe, expect, it, vi } from "vitest"; import { http, HttpResponse } from "msw"; import { closeDb, getConversationEventStore } from "@/chat/db"; import { setExperimentalFeatures } from "@/chat/experimental"; -import { handleSignInCallback } from "@/chat/sign-in"; +import { createSignInAuth } from "@/chat/sign-in/auth"; +import { handleSlackSignIn } from "@/chat/sign-in/slack"; import { JuniorChat } from "@/chat/ingress/junior-chat"; import { runWithWorkspaceTeamId } from "@/chat/ingress/workspace-membership"; import { getConversationWorkState } from "@/chat/task-execution/store"; @@ -260,18 +261,31 @@ describe("Slack webhook auth boundary", () => { await state.disconnect(); }); - it("asks Slack users to sign in with Google before their first Turn", async () => { + it("asks Slack users for a recent Google sign-in shared with the dashboard", async () => { setExperimentalFeatures({ ...SUITE_EXPERIMENTAL, "slack-sign-in": true }); - vi.stubEnv("GOOGLE_CLIENT_ID", "google-client"); - vi.stubEnv("GOOGLE_CLIENT_SECRET", "google-secret"); - vi.stubEnv("JUNIOR_BASE_URL", "https://junior.example.com"); + const baseURL = "https://junior.example.com"; + // Two domains, so Google does not limit accounts with `hd`. + const allowlist = { domains: ["example.com", "example.org"], emails: [] }; + const auth = createSignInAuth({ + agentName: "junior", + allowlist, + authPath: "/api/auth", + baseURL, + googleClientId: "google-client", + googleClientSecret: "google-secret", + secret: "0123456789abcdef0123456789abcdef", + trustedOrigins: [], + }); + const signIn = { baseURL, maxAgeMs: 7 * 24 * 60 * 60 * 1000 }; let googleEmail = ""; + let googleVisits = 0; mswServer.use( http.post("https://oauth2.googleapis.com/token", () => { const claims = { aud: "google-client", email: googleEmail, email_verified: true, + hd: googleEmail.split("@")[1], iss: "https://accounts.google.com", sub: `google-${googleEmail}`, }; @@ -289,6 +303,7 @@ describe("Slack webhook auth boundary", () => { getSlackAdapter: () => adapter, queue, runtime: createNoopSlackWebhookRuntime(), + signIn, state, }; let sequence = 1; @@ -306,43 +321,112 @@ describe("Slack webhook auth boundary", () => { ), services, }); - // Each blocked message sends a new private link. Finish it as one account. - const signInAs = async (email: string) => { + const sendBlocked = async () => { + const prompts = getCapturedSlackApiCalls("chat.postMessage").length; expect((await send()).status).toBe(200); - const prompt = getCapturedSlackApiCalls("chat.postMessage").at(-1); - expect(prompt?.params).toMatchObject({ channel: "D123" }); - const link = new URL( - /<(https:\/\/accounts\.google\.com[^|>]+)/ - .exec(String(prompt?.params.text))![1]! - .replaceAll("&", "&"), - ); - expect(link.searchParams.get("redirect_uri")).toBe( - "https://junior.example.com/api/sign-in/callback", - ); - googleEmail = email; - const callback = new URL( - "https://junior.example.com/api/sign-in/callback", - ); - callback.searchParams.set("state", link.searchParams.get("state")!); - callback.searchParams.set("code", "google-code"); - return await handleSignInCallback({ - allowlist: { domains: ["example.com"], emails: [] }, - request: new Request(callback), - state, - }); + const calls = getCapturedSlackApiCalls("chat.postMessage"); + expect(calls).toHaveLength(prompts + 1); + expect(calls.at(-1)?.params).toMatchObject({ channel: "D123" }); + return /<([^|>]+)\|/ + .exec(String(calls.at(-1)?.params.text))![1]! + .replaceAll("&", "&"); + }; + // A browser with one cookie jar. Google signs in as `email`. + const createBrowser = () => { + const cookies = new Map(); + const request = (url: string) => + new Request(url, { + headers: { + cookie: [...cookies].map(([k, v]) => `${k}=${v}`).join("; "), + }, + }); + const route = async (url: URL): Promise => { + if (url.origin === "https://accounts.google.com") { + googleVisits += 1; + const back = new URL(url.searchParams.get("redirect_uri")!); + back.searchParams.set("state", url.searchParams.get("state")!); + back.searchParams.set("code", "google-code"); + return Response.redirect(back, 302); + } + if (url.pathname.startsWith("/api/auth/")) { + return await auth.handler(request(url.toString())); + } + if (url.pathname === "/api/sign-in/slack") { + return await handleSlackSignIn({ + allowlist, + auth, + request: request(url.toString()), + signIn, + state, + }); + } + // Like the dashboard login: use the session, or sign in with Google. + if (url.pathname === "/auth/login") { + if (await auth.getSession(request(url.toString()))) { + return Response.redirect(`${baseURL}/`, 302); + } + return await auth.signInWithGoogle( + request(url.toString()), + `${baseURL}/`, + ); + } + return new Response("dashboard"); + }; + const visit = async (start: string, email: string) => { + googleEmail = email; + let url = new URL(start); + for (;;) { + const response = await route(url); + for (const cookie of response.headers.getSetCookie()) { + const [pair] = cookie.split(";"); + const name = pair!.slice(0, pair!.indexOf("=")); + const value = pair!.slice(pair!.indexOf("=") + 1); + if (value && !/max-age=0/i.test(cookie)) cookies.set(name, value); + else cookies.delete(name); + } + const location = response.headers.get("location"); + if (!location) return response; + url = new URL(location, url); + } + }; + return Object.assign(visit, { cookies }); }; // Accounts outside the allowlist, or another person's account, fail. - expect((await signInAs("testuser@contractor.dev")).status).toBe(403); - expect((await signInAs("someone@example.com")).status).toBe(403); + expect( + (await createBrowser()(await sendBlocked(), "testuser@contractor.dev")) + .status, + ).toBe(403); + expect( + (await createBrowser()(await sendBlocked(), "someone@example.com")) + .status, + ).toBe(403); expect(queue.queuedMessages()).toEqual([]); - // The matching account links a Google identity that later checks reuse. - expect((await signInAs("testuser@example.com")).status).toBe(200); - const prompts = getCapturedSlackApiCalls("chat.postMessage").length; + // A dashboard sign-in by the Slack user's account also counts for Slack. + const browser = createBrowser(); + const home = await browser(`${baseURL}/auth/login`, "testuser@example.com"); + expect(await home.text()).toBe("dashboard"); + // The session cookie never carries the Google account or its tokens. + expect([...browser.cookies.keys()].join()).not.toContain("account"); expect((await send()).status).toBe(200); expect(queue.queuedMessages()).toHaveLength(1); - expect(getCapturedSlackApiCalls("chat.postMessage")).toHaveLength(prompts); + + // After the max age, Slack asks again, and the Slack page signs in. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 8 * 24 * 60 * 60 * 1000); + const link = await sendBlocked(); + const visits = googleVisits; + expect((await browser(link, "testuser@example.com")).status).toBe(200); + expect(googleVisits).toBe(visits + 1); + expect((await send()).status).toBe(200); + expect(queue.queuedMessages()).toHaveLength(2); + + // The dashboard uses the session from the Slack page. + expect( + await (await browser(`${baseURL}/auth/login`, "x@example.com")).text(), + ).toBe("dashboard"); + expect(googleVisits).toBe(visits + 1); await state.disconnect(); }); diff --git a/packages/junior/tests/unit/chat/sign-in-allowlist.test.ts b/packages/junior/tests/unit/chat/sign-in-allowlist.test.ts deleted file mode 100644 index fbbdb8e356..0000000000 --- a/packages/junior/tests/unit/chat/sign-in-allowlist.test.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { afterEach, describe, expect, it, vi } from "vitest"; -import { resolveSignInAllowlist } from "@/chat/sign-in"; - -afterEach(() => { - vi.unstubAllEnvs(); -}); - -describe("sign-in allowlist", () => { - it("reads dashboard env lists as JSON arrays or comma separated values", () => { - vi.stubEnv( - "JUNIOR_DASHBOARD_GOOGLE_DOMAINS", - '["Sentry.io", "example.com"]', - ); - vi.stubEnv( - "JUNIOR_DASHBOARD_ALLOWED_EMAILS", - "a@example.net, b@example.net", - ); - - expect(resolveSignInAllowlist()).toEqual({ - domains: ["sentry.io", "example.com"], - emails: ["a@example.net", "b@example.net"], - }); - }); - - it("rejects an env list that is not a JSON string array", () => { - vi.stubEnv("JUNIOR_DASHBOARD_GOOGLE_DOMAINS", "[1]"); - - expect(() => resolveSignInAllowlist()).toThrow( - "JUNIOR_DASHBOARD_GOOGLE_DOMAINS must be a JSON string array", - ); - }); -}); diff --git a/packages/junior/tests/unit/chat/sign-in-auth.test.ts b/packages/junior/tests/unit/chat/sign-in-auth.test.ts new file mode 100644 index 0000000000..0da9d70991 --- /dev/null +++ b/packages/junior/tests/unit/chat/sign-in-auth.test.ts @@ -0,0 +1,65 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveSignInAllowlist } from "@/chat/sign-in/allowlist"; +import { createSignInAuth } from "@/chat/sign-in/auth"; + +afterEach(() => { + vi.unstubAllEnvs(); +}); + +describe("sign-in allowlist", () => { + it("reads dashboard env lists as JSON arrays or comma separated values", () => { + vi.stubEnv( + "JUNIOR_DASHBOARD_GOOGLE_DOMAINS", + '["Sentry.io", "example.com"]', + ); + vi.stubEnv( + "JUNIOR_DASHBOARD_ALLOWED_EMAILS", + "a@example.net, b@example.net", + ); + + expect(resolveSignInAllowlist()).toEqual({ + domains: ["sentry.io", "example.com"], + emails: ["a@example.net", "b@example.net"], + }); + }); + + it("rejects an env list that is not a JSON string array", () => { + vi.stubEnv("JUNIOR_DASHBOARD_GOOGLE_DOMAINS", "[1]"); + + expect(() => resolveSignInAllowlist()).toThrow( + "JUNIOR_DASHBOARD_GOOGLE_DOMAINS must be a JSON string array", + ); + }); +}); + +describe("sign-in session", () => { + it("limits the Google account chooser only when the allowlist is one domain", async () => { + const googleUrl = async (domains: string[], emails: string[]) => { + const auth = createSignInAuth({ + agentName: "junior", + allowlist: { domains, emails }, + authPath: "/api/auth", + baseURL: "https://junior.example.com", + googleClientId: "google-client", + googleClientSecret: "google-secret", + secret: "0123456789abcdef0123456789abcdef", + trustedOrigins: [], + }); + const response = await auth.signInWithGoogle( + new Request("https://junior.example.com/auth/login"), + "https://junior.example.com/", + ); + expect(response.headers.get("set-cookie")).toContain("oauth_state"); + return new URL(response.headers.get("location")!); + }; + + expect((await googleUrl(["example.com"], [])).searchParams.get("hd")).toBe( + "example.com", + ); + expect( + ( + await googleUrl(["example.com"], ["person@partner.dev"]) + ).searchParams.has("hd"), + ).toBe(false); + }); +}); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d423599877..48ed42ec46 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -102,7 +102,7 @@ importers: version: 2.9.0(@opentelemetry/api@1.9.1) '@sentry/junior': specifier: workspace:* - version: file:packages/junior + version: file:packages/junior(@types/node@25.9.1) '@sentry/junior-agent-browser': specifier: workspace:* version: link:../../packages/junior-agent-browser @@ -111,7 +111,7 @@ importers: version: link:../../packages/junior-amplitude '@sentry/junior-dashboard': specifier: workspace:* - version: file:packages/junior-dashboard(@opentelemetry/api@1.9.1)(jiti@2.7.0) + version: file:packages/junior-dashboard(@types/node@25.9.1)(jiti@2.7.0) '@sentry/junior-datadog': specifier: workspace:* version: link:../../packages/junior-datadog @@ -254,6 +254,9 @@ importers: ai: specifier: 6.0.190 version: 6.0.190(zod@4.5.4) + better-auth: + specifier: ^1.6.22 + version: 1.6.22(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(pg@8.21.0))(pg@8.21.0)(vitest@4.1.7) chat: specifier: 4.29.0 version: 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) @@ -365,16 +368,13 @@ importers: version: 1.1.23(@types/react-dom@19.2.3(@types/react@19.2.15))(@types/react@19.2.15)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@sentry/junior': specifier: workspace:* - version: file:packages/junior + version: file:packages/junior(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7) '@sentry/junior-plugin-api': specifier: workspace:* version: link:../junior-plugin-api '@tanstack/react-query': specifier: ^5.100.14 version: 5.100.14(react@19.2.6) - better-auth: - specifier: ^1.6.22 - version: 1.6.22(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7) hono: specifier: ^4.12.27 version: 4.12.27 @@ -455,7 +455,7 @@ importers: version: 0.85.1(zod@4.5.4) '@sentry/junior': specifier: workspace:* - version: file:packages/junior + version: file:packages/junior(vitest@4.1.7(msw@2.14.6(typescript@6.0.3))(tsx@4.22.3)) '@sentry/junior-github': specifier: workspace:* version: file:packages/junior-github @@ -2703,6 +2703,7 @@ packages: '@opentelemetry/sdk-trace-base@2.9.0': resolution: {integrity: sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==} engines: {node: ^18.19.0 || >=20.6.0} + deprecated: Use @opentelemetry/sdk-trace package peerDependencies: '@opentelemetry/api': '>=1.3.0 <1.10.0' @@ -9773,38 +9774,23 @@ snapshots: optionalDependencies: '@opentelemetry/api': 1.9.1 - '@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1)': - dependencies: - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - '@opentelemetry/semantic-conventions': 1.43.0 - '@standard-schema/spec': 1.1.0 - better-call: 1.3.7(zod@4.5.4) - jose: 6.2.4 - kysely: 0.29.4 - nanostores: 1.4.1 - zod: 4.5.4 - - '@better-auth/drizzle-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': + '@better-auth/drizzle-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(pg@8.21.0))': dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 + optionalDependencies: + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(pg@8.21.0) - '@better-auth/drizzle-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': - dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/utils': 0.4.2 - - '@better-auth/kysely-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4)': + '@better-auth/drizzle-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))': dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 optionalDependencies: - kysely: 0.29.4 + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) - '@better-auth/kysely-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4)': + '@better-auth/kysely-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4)': dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 optionalDependencies: kysely: 0.29.4 @@ -9814,43 +9800,22 @@ snapshots: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 - '@better-auth/memory-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': - dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 - '@better-auth/mongo-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': - dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 - '@better-auth/prisma-adapter@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)': - dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/utils': 0.4.2 - '@better-auth/telemetry@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 - '@better-auth/telemetry@1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)': - dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/utils': 0.4.2 - '@better-fetch/fetch': 1.3.1 - '@better-auth/utils@0.4.2': dependencies: '@noble/hashes': 2.2.0 @@ -11844,13 +11809,12 @@ snapshots: dependencies: '@sentry/conventions': 0.15.1 - '@sentry/junior-dashboard@file:packages/junior-dashboard(@opentelemetry/api@1.9.1)(jiti@2.7.0)': + '@sentry/junior-dashboard@file:packages/junior-dashboard(@types/node@25.9.1)(jiti@2.7.0)': dependencies: '@radix-ui/react-hover-card': 1.1.23(react-dom@19.2.6(react@19.2.6))(react@19.2.6) - '@sentry/junior': file:packages/junior + '@sentry/junior': file:packages/junior(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) '@sentry/junior-plugin-api': file:packages/junior-plugin-api '@tanstack/react-query': 5.100.14(react@19.2.6) - better-auth: 1.6.22(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) hono: 4.12.27 lucide-react: 1.17.0(react@19.2.6) nitro: 3.0.260522-beta(jiti@2.7.0) @@ -11881,7 +11845,6 @@ snapshots: - '@netlify/runtime' - '@node-rs/xxhash' - '@op-engineering/op-sqlite' - - '@opentelemetry/api' - '@opentelemetry/exporter-trace-otlp-http' - '@planetscale/database' - '@prisma/client' @@ -11890,6 +11853,7 @@ snapshots: - '@tanstack/solid-start' - '@tidbcloud/serverless' - '@types/better-sqlite3' + - '@types/node' - '@types/pg' - '@types/react' - '@types/react-dom' @@ -11924,7 +11888,6 @@ snapshots: - mongodb - mysql2 - next - - pg - pg-native - postgres - prisma @@ -12283,7 +12246,195 @@ snapshots: - sql.js - sqlite3 - '@sentry/junior@file:packages/junior': + '@sentry/junior@file:packages/junior(@types/node@25.9.1)': + dependencies: + '@agentclientprotocol/sdk': 1.3.0(zod@4.5.4) + '@ai-sdk/gateway': 3.0.119(zod@4.5.4) + '@chat-adapter/slack': 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@chat-adapter/state-memory': 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@chat-adapter/state-redis': 4.29.0(@opentelemetry/api@1.9.1)(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@earendil-works/pi-agent-core': 0.84.1(@modelcontextprotocol/sdk@1.31.0(zod@4.5.4))(zod@4.5.4) + '@earendil-works/pi-ai': 0.84.1(@modelcontextprotocol/sdk@1.31.0(zod@4.5.4))(zod@4.5.4) + '@hono/zod-validator': 0.9.0(hono@4.12.27)(zod@4.5.4) + '@logtape/logtape': 2.1.1 + '@modelcontextprotocol/sdk': 1.31.0(zod@4.5.4) + '@neondatabase/serverless': 1.1.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@sentry/junior-plugin-api': file:packages/junior-plugin-api + '@sentry/node': 10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)) + '@sinclair/typebox': 0.34.49 + '@slack/types': 2.21.1 + '@slack/web-api': 7.16.0 + '@vercel/blob': 2.8.0 + '@vercel/functions': 3.6.0 + '@vercel/oidc': 3.4.1 + '@vercel/queue': 0.2.0 + '@vercel/sandbox': 2.8.0 + ai: 6.0.190(zod@4.5.4) + better-auth: 1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0) + chat: 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + commander: 14.0.3 + diff: 8.0.4 + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) + hono: 4.12.27 + jiti: 2.7.0 + jose: 6.2.3 + just-bash: 3.0.1 + node-html-markdown: 2.0.0 + pg: 8.21.0 + sharp: 0.35.3(@types/node@25.9.1) + yaml: 2.9.0 + zod: 4.5.4 + transitivePeerDependencies: + - '@aws-sdk/client-rds-data' + - '@aws-sdk/credential-provider-web-identity' + - '@cfworker/json-schema' + - '@cloudflare/workers-types' + - '@electric-sql/pglite' + - '@libsql/client' + - '@libsql/client-wasm' + - '@lynx-js/react' + - '@node-rs/xxhash' + - '@op-engineering/op-sqlite' + - '@opentelemetry/exporter-trace-otlp-http' + - '@planetscale/database' + - '@prisma/client' + - '@sveltejs/kit' + - '@tanstack/react-start' + - '@tanstack/solid-start' + - '@tidbcloud/serverless' + - '@types/better-sqlite3' + - '@types/node' + - '@types/pg' + - '@types/sql.js' + - '@upstash/redis' + - '@vercel/postgres' + - '@xata.io/client' + - bare-abort-controller + - better-sqlite3 + - bufferutil + - bun-types + - debug + - drizzle-kit + - expo-sqlite + - gel + - knex + - kysely + - mongodb + - mysql2 + - next + - pg-native + - postgres + - prisma + - react + - react-dom + - react-native-b4a + - solid-js + - sql.js + - sqlite3 + - supports-color + - svelte + - utf-8-validate + - vitest + - vue + - ws + + '@sentry/junior@file:packages/junior(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)': + dependencies: + '@agentclientprotocol/sdk': 1.3.0(zod@4.5.4) + '@ai-sdk/gateway': 3.0.119(zod@4.5.4) + '@chat-adapter/slack': 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@chat-adapter/state-memory': 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@chat-adapter/state-redis': 4.29.0(@opentelemetry/api@1.9.1)(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@earendil-works/pi-agent-core': 0.84.1(@modelcontextprotocol/sdk@1.31.0(zod@4.5.4))(zod@4.5.4) + '@earendil-works/pi-ai': 0.84.1(@modelcontextprotocol/sdk@1.31.0(zod@4.5.4))(zod@4.5.4) + '@hono/zod-validator': 0.9.0(hono@4.12.27)(zod@4.5.4) + '@logtape/logtape': 2.1.1 + '@modelcontextprotocol/sdk': 1.31.0(zod@4.5.4) + '@neondatabase/serverless': 1.1.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@sentry/junior-plugin-api': file:packages/junior-plugin-api + '@sentry/node': 10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)) + '@sinclair/typebox': 0.34.49 + '@slack/types': 2.21.1 + '@slack/web-api': 7.16.0 + '@vercel/blob': 2.8.0 + '@vercel/functions': 3.6.0 + '@vercel/oidc': 3.4.1 + '@vercel/queue': 0.2.0 + '@vercel/sandbox': 2.8.0 + ai: 6.0.190(zod@4.5.4) + better-auth: 1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6) + chat: 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + commander: 14.0.3 + diff: 8.0.4 + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) + hono: 4.12.27 + jiti: 2.7.0 + jose: 6.2.3 + just-bash: 3.0.1 + node-html-markdown: 2.0.0 + pg: 8.21.0 + sharp: 0.35.3(@types/node@25.9.1) + yaml: 2.9.0 + zod: 4.5.4 + transitivePeerDependencies: + - '@aws-sdk/client-rds-data' + - '@aws-sdk/credential-provider-web-identity' + - '@cfworker/json-schema' + - '@cloudflare/workers-types' + - '@electric-sql/pglite' + - '@libsql/client' + - '@libsql/client-wasm' + - '@lynx-js/react' + - '@node-rs/xxhash' + - '@op-engineering/op-sqlite' + - '@opentelemetry/exporter-trace-otlp-http' + - '@planetscale/database' + - '@prisma/client' + - '@sveltejs/kit' + - '@tanstack/react-start' + - '@tanstack/solid-start' + - '@tidbcloud/serverless' + - '@types/better-sqlite3' + - '@types/node' + - '@types/pg' + - '@types/sql.js' + - '@upstash/redis' + - '@vercel/postgres' + - '@xata.io/client' + - bare-abort-controller + - better-sqlite3 + - bufferutil + - bun-types + - debug + - drizzle-kit + - expo-sqlite + - gel + - knex + - kysely + - mongodb + - mysql2 + - next + - pg-native + - postgres + - prisma + - react + - react-dom + - react-native-b4a + - solid-js + - sql.js + - sqlite3 + - supports-color + - svelte + - utf-8-validate + - vitest + - vue + - ws + + '@sentry/junior@file:packages/junior(@types/node@25.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7)': dependencies: '@agentclientprotocol/sdk': 1.3.0(zod@4.5.4) '@ai-sdk/gateway': 3.0.119(zod@4.5.4) @@ -12309,6 +12460,101 @@ snapshots: '@vercel/queue': 0.2.0 '@vercel/sandbox': 2.8.0 ai: 6.0.190(zod@4.5.4) + better-auth: 1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7) + chat: 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + commander: 14.0.3 + diff: 8.0.4 + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) + hono: 4.12.27 + jiti: 2.7.0 + jose: 6.2.3 + just-bash: 3.0.1 + node-html-markdown: 2.0.0 + pg: 8.21.0 + sharp: 0.35.3(@types/node@25.9.1) + yaml: 2.9.0 + zod: 4.5.4 + transitivePeerDependencies: + - '@aws-sdk/client-rds-data' + - '@aws-sdk/credential-provider-web-identity' + - '@cfworker/json-schema' + - '@cloudflare/workers-types' + - '@electric-sql/pglite' + - '@libsql/client' + - '@libsql/client-wasm' + - '@lynx-js/react' + - '@node-rs/xxhash' + - '@op-engineering/op-sqlite' + - '@opentelemetry/exporter-trace-otlp-http' + - '@planetscale/database' + - '@prisma/client' + - '@sveltejs/kit' + - '@tanstack/react-start' + - '@tanstack/solid-start' + - '@tidbcloud/serverless' + - '@types/better-sqlite3' + - '@types/node' + - '@types/pg' + - '@types/sql.js' + - '@upstash/redis' + - '@vercel/postgres' + - '@xata.io/client' + - bare-abort-controller + - better-sqlite3 + - bufferutil + - bun-types + - debug + - drizzle-kit + - expo-sqlite + - gel + - knex + - kysely + - mongodb + - mysql2 + - next + - pg-native + - postgres + - prisma + - react + - react-dom + - react-native-b4a + - solid-js + - sql.js + - sqlite3 + - supports-color + - svelte + - utf-8-validate + - vitest + - vue + - ws + + '@sentry/junior@file:packages/junior(vitest@4.1.7(msw@2.14.6(typescript@6.0.3))(tsx@4.22.3))': + dependencies: + '@agentclientprotocol/sdk': 1.3.0(zod@4.5.4) + '@ai-sdk/gateway': 3.0.119(zod@4.5.4) + '@chat-adapter/slack': 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@chat-adapter/state-memory': 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@chat-adapter/state-redis': 4.29.0(@opentelemetry/api@1.9.1)(ai@6.0.190(zod@4.5.4))(zod@4.5.4) + '@earendil-works/pi-agent-core': 0.84.1(@modelcontextprotocol/sdk@1.31.0(zod@4.5.4))(zod@4.5.4) + '@earendil-works/pi-ai': 0.84.1(@modelcontextprotocol/sdk@1.31.0(zod@4.5.4))(zod@4.5.4) + '@hono/zod-validator': 0.9.0(hono@4.12.27)(zod@4.5.4) + '@logtape/logtape': 2.1.1 + '@modelcontextprotocol/sdk': 1.31.0(zod@4.5.4) + '@neondatabase/serverless': 1.1.0 + '@opentelemetry/api': 1.9.1 + '@opentelemetry/core': 2.9.0(@opentelemetry/api@1.9.1) + '@sentry/junior-plugin-api': file:packages/junior-plugin-api + '@sentry/node': 10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1)) + '@sinclair/typebox': 0.34.49 + '@slack/types': 2.21.1 + '@slack/web-api': 7.16.0 + '@vercel/blob': 2.8.0 + '@vercel/functions': 3.6.0 + '@vercel/oidc': 3.4.1 + '@vercel/queue': 0.2.0 + '@vercel/sandbox': 2.8.0 + ai: 6.0.190(zod@4.5.4) + better-auth: 1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0)(vitest@4.1.7(msw@2.14.6(typescript@6.0.3))(tsx@4.22.3)) chat: 4.29.0(ai@6.0.190(zod@4.5.4))(zod@4.5.4) commander: 14.0.3 diff: 8.0.4 @@ -12330,13 +12576,18 @@ snapshots: - '@electric-sql/pglite' - '@libsql/client' - '@libsql/client-wasm' + - '@lynx-js/react' - '@node-rs/xxhash' - '@op-engineering/op-sqlite' - '@opentelemetry/exporter-trace-otlp-http' - '@planetscale/database' - '@prisma/client' + - '@sveltejs/kit' + - '@tanstack/react-start' + - '@tanstack/solid-start' - '@tidbcloud/serverless' - '@types/better-sqlite3' + - '@types/node' - '@types/pg' - '@types/sql.js' - '@upstash/redis' @@ -12347,19 +12598,28 @@ snapshots: - bufferutil - bun-types - debug + - drizzle-kit - expo-sqlite - gel - knex - kysely + - mongodb - mysql2 + - next - pg-native - postgres - prisma + - react + - react-dom - react-native-b4a + - solid-js - sql.js - sqlite3 - supports-color + - svelte - utf-8-validate + - vitest + - vue - ws '@sentry/nitro@10.65.0(@opentelemetry/core@2.9.0(@opentelemetry/api@1.9.1))(nitro@3.0.260522-beta(jiti@2.7.0))': @@ -13680,10 +13940,10 @@ snapshots: is-alphanumerical: 2.0.1 is-decimal: 2.0.1 - better-auth@1.6.22(@opentelemetry/api@1.9.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + better-auth@1.6.22(@opentelemetry/api@1.9.1)(drizzle-kit@0.31.10)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(pg@8.21.0))(pg@8.21.0)(vitest@4.1.7): dependencies: '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(pg@8.21.0)) '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) @@ -13700,21 +13960,77 @@ snapshots: nanostores: 1.4.1 zod: 4.5.4 optionalDependencies: + drizzle-kit: 0.31.10 + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(@types/pg@8.15.6)(pg@8.21.0) + pg: 8.21.0 + vitest: 4.1.7(@opentelemetry/api@1.9.1)(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(jiti@2.7.0)(msw@2.14.6(@types/node@25.9.1)(typescript@6.0.3))(tsx@4.22.3)(yaml@2.9.0) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + + better-auth@1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0): + dependencies: + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0)) + '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) + '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + better-call: 1.3.7(zod@4.5.4) + defu: 6.1.7 + jose: 6.2.4 + kysely: 0.29.4 + nanostores: 1.4.1 + zod: 4.5.4 + optionalDependencies: + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) + pg: 8.21.0 + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + + better-auth@1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6): + dependencies: + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0)) + '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) + '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + better-call: 1.3.7(zod@4.5.4) + defu: 6.1.7 + jose: 6.2.4 + kysely: 0.29.4 + nanostores: 1.4.1 + zod: 4.5.4 + optionalDependencies: + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) + pg: 8.21.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) transitivePeerDependencies: - '@cloudflare/workers-types' - '@opentelemetry/api' - better-auth@1.6.22(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7): + better-auth@1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(vitest@4.1.7): dependencies: - '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) - '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) - '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) - '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) - '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) - '@better-auth/prisma-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) - '@better-auth/telemetry': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0)) + '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) + '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) '@better-auth/utils': 0.4.2 '@better-fetch/fetch': 1.3.1 '@noble/ciphers': 2.2.0 @@ -13726,6 +14042,8 @@ snapshots: nanostores: 1.4.1 zod: 4.5.4 optionalDependencies: + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) + pg: 8.21.0 react: 19.2.6 react-dom: 19.2.6(react@19.2.6) vitest: 4.1.7(@types/node@25.9.1)(@vitest/coverage-v8@4.1.7)(tsx@4.22.3) @@ -13733,6 +14051,33 @@ snapshots: - '@cloudflare/workers-types' - '@opentelemetry/api' + better-auth@1.6.22(@opentelemetry/api@1.9.1)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0))(pg@8.21.0)(vitest@4.1.7(msw@2.14.6(typescript@6.0.3))(tsx@4.22.3)): + dependencies: + '@better-auth/core': 1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1) + '@better-auth/drizzle-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(drizzle-orm@0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0)) + '@better-auth/kysely-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(kysely@0.29.4) + '@better-auth/memory-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/mongo-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/prisma-adapter': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2) + '@better-auth/telemetry': 1.6.22(@better-auth/core@1.6.22(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1)(@opentelemetry/api@1.9.1)(better-call@1.3.7(zod@4.5.4))(jose@6.2.4)(kysely@0.29.4)(nanostores@1.4.1))(@better-auth/utils@0.4.2)(@better-fetch/fetch@1.3.1) + '@better-auth/utils': 0.4.2 + '@better-fetch/fetch': 1.3.1 + '@noble/ciphers': 2.2.0 + '@noble/hashes': 2.2.0 + better-call: 1.3.7(zod@4.5.4) + defu: 6.1.7 + jose: 6.2.4 + kysely: 0.29.4 + nanostores: 1.4.1 + zod: 4.5.4 + optionalDependencies: + drizzle-orm: 0.45.2(@neondatabase/serverless@1.1.0)(@opentelemetry/api@1.9.1)(pg@8.21.0) + pg: 8.21.0 + vitest: 4.1.7(msw@2.14.6(typescript@6.0.3))(tsx@4.22.3) + transitivePeerDependencies: + - '@cloudflare/workers-types' + - '@opentelemetry/api' + better-call@1.3.7(zod@4.5.4): dependencies: '@better-auth/utils': 0.4.2 @@ -17488,7 +17833,6 @@ snapshots: '@img/sharp-win32-arm64': 0.35.3 '@img/sharp-win32-ia32': 0.35.3 '@img/sharp-win32-x64': 0.35.3 - optional: true sharp@0.35.3(@types/node@25.9.1): dependencies: From ecc10032114df65219e36a789d5badb6ef356661 Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:05:52 +0000 Subject: [PATCH 08/10] fix(sign-in): Trim auth path slashes without a regex CodeQL flagged the slash-trimming regexes as polynomial on uncontrolled input. Trim with a loop instead. Co-Authored-By: David Cramer --- packages/junior/src/chat/sign-in/auth.ts | 20 ++++++++++++++++---- 1 file changed, 16 insertions(+), 4 deletions(-) diff --git a/packages/junior/src/chat/sign-in/auth.ts b/packages/junior/src/chat/sign-in/auth.ts index 81bd3316fa..18e2bfbaaf 100644 --- a/packages/junior/src/chat/sign-in/auth.ts +++ b/packages/junior/src/chat/sign-in/auth.ts @@ -60,6 +60,15 @@ export interface SignInAuthOptions { trustedOrigins?: string[]; } +// Loop instead of a regex, so many slashes cannot make it slow. +function trimSlashes(value: string, leading: boolean): string { + let start = 0; + let end = value.length; + while (leading && start < end && value[start] === "/") start += 1; + while (end > start && value[end - 1] === "/") end -= 1; + return value.slice(start, end); +} + function withHttps(host: string): string { return /^https?:\/\//.test(host) ? host : `https://${host}`; } @@ -69,10 +78,13 @@ export function resolveSignInAuthConfig( agentName: string, options: SignInAuthOptions = {}, ): SignInAuthConfig { - const authPath = `/${(options.authPath?.trim() || DEFAULT_AUTH_PATH).replace(/^\/+|\/+$/g, "")}`; - const baseURL = withHttps( - options.baseURL?.trim() || resolveBaseUrl() || "http://localhost:3000", - ).replace(/\/+$/, ""); + const authPath = `/${trimSlashes(options.authPath?.trim() || DEFAULT_AUTH_PATH, true)}`; + const baseURL = trimSlashes( + withHttps( + options.baseURL?.trim() || resolveBaseUrl() || "http://localhost:3000", + ), + false, + ); return { agentName, allowlist: resolveSignInAllowlist(options), From cac7e20111abef23fccabf6f6049e077893d3a8e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 00:13:51 +0000 Subject: [PATCH 09/10] fix(slack): Accept a dashboard sign-in on the first Slack message Slack ingress checked for a recent Google sign-in before it saved the Slack identity. On the first message, the Slack identity had no user, so a recent dashboard sign-in did not count and the message was dropped. Check again after saving the Slack identity. Co-Authored-By: David Cramer --- .../junior/src/chat/ingress/slack-sign-in.ts | 19 +++++++------ .../slack/webhook-auth-boundary.test.ts | 27 ++++++++++--------- 2 files changed, 26 insertions(+), 20 deletions(-) diff --git a/packages/junior/src/chat/ingress/slack-sign-in.ts b/packages/junior/src/chat/ingress/slack-sign-in.ts index 36c4edd388..10e372d6e7 100644 --- a/packages/junior/src/chat/ingress/slack-sign-in.ts +++ b/packages/junior/src/chat/ingress/slack-sign-in.ts @@ -20,7 +20,8 @@ import type { StateAdapter } from "chat"; * The user of the Slack identity must have signed in with Google, in Slack or * in the dashboard, within `signIn.maxAgeMs`. When it has not, save the Slack * identity with the email from the Slack profile, so the sign-in page can match - * it. Then send the user a private sign-in link and drop the message. + * it, and check again. Then send the user a private sign-in link and drop the + * message. */ export async function hasSlackSignIn(args: { event: SlackInboundEvent; @@ -34,15 +35,14 @@ export async function hasSlackSignIn(args: { const userId = parseSlackUserId(args.event.user); if (!teamId || !channelId || !userId) return false; const executor = getSqlExecutor(); - if ( - await slackUserHasGoogleSignIn(executor, { - signedInSince: new Date(Date.now() - args.signIn.maxAgeMs), + const signedInSince = new Date(Date.now() - args.signIn.maxAgeMs); + const hasSignIn = () => + slackUserHasGoogleSignIn(executor, { + signedInSince, slackTeamId: teamId, slackUserId: userId, - }) - ) { - return true; - } + }); + if (await hasSignIn()) return true; const profile = await lookupSlackUserProfile(userId); await upsertIdentity(executor, { @@ -57,6 +57,9 @@ export async function hasSlackSignIn(args: { : undefined), metadata: { platform: "slack" }, }); + // The first Slack message links the Slack identity to its user. That user + // may already have a recent dashboard sign-in. + if (await hasSignIn()) return true; const link = await createSlackSignInUrl({ baseURL: args.signIn.baseURL, diff --git a/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts b/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts index 0505b72c04..ac3a414462 100644 --- a/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts +++ b/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts @@ -392,6 +392,20 @@ describe("Slack webhook auth boundary", () => { return Object.assign(visit, { cookies }); }; + // A dashboard sign-in before the first Slack message counts for Slack. + const browser = createBrowser(); + const home = await browser(`${baseURL}/auth/login`, "testuser@example.com"); + expect(await home.text()).toBe("dashboard"); + // The session cookie never carries the Google account or its tokens. + expect([...browser.cookies.keys()].join()).not.toContain("account"); + expect((await send()).status).toBe(200); + expect(queue.queuedMessages()).toHaveLength(1); + expect(getCapturedSlackApiCalls("chat.postMessage")).toEqual([]); + + // After the max age, Slack asks again. + vi.useFakeTimers({ toFake: ["Date"] }); + vi.setSystemTime(Date.now() + 8 * 24 * 60 * 60 * 1000); + // Accounts outside the allowlist, or another person's account, fail. expect( (await createBrowser()(await sendBlocked(), "testuser@contractor.dev")) @@ -401,20 +415,9 @@ describe("Slack webhook auth boundary", () => { (await createBrowser()(await sendBlocked(), "someone@example.com")) .status, ).toBe(403); - expect(queue.queuedMessages()).toEqual([]); - - // A dashboard sign-in by the Slack user's account also counts for Slack. - const browser = createBrowser(); - const home = await browser(`${baseURL}/auth/login`, "testuser@example.com"); - expect(await home.text()).toBe("dashboard"); - // The session cookie never carries the Google account or its tokens. - expect([...browser.cookies.keys()].join()).not.toContain("account"); - expect((await send()).status).toBe(200); expect(queue.queuedMessages()).toHaveLength(1); - // After the max age, Slack asks again, and the Slack page signs in. - vi.useFakeTimers({ toFake: ["Date"] }); - vi.setSystemTime(Date.now() + 8 * 24 * 60 * 60 * 1000); + // The Slack page signs in the matching account. const link = await sendBlocked(); const visits = googleVisits; expect((await browser(link, "testuser@example.com")).status).toBe(200); From 017737b7c4922729ada80ddf067713853c60b59e Mon Sep 17 00:00:00 2001 From: "sentry-junior[bot]" <264270552+sentry-junior[bot]@users.noreply.github.com> Date: Mon, 26 Oct 2026 14:45:24 +0000 Subject: [PATCH 10/10] test(slack): Route Slack sign-in coverage through createApp Send Slack webhooks, Better Auth routes, and the Slack sign-in page through the real app. Cover a cancelled Google sign-in, and check that a Slack Connect author gets no profile lookup, no sign-in link, and no Google redirect. Co-Authored-By: David Cramer --- .../slack/webhook-auth-boundary.test.ts | 220 ++++++++++-------- 1 file changed, 119 insertions(+), 101 deletions(-) diff --git a/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts b/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts index ac3a414462..d0ddc863e5 100644 --- a/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts +++ b/packages/junior/tests/integration/slack/webhook-auth-boundary.test.ts @@ -1,10 +1,9 @@ import { createMemoryState } from "@chat-adapter/state-memory"; import { afterEach, describe, expect, it, vi } from "vitest"; import { http, HttpResponse } from "msw"; +import { createApp } from "@/app"; import { closeDb, getConversationEventStore } from "@/chat/db"; -import { setExperimentalFeatures } from "@/chat/experimental"; -import { createSignInAuth } from "@/chat/sign-in/auth"; -import { handleSlackSignIn } from "@/chat/sign-in/slack"; +import { disconnectStateAdapter, getStateAdapter } from "@/chat/state/adapter"; import { JuniorChat } from "@/chat/ingress/junior-chat"; import { runWithWorkspaceTeamId } from "@/chat/ingress/workspace-membership"; import { getConversationWorkState } from "@/chat/task-execution/store"; @@ -37,6 +36,7 @@ describe("Slack webhook auth boundary", () => { afterEach(async () => { vi.unstubAllEnvs(); vi.useRealTimers(); + await disconnectStateAdapter(); await closeDb(); }); @@ -261,22 +261,31 @@ describe("Slack webhook auth boundary", () => { await state.disconnect(); }); - it("asks Slack users for a recent Google sign-in shared with the dashboard", async () => { - setExperimentalFeatures({ ...SUITE_EXPERIMENTAL, "slack-sign-in": true }); + it("asks Slack members for a recent Google sign-in shared with the dashboard", async () => { + vi.stubEnv("GOOGLE_CLIENT_ID", "google-client"); + vi.stubEnv("GOOGLE_CLIENT_SECRET", "google-secret"); const baseURL = "https://junior.example.com"; - // Two domains, so Google does not limit accounts with `hd`. - const allowlist = { domains: ["example.com", "example.org"], emails: [] }; - const auth = createSignInAuth({ - agentName: "junior", - allowlist, - authPath: "/api/auth", - baseURL, - googleClientId: "google-client", - googleClientSecret: "google-secret", - secret: "0123456789abcdef0123456789abcdef", - trustedOrigins: [], + const queue = createConversationWorkQueueTestAdapter(); + // The production Slack adapter uses SLACK_SIGNING_SECRET from test setup. + const slack = createSlackWebhookTestClient({ + signingSecret: SIGNING_SECRET, }); - const signIn = { baseURL, maxAgeMs: 7 * 24 * 60 * 60 * 1000 }; + const waitUntil = slack.waitUntil(); + const app = await createApp({ + conversationWorkQueue: () => queue, + // No dashboard app, so core serves the Better Auth routes itself. + dashboard: { + // Two domains, so Google does not limit accounts with `hd`. + allowedGoogleDomains: ["example.com", "example.org"], + baseURL, + disabled: true, + }, + experimental: { ...SUITE_EXPERIMENTAL, "slack-sign-in": true }, + waitUntil: waitUntil.fn, + }); + await getStateAdapter().connect(); + // Fake Google. Each visit signs in as `googleEmail`, or the person + // cancels when it is empty. let googleEmail = ""; let googleVisits = 0; mswServer.use( @@ -295,32 +304,21 @@ describe("Slack webhook auth boundary", () => { return HttpResponse.json({ id_token: `e30.${payload}.sig` }); }), ); - const state = createMemoryState(); - await state.connect(); - const adapter = createSlackAdapterFixture(); - const queue = createConversationWorkQueueTestAdapter(); - const services = { - getSlackAdapter: () => adapter, - queue, - runtime: createNoopSlackWebhookRuntime(), - signIn, - state, - }; let sequence = 1; - const send = async () => - await handleSlackWebhookAndFlush({ - request: slackWebhookRequest( - slackEventsApiEnvelope({ - eventType: "message", - channel: "D123", - user: "U123", - ts: `1712345.${String(sequence++).padStart(6, "0")}`, - threadTs: "1712345.000001", - text: "hello", - }), - ), - services, + const send = async (fields: Record = {}) => { + const envelope = slackEventsApiEnvelope({ + eventType: "message", + channel: "D123", + user: "U123", + ts: `1712345.${String(sequence++).padStart(6, "0")}`, + threadTs: "1712345.000001", + text: "hello", }); + Object.assign(envelope.event, fields); + const response = await app.fetch(slack.event(envelope)); + await waitUntil.flush(); + return response; + }; const sendBlocked = async () => { const prompts = getCapturedSlackApiCalls("chat.postMessage").length; expect((await send()).status).toBe(200); @@ -331,71 +329,88 @@ describe("Slack webhook auth boundary", () => { .exec(String(calls.at(-1)?.params.text))![1]! .replaceAll("&", "&"); }; - // A browser with one cookie jar. Google signs in as `email`. + // A browser with one cookie jar. It follows redirects through the app + // and the fake Google consent page. const createBrowser = () => { const cookies = new Map(); - const request = (url: string) => - new Request(url, { - headers: { - cookie: [...cookies].map(([k, v]) => `${k}=${v}`).join("; "), - }, - }); - const route = async (url: URL): Promise => { + const fetchApp = async (url: URL, init: RequestInit = {}) => { if (url.origin === "https://accounts.google.com") { googleVisits += 1; const back = new URL(url.searchParams.get("redirect_uri")!); back.searchParams.set("state", url.searchParams.get("state")!); - back.searchParams.set("code", "google-code"); + if (googleEmail) back.searchParams.set("code", "google-code"); + else back.searchParams.set("error", "access_denied"); return Response.redirect(back, 302); } - if (url.pathname.startsWith("/api/auth/")) { - return await auth.handler(request(url.toString())); - } - if (url.pathname === "/api/sign-in/slack") { - return await handleSlackSignIn({ - allowlist, - auth, - request: request(url.toString()), - signIn, - state, - }); - } - // Like the dashboard login: use the session, or sign in with Google. - if (url.pathname === "/auth/login") { - if (await auth.getSession(request(url.toString()))) { - return Response.redirect(`${baseURL}/`, 302); - } - return await auth.signInWithGoogle( - request(url.toString()), - `${baseURL}/`, - ); + const headers = new Headers(init.headers); + headers.set( + "cookie", + [...cookies].map(([k, v]) => `${k}=${v}`).join("; "), + ); + const response = await app.fetch( + new Request(url, { ...init, headers }), + ); + for (const cookie of response.headers.getSetCookie()) { + const [pair] = cookie.split(";"); + const name = pair!.slice(0, pair!.indexOf("=")); + const value = pair!.slice(pair!.indexOf("=") + 1); + if (value && !/max-age=0/i.test(cookie)) cookies.set(name, value); + else cookies.delete(name); } - return new Response("dashboard"); + return response; }; - const visit = async (start: string, email: string) => { - googleEmail = email; - let url = new URL(start); + const follow = async (response: Response, from: URL) => { for (;;) { - const response = await route(url); - for (const cookie of response.headers.getSetCookie()) { - const [pair] = cookie.split(";"); - const name = pair!.slice(0, pair!.indexOf("=")); - const value = pair!.slice(pair!.indexOf("=") + 1); - if (value && !/max-age=0/i.test(cookie)) cookies.set(name, value); - else cookies.delete(name); - } const location = response.headers.get("location"); if (!location) return response; - url = new URL(location, url); + from = new URL(location, from); + response = await fetchApp(from); } }; - return Object.assign(visit, { cookies }); + const visit = async (start: string, email: string) => { + googleEmail = email; + return await follow(await fetchApp(new URL(start)), new URL(start)); + }; + // Like the dashboard client: start Google sign-in with Better Auth. + const signInFromDashboard = async (email: string) => { + googleEmail = email; + const url = new URL("/api/auth/sign-in/social", baseURL); + const started = await fetchApp(url, { + body: JSON.stringify({ + callbackURL: `${baseURL}/`, + provider: "google", + }), + headers: { "content-type": "application/json", origin: baseURL }, + method: "POST", + }); + const { url: google } = (await started.json()) as { url: string }; + return await follow(await fetchApp(new URL(google)), new URL(google)); + }; + const session = async () => + await ( + await fetchApp(new URL("/api/auth/get-session", baseURL)) + ).json(); + return { cookies, session, signInFromDashboard, visit }; }; + // Slack Connect authors never get a sign-in link or a Google redirect. + // Membership is checked first, so Slack is not even asked for a profile. + expect( + (await send({ user: "UEXTERNAL", user_team: "TEXTERNAL" })).status, + ).toBe(200); + expect(queue.queuedMessages()).toEqual([]); + expect(getCapturedSlackApiCalls("users.info")).toEqual([]); + expect(slackApiOutbox.messages()).toEqual([]); + expect(getCapturedSlackApiCalls("chat.postEphemeral")).toEqual([]); + expect(googleVisits).toBe(0); + // A dashboard sign-in before the first Slack message counts for Slack. const browser = createBrowser(); - const home = await browser(`${baseURL}/auth/login`, "testuser@example.com"); - expect(await home.text()).toBe("dashboard"); + await browser.signInFromDashboard("testuser@example.com"); + expect(await browser.session()).toMatchObject({ + user: { email: "testuser@example.com" }, + }); + expect(googleVisits).toBe(1); // The session cookie never carries the Google account or its tokens. expect([...browser.cookies.keys()].join()).not.toContain("account"); expect((await send()).status).toBe(200); @@ -406,31 +421,34 @@ describe("Slack webhook auth boundary", () => { vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(Date.now() + 8 * 24 * 60 * 60 * 1000); - // Accounts outside the allowlist, or another person's account, fail. - expect( - (await createBrowser()(await sendBlocked(), "testuser@contractor.dev")) - .status, - ).toBe(403); - expect( - (await createBrowser()(await sendBlocked(), "someone@example.com")) - .status, - ).toBe(403); + // Accounts outside the allowlist, another person's account, or a + // cancelled Google sign-in fail. + for (const [email, status] of [ + ["testuser@contractor.dev", 403], + ["someone@example.com", 403], + ["", 400], + ] as const) { + expect( + (await createBrowser().visit(await sendBlocked(), email)).status, + ).toBe(status); + } expect(queue.queuedMessages()).toHaveLength(1); // The Slack page signs in the matching account. const link = await sendBlocked(); const visits = googleVisits; - expect((await browser(link, "testuser@example.com")).status).toBe(200); + expect((await browser.visit(link, "testuser@example.com")).status).toBe( + 200, + ); expect(googleVisits).toBe(visits + 1); expect((await send()).status).toBe(200); expect(queue.queuedMessages()).toHaveLength(2); // The dashboard uses the session from the Slack page. - expect( - await (await browser(`${baseURL}/auth/login`, "x@example.com")).text(), - ).toBe("dashboard"); + expect(await browser.session()).toMatchObject({ + user: { email: "testuser@example.com" }, + }); expect(googleVisits).toBe(visits + 1); - await state.disconnect(); }); it.each(["message", "factory"] as const)(