diff --git a/doc/DATABASE.md b/doc/DATABASE.md index 2177689e1b9..5862fe40a55 100644 --- a/doc/DATABASE.md +++ b/doc/DATABASE.md @@ -701,6 +701,18 @@ this non-secret binding for this namespace; Slack bot installation does not use PKCE. Removal and manual recovery invalidate outstanding attempts under the same credential-mutation lease used by configuration. +### Slack manager workspace grants + +`chat_slack_manager_grants` stores a personal workspace authorization, scoped by +company, Paperclip user, Slack user, workspace and manager app. Access/refresh +credentials are references to user-scoped vault entries, never plaintext columns. +Revision fencing protects reauthorization from concurrent revocation. Per-method +rate-limit deadlines retain Slack's Retry-After across setup retries. + +`chat_slack_registrations.manager_grant_id` is optional, with a composite company +foreign key. Existing registrations remain customer owned with a null reference. +Deleting one endpoint removes its registration credentials and attempts, but does +not revoke grants used by other bots. See [Slack setup](connections/slack-setup.md). ## Transaction-aware delivery work signals diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 233c2e5cf66..575b1430fa8 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -2239,3 +2239,12 @@ Chromium. The test starts its own loopback Vite server and mocks API responses; it needs no running Paperclip instance or provider credentials. The same spec lives in the default `test:e2e` discovery tree, so the existing Chrome CI shards run it on pull requests. + +### Slack provisioning paths + +Self-hosted development continues to use independent Slack app setup. Managed +Slack installation is a disabled-by-default Cloud feature that also requires the +companion broker profile and Slack approval. Do not add manager client credentials +to a local `.env` or an open-source deployment. See +[Slack setup and Cloud qualification](connections/slack-setup.md) for the gates, +recovery behavior, and production-component stories. diff --git a/doc/connections/slack-setup.md b/doc/connections/slack-setup.md new file mode 100644 index 00000000000..78abb8ef67f --- /dev/null +++ b/doc/connections/slack-setup.md @@ -0,0 +1,162 @@ +# Slack app ownership and setup + +Slack has one chat runtime and two provisioning paths. A saved connection's +`slackSetupMethod` selects provisioning; it does not change tools, scopes, +conversation routing, people permissions, or channel access. + +| Method | App creation and installation | Default | +| --- | --- | --- | +| `managed` | A personal Slack manager grant creates a child app; managed installation supplies its bot token. Slack consent/admin approval remains a fallback. | New drafts on qualified Cloud deployments only | +| `automatic` | A temporary app configuration access token creates a customer-owned app; the user approves its OAuth installation. | New self-hosted drafts and Cloud without managed availability | +| `manual` | User creates an app from the canonical manifest and enters credentials. | Legacy drafts with no saved method | +| `existing` | User supplies credentials for an existing app. | Explicit selection | + +Existing drafts and active bots retain their method. Enabling managed setup never +adopts existing apps. Choose agent offers **Use your own app** under **Advanced**, below Slash command, +when the instance supports managed setup and creation has not started. The own-app flow retains **Create manually** and **Use an existing +app** under Advanced. After creation dispatch, a managed draft cannot switch paths. + +Managed setup has two visible stops: Choose agent and Connect. **Continue** starts +manager authorization or installation in the saved workspace automatically. Multiple +workspaces still require a selection. Recovery actions remain explicit; opening +a failed or uncertain registration never retries creation automatically. +Creation, installation, message waiting, and success use the same production +components and server evidence as the own-app flow. A click or OAuth return does +not constitute successful configuration. + +## Cloud deployment prerequisites + +Managed setup is disabled by default. Qualification requires all of: + +- The instance chat-connector rollout gate and connection-management permission. +- Verified Cloud runtime identity, connector broker signing/sealing configuration, + and valid independently configured public board and webhook HTTPS origins. +- `PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED=true` on the approved tenant deployment. +- The companion Cloud broker deployment with the explicitly enabled `slack.manager` + profile, approved Slack manager app, token rotation, and its client secret in + the broker's secret store. No manager client secret belongs in the tenant. + +The broker must advertise its manager app identity as well as the enabled profile. +Previously saved grants from a different manager app are not reused. A broker +without this capability leaves managed setup unavailable. Do not enable the tenant +gate until real Slack qualification has passed; fixture coverage does not prove +Marketplace eligibility or a workspace's approval policy. + +Self-hosted instances do not need a Paperclip account, broker enrollment, manager +credentials, or hosted-service calls. They continue using the own-app flow. + +## Personal workspace authorization + +Manager grants belong to a company **and one Paperclip user**, and bind a verified +Slack user, workspace, and manager app. Another company member must authorize their +own account, even for the same workspace. Access and rotating refresh tokens are +user-scoped vault entries. Refreshes serialize across connections, persist the new +refresh token, and require fresh authorization after an ambiguous exchange. + +Revoking a grant requests provider revocation, removes its local credentials, invalidates pending manager +authorization, and prevents future provisioning with it. The local activity records whether provider revocation succeeded. Provider errors never restore local access. It does not uninstall +apps or disable already-installed bots. Removing a bot deletes endpoint-specific +secrets and pending setup attempts; a reusable personal manager grant survives. +Provider-side app removal remains explicit through the saved Slack management URL. + +## Recovery + +- A dispatched creation with an unknown result is **uncertain**. Never automatically + create again. The user must inspect Slack and explicitly confirm that no app was + created before a new request identity is accepted. +- Denied or pending installation retains the app. Resume with the same grant or + complete the child app's state-bound OAuth approval; the installer must match the + grant's Slack identity before personal linking. +- Returned bot credentials are vaulted before inventory or runtime configuration. + Retry connecting reuses those credentials after network or inventory failures. + Rejected or under-scoped bot credentials instead reinstall the saved app; they + never trigger another app creation. +- Manifest updates finish before managed installation. Changes requiring updated + permissions therefore pass through installation again before activation. +- Expired or revoked manager grants require reconnection. Losing managed + availability pauses setup; it never changes the saved ownership method. +- Slack rate limits are grouped by manager/workspace/method. Provider `Retry-After` + is persisted across matching grants in the company, survives restarts, and blocks + early retries. Other tenant instances must observe Slack's limits independently; + no background loop retries app creation. + +All OAuth attempts bind company, actor/session, endpoint, registration revision, +and configured origins, expire after ten minutes, and are claimed once before +exchange. HTTP diagnostics redact manager tokens, refresh tokens, codes, and sealed +handoffs. Local activity records contain only IDs and safe outcome codes. This adds +no first-party telemetry. + +## Code and verification + +Server provisioning lives in `server/src/services/connectors/slack/setup/`; +`chat-slack-registration.ts` remains the compatibility facade. Shared completion +owns credential binding, personal linking, welcome messages, and verification. +Removal cleanup stays independently callable. Slack wizard components and stage +selection live in `ui/src/pages/apps/chat/slack/`. + +Production Storybook journeys: **Connections / Slack / Managed setup** and +**Connections / Slack / Automatic setup** (the persisted own-app automatic path). +Server integration cases cover personal grants, duplicate requests, uncertain +creation, approval fallback, refresh, revocation, removal, saved-credential recovery, +rate limits, and secret canaries. Browser cases are in +`tests/e2e/chat-adapters-ui-providers.spec.ts`; companion broker tests cover manager +user OAuth and the authenticated tenant handoff. + +Before enabling a deployment, create two test agents in the authorized workspace. +The second must reuse the personal grant without another configuration token or +manager authorization. Verify avatars, identity linking, signed inbound events, +and same-thread replies. Record real approval behavior separately from fixtures. + +### Managed setup during broker outages + +Cloud deployments configured for managed Slack keep the managed default when the broker profile is temporarily unavailable. New drafts remain on **Connect** and offer **Retry**. **Use your own app** remains available under **Advanced** on Choose agent before creation. Discovery loading or failure never selects the configuration-token page. Self-hosted instances without managed configuration still use their independent own-app flow; saved draft methods do not change automatically. Provisioning continues to require current broker availability. + +### Local managed-bot qualification + +Maintainers with an approved Slack manager app can exercise the real managed +journey from an authenticated local test drive exposed through HTTPS (including +TryCloudflare). This development transport talks directly to Slack and reuses +the production manager-grant vault, state claims, provisioning, completion, and +runtime. It does not exercise the Cloud broker's sealed handoff; qualify that +separately on staging afterward. + +Set `NODE_ENV=development`, `PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED=true`, and +`PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG` to an absolute path outside the +checkout containing a mode-0600 JSON file with `appId`, `clientId`, and +`clientSecret`. Use only an authorized qualification manager app. No credentials +are shipped with Paperclip. Production ignores this development configuration, +and Cloud runtime identity continues to select the Cloud broker. + +Configure the local instance for authenticated public access, with its public +URL and chat webhook URL pointing to the HTTPS tunnel. Add the exact public URL +plus `/api/chat-slack/managed/oauth/callback` to the manager app's OAuth redirect +URLs, preserving any existing redirects. Start a **new** Slack setup: saved +own-app drafts retain their selected method. Authorize the manager app normally; +do not copy user grants from another instance. The second bot should reuse the +same local user's vaulted grant. A changed tunnel requires updating public +origins and the manager redirect before starting fresh authorization. + +Remove the temporary redirect when retiring the test drive and securely remove +its development credential file. Removing a bot does not revoke a manager grant +used by other bots. + +### Test messages from Settings + +**Send me a test message** sends a DM to the signed-in connection manager, +using their linked identity in this workspace. If this bot is not linked yet, +Paperclip can reuse that user's verified manager grant or another bot's linked +identity in the same company and workspace. Otherwise, the user selects their +Slack account and receives a private connection invitation in Slack. They must +open that invitation, sign in, and confirm before Paperclip links the account and +sends the test DM. The account picker loads further workspace pages on request. Existing revoked or conflicting identity links are not overwritten. +Direct messages must be enabled, and the user must be an active company operator. + +The action records a durable request before sending. Repeating the same request +returns its saved result; an uncertain delivery is never automatically resent. +**Open Slack configuration** opens the saved app's OAuth & Permissions page. +Legacy connections without an app ID open Slack's app list instead. + +An explicit managed reconnect reinstalls the saved app and verifies the replacement +bot credentials. Its request ID is saved before dispatch, so a duplicate completed +request does not reinstall again. Credential-configuration recovery still resumes +the saved token after a downstream failure. No reconnect creates another Slack app. diff --git a/packages/db/src/migrations/0335_absurd_bulldozer.sql b/packages/db/src/migrations/0335_absurd_bulldozer.sql new file mode 100644 index 00000000000..56224dbe057 --- /dev/null +++ b/packages/db/src/migrations/0335_absurd_bulldozer.sql @@ -0,0 +1,39 @@ +CREATE TABLE IF NOT EXISTS "chat_slack_manager_grants" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "company_id" uuid NOT NULL, + "user_id" text NOT NULL, + "slack_user_id" text NOT NULL, + "workspace_id" text NOT NULL, + "workspace_name" text NOT NULL, + "manager_app_id" text NOT NULL, + "status" text DEFAULT 'active' NOT NULL, + "access_secret_id" uuid, + "refresh_secret_id" uuid, + "expires_at" timestamp with time zone, + "created_at" timestamp with time zone DEFAULT now() NOT NULL, + "updated_at" timestamp with time zone DEFAULT now() NOT NULL, + CONSTRAINT "chat_slack_manager_grants_status_check" CHECK ("chat_slack_manager_grants"."status" in ('active', 'revoked', 'reauthorize')) +); + +--> statement-breakpoint + +ALTER TABLE "chat_slack_registrations" ADD COLUMN IF NOT EXISTS "manager_grant_id" uuid; +--> statement-breakpoint +DO $$ BEGIN +ALTER TABLE "chat_slack_manager_grants" ADD CONSTRAINT "chat_slack_manager_grants_company_id_companies_id_fk" FOREIGN KEY ("company_id") REFERENCES "public"."companies"("id") ON DELETE cascade ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; +--> statement-breakpoint + +CREATE UNIQUE INDEX IF NOT EXISTS "chat_slack_manager_grants_company_id_idx" ON "chat_slack_manager_grants" USING btree ("company_id","id"); +--> statement-breakpoint + +CREATE UNIQUE INDEX IF NOT EXISTS "chat_slack_manager_grants_owner_idx" ON "chat_slack_manager_grants" USING btree ("company_id","user_id","manager_app_id","workspace_id"); +--> statement-breakpoint +DO $$ BEGIN +ALTER TABLE "chat_slack_registrations" ADD CONSTRAINT "chat_slack_registrations_company_id_manager_grant_id_chat_slack_manager_grants_company_id_id_fk" FOREIGN KEY ("company_id","manager_grant_id") REFERENCES "public"."chat_slack_manager_grants"("company_id","id") ON DELETE no action ON UPDATE no action; +EXCEPTION WHEN duplicate_object THEN NULL; END $$; +--> statement-breakpoint +ALTER TABLE "chat_slack_manager_grants" ADD COLUMN IF NOT EXISTS "revision" integer DEFAULT 0 NOT NULL; +--> statement-breakpoint + +ALTER TABLE "chat_slack_manager_grants" ADD COLUMN IF NOT EXISTS "rate_limits" jsonb DEFAULT '{}'::jsonb NOT NULL; diff --git a/packages/db/src/migrations/meta/0330_snapshot.json b/packages/db/src/migrations/meta/0335_snapshot.json similarity index 98% rename from packages/db/src/migrations/meta/0330_snapshot.json rename to packages/db/src/migrations/meta/0335_snapshot.json index 460bb97a1cf..b348334bbd3 100644 --- a/packages/db/src/migrations/meta/0330_snapshot.json +++ b/packages/db/src/migrations/meta/0335_snapshot.json @@ -1,6 +1,6 @@ { - "id": "0a93bc99-bf0c-4ea2-ab27-6043b6746cd5", - "prevId": "76861d12-d9a9-4f5f-bcbc-0fb06c0addbc", + "id": "18c156f2-8344-4282-98b7-f702f0035c81", + "prevId": "39d82f2c-8fd5-4606-84df-f51880dbd6a7", "version": "7", "dialect": "postgresql", "tables": { @@ -416,6 +416,12 @@ "primaryKey": false, "notNull": false }, + "fast_response_request_id": { + "name": "fast_response_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "agent_id": { "name": "agent_id", "type": "uuid", @@ -462,6 +468,27 @@ } }, "indexes": { + "budget_reservations_fast_response_idx": { + "name": "budget_reservations_fast_response_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "fast_response_request_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, "budget_reservations_decision_idx": { "name": "budget_reservations_decision_idx", "columns": [ @@ -580,7 +607,7 @@ "checkConstraints": { "budget_reservations_source_check": { "name": "budget_reservations_source_check", - "value": "(\"budget_reservations\".\"run_id\" is not null)::int + (\"budget_reservations\".\"decision_invocation_id\" is not null)::int = 1" + "value": "(\"budget_reservations\".\"run_id\" is not null)::int + (\"budget_reservations\".\"decision_invocation_id\" is not null)::int + (\"budget_reservations\".\"fast_response_request_id\" is not null)::int = 1" }, "budget_reservations_state_check": { "name": "budget_reservations_state_check", @@ -3737,6 +3764,28 @@ "method": "btree", "with": {} }, + "agent_wakeup_requests_question_response_history_idx": { + "name": "agent_wakeup_requests_question_response_history_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "idempotency_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"agent_wakeup_requests\".\"idempotency_key\" LIKE 'question-response:%'", + "concurrently": true, + "method": "btree", + "with": {} + }, "agent_wakeup_requests_connection_intent_delivery_idempotency_uq": { "name": "agent_wakeup_requests_connection_intent_delivery_idempotency_uq", "columns": [ @@ -12384,6 +12433,189 @@ }, "isRLSEnabled": false }, + "public.chat_slack_manager_grants": { + "name": "chat_slack_manager_grants", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slack_user_id": { + "name": "slack_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_name": { + "name": "workspace_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "manager_app_id": { + "name": "manager_app_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "access_secret_id": { + "name": "access_secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "refresh_secret_id": { + "name": "refresh_secret_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "revision": { + "name": "revision", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rate_limits": { + "name": "rate_limits", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "chat_slack_manager_grants_company_id_idx": { + "name": "chat_slack_manager_grants_company_id_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_slack_manager_grants_owner_idx": { + "name": "chat_slack_manager_grants_owner_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "manager_app_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_slack_manager_grants_company_id_companies_id_fk": { + "name": "chat_slack_manager_grants_company_id_companies_id_fk", + "tableFrom": "chat_slack_manager_grants", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "chat_slack_manager_grants_status_check": { + "name": "chat_slack_manager_grants_status_check", + "value": "\"chat_slack_manager_grants\".\"status\" in ('active', 'revoked', 'reauthorize')" + } + }, + "isRLSEnabled": false + }, "public.chat_slack_registrations": { "name": "chat_slack_registrations", "schema": "", @@ -12400,6 +12632,12 @@ "primaryKey": false, "notNull": true }, + "manager_grant_id": { + "name": "manager_grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "request_id": { "name": "request_id", "type": "uuid", @@ -12490,6 +12728,21 @@ }, "indexes": {}, "foreignKeys": { + "chat_slack_registrations_company_id_manager_grant_id_chat_slack_manager_grants_company_id_id_fk": { + "name": "chat_slack_registrations_company_id_manager_grant_id_chat_slack_manager_grants_company_id_id_fk", + "tableFrom": "chat_slack_registrations", + "tableTo": "chat_slack_manager_grants", + "columnsFrom": [ + "company_id", + "manager_grant_id" + ], + "columnsTo": [ + "company_id", + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, "chat_slack_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk": { "name": "chat_slack_registrations_company_id_endpoint_id_chat_endpoints_company_id_id_fk", "tableFrom": "chat_slack_registrations", @@ -16725,6 +16978,19 @@ "primaryKey": false, "notNull": false }, + "package_mode": { + "name": "package_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'skills'" + }, + "repository_package": { + "name": "repository_package", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, "excluded_folders": { "name": "excluded_folders", "type": "jsonb", @@ -17052,6 +17318,87 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_skill_repository_snapshots": { + "name": "company_skill_repository_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_inventory": { + "name": "file_inventory", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "company_skill_repository_snapshots_content_idx": { + "name": "company_skill_repository_snapshots_content_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "content_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "company_skill_repository_snapshots_company_id_companies_id_fk": { + "name": "company_skill_repository_snapshots_company_id_companies_id_fk", + "tableFrom": "company_skill_repository_snapshots", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, "public.company_skill_stars": { "name": "company_skill_stars", "schema": "", @@ -17906,6 +18253,18 @@ "primaryKey": false, "notNull": true }, + "repository_snapshot_id": { + "name": "repository_snapshot_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "repository_skill_path": { + "name": "repository_skill_path", + "type": "text", + "primaryKey": false, + "notNull": false + }, "revision_number": { "name": "revision_number", "type": "integer", @@ -18062,6 +18421,19 @@ "onDelete": "cascade", "onUpdate": "no action" }, + "company_skill_versions_repository_snapshot_id_company_skill_repository_snapshots_id_fk": { + "name": "company_skill_versions_repository_snapshot_id_company_skill_repository_snapshots_id_fk", + "tableFrom": "company_skill_versions", + "tableTo": "company_skill_repository_snapshots", + "columnsFrom": [ + "repository_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "restrict", + "onUpdate": "no action" + }, "company_skill_versions_author_agent_id_agents_id_fk": { "name": "company_skill_versions_author_agent_id_agents_id_fk", "tableFrom": "company_skill_versions", @@ -19576,6 +19948,34 @@ "method": "btree", "with": {} }, + "cost_events_data_quality_idx": { + "name": "cost_events_data_quality_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "occurred_at", + "isExpression": false, + "asc": false, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"cost_events\".\"billing_type\" = 'unknown' or (\"cost_events\".\"cost_status\" = 'unpriced' and \"cost_events\".\"billing_type\" <> 'subscription_included')", + "concurrently": false, + "method": "btree", + "with": {} + }, "cost_events_company_occurred_idx": { "name": "cost_events_company_occurred_idx", "columns": [ @@ -19826,7 +20226,7 @@ "checkConstraints": { "cost_events_usage_kind_check": { "name": "cost_events_usage_kind_check", - "value": "\"cost_events\".\"usage_kind\" = 'decision' or (\"cost_events\".\"usage_kind\" = 'agent' and \"cost_events\".\"agent_id\" is not null)" + "value": "\"cost_events\".\"usage_kind\" in ('decision', 'fast_response') or (\"cost_events\".\"usage_kind\" = 'agent' and \"cost_events\".\"agent_id\" is not null)" }, "cost_events_nonnegative_amounts": { "name": "cost_events_nonnegative_amounts", @@ -27491,6 +27891,378 @@ "checkConstraints": {}, "isRLSEnabled": false }, + "public.company_fast_responses": { + "name": "company_fast_responses", + "schema": "", + "columns": { + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": true, + "notNull": true + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "allow_sponsored": { + "name": "allow_sponsored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "company_fast_responses_company_id_companies_id_fk": { + "name": "company_fast_responses_company_id_companies_id_fk", + "tableFrom": "company_fast_responses", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "company_fast_responses_configured": { + "name": "company_fast_responses_configured", + "value": "(\"company_fast_responses\".\"connection_id\" is null) = (\"company_fast_responses\".\"grant_id\" is null) and (not \"company_fast_responses\".\"enabled\" or (\"company_fast_responses\".\"connection_id\" is not null and \"company_fast_responses\".\"model\" is not null))" + } + }, + "isRLSEnabled": false + }, + "public.fast_response_requests": { + "name": "fast_response_requests", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "company_id": { + "name": "company_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "source_key": { + "name": "source_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_comment_id": { + "name": "source_comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "issue_id": { + "name": "issue_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "project_id": { + "name": "project_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "agent_id": { + "name": "agent_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "responsible_user_id": { + "name": "responsible_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sponsored": { + "name": "sponsored", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "endpoint_id": { + "name": "endpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "delivery_id": { + "name": "delivery_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "session_generation": { + "name": "session_generation", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "publication_status": { + "name": "publication_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "connection_id": { + "name": "connection_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "grant_id": { + "name": "grant_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_request_id": { + "name": "provider_request_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cost_event_id": { + "name": "cost_event_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "error_code": { + "name": "error_code", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "comment_id": { + "name": "comment_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "accepted_at": { + "name": "accepted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "duration_ms": { + "name": "duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "input_tokens": { + "name": "input_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "output_tokens": { + "name": "output_tokens", + "type": "integer", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "fast_response_requests_source_idx": { + "name": "fast_response_requests_source_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_response_requests_history_idx": { + "name": "fast_response_requests_history_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "accepted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "fast_response_requests_work_idx": { + "name": "fast_response_requests_work_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"fast_response_requests\".\"status\" in ('pending', 'running')", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "fast_response_requests_company_id_companies_id_fk": { + "name": "fast_response_requests_company_id_companies_id_fk", + "tableFrom": "fast_response_requests", + "tableTo": "companies", + "columnsFrom": [ + "company_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "fast_response_requests_cost_event_id_cost_events_id_fk": { + "name": "fast_response_requests_cost_event_id_cost_events_id_fk", + "tableFrom": "fast_response_requests", + "tableTo": "cost_events", + "columnsFrom": [ + "cost_event_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "fast_response_requests_status_check": { + "name": "fast_response_requests_status_check", + "value": "\"fast_response_requests\".\"status\" in ('pending', 'running', 'succeeded', 'failed', 'unknown', 'skipped')" + } + }, + "isRLSEnabled": false + }, "public.feedback_exports": { "name": "feedback_exports", "schema": "", @@ -29938,6 +30710,34 @@ "method": "btree", "with": {} }, + "heartbeat_runs_cost_data_quality_idx": { + "name": "heartbeat_runs_cost_data_quality_idx", + "columns": [ + { + "expression": "company_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"finished_at\", \"created_at\") desc", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"heartbeat_runs\".\"cost_accounting_pending\" = true and \"heartbeat_runs\".\"status\" in ('succeeded','failed','timed_out','cancelled','interrupted')", + "concurrently": false, + "method": "btree", + "with": {} + }, "heartbeat_runs_execution_status_delivery_idx": { "name": "heartbeat_runs_execution_status_delivery_idx", "columns": [ @@ -32392,6 +33192,19 @@ "primaryKey": false, "notNull": false }, + "origin": { + "name": "origin", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'comment'" + }, + "fast_response_request_id": { + "name": "fast_response_request_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, "body": { "name": "body", "type": "text", diff --git a/packages/db/src/migrations/meta/_journal.json b/packages/db/src/migrations/meta/_journal.json index 4b1ef9079c1..22c832f0017 100644 --- a/packages/db/src/migrations/meta/_journal.json +++ b/packages/db/src/migrations/meta/_journal.json @@ -2332,6 +2332,13 @@ "when": 1791807836288, "tag": "0334_rare_slipstream", "breakpoints": true + }, + { + "idx": 335, + "version": "7", + "when": 1791832982875, + "tag": "0335_absurd_bulldozer", + "breakpoints": true } ] } \ No newline at end of file diff --git a/packages/db/src/schema/chat_slack_manager_grants.ts b/packages/db/src/schema/chat_slack_manager_grants.ts new file mode 100644 index 00000000000..fb4d5ef8861 --- /dev/null +++ b/packages/db/src/schema/chat_slack_manager_grants.ts @@ -0,0 +1,26 @@ +import { sql } from "drizzle-orm"; +import { check, integer, jsonb, pgTable, text, timestamp, uniqueIndex, uuid } from "drizzle-orm/pg-core"; +import { companies } from "./companies.js"; + +/** Reusable personal management authorization; never a bot runtime credential. */ +export const chatSlackManagerGrants = pgTable("chat_slack_manager_grants", { + id: uuid("id").primaryKey().defaultRandom(), + companyId: uuid("company_id").notNull().references(() => companies.id, { onDelete: "cascade" }), + userId: text("user_id").notNull(), + slackUserId: text("slack_user_id").notNull(), + workspaceId: text("workspace_id").notNull(), + workspaceName: text("workspace_name").notNull(), + managerAppId: text("manager_app_id").notNull(), + status: text("status").$type<"active" | "revoked" | "reauthorize">().notNull().default("active"), + accessSecretId: uuid("access_secret_id"), + refreshSecretId: uuid("refresh_secret_id"), + revision: integer("revision").notNull().default(0), + rateLimits: jsonb("rate_limits").$type>().notNull().default({}), + expiresAt: timestamp("expires_at", { withTimezone: true }), + createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), +}, t => [ + uniqueIndex("chat_slack_manager_grants_company_id_idx").on(t.companyId, t.id), + uniqueIndex("chat_slack_manager_grants_owner_idx").on(t.companyId, t.userId, t.managerAppId, t.workspaceId), + check("chat_slack_manager_grants_status_check", sql`${t.status} in ('active', 'revoked', 'reauthorize')`), +]); diff --git a/packages/db/src/schema/chat_slack_registrations.ts b/packages/db/src/schema/chat_slack_registrations.ts index 0edad4a8917..5d17b660bd0 100644 --- a/packages/db/src/schema/chat_slack_registrations.ts +++ b/packages/db/src/schema/chat_slack_registrations.ts @@ -1,12 +1,14 @@ import { sql } from "drizzle-orm"; import { check, foreignKey, jsonb, pgTable, text, timestamp, uuid } from "drizzle-orm/pg-core"; import type { SlackRegistrationStatus } from "@paperclipai/shared"; +import { chatSlackManagerGrants } from "./chat_slack_manager_grants.js"; import { chatEndpoints } from "./chat_channels.js"; /** App provisioning is distinct from installation and runtime credentials. No plaintext secrets. */ export const chatSlackRegistrations = pgTable("chat_slack_registrations", { endpointId: uuid("endpoint_id").primaryKey(), companyId: uuid("company_id").notNull(), + managerGrantId: uuid("manager_grant_id"), requestId: uuid("request_id").notNull(), status: text("status").$type().notNull(), manifest: jsonb("manifest").$type>().notNull(), @@ -22,6 +24,7 @@ export const chatSlackRegistrations = pgTable("chat_slack_registrations", { createdAt: timestamp("created_at", { withTimezone: true }).notNull().defaultNow(), updatedAt: timestamp("updated_at", { withTimezone: true }).notNull().defaultNow(), }, t => [ + foreignKey({ columns: [t.companyId, t.managerGrantId], foreignColumns: [chatSlackManagerGrants.companyId, chatSlackManagerGrants.id] }), foreignKey({ columns: [t.companyId, t.endpointId], foreignColumns: [chatEndpoints.companyId, chatEndpoints.id] }).onDelete("cascade"), check("chat_slack_registration_status_check", sql`${t.status} in ('creating', 'uncertain', 'failed', 'install', 'credentials_saved', 'configured', 'removed')`), ]); diff --git a/packages/db/src/schema/index.ts b/packages/db/src/schema/index.ts index 4c4bad38337..630a0869b55 100644 --- a/packages/db/src/schema/index.ts +++ b/packages/db/src/schema/index.ts @@ -243,6 +243,8 @@ export { chatVoiceReports } from "./chat_voice_reports.js"; export { chatVoicePhoneLines, chatVoiceInboundCalls } from "./chat_voice_inbound.js"; export * from "./decision_models.js"; export { userCompanyPreferences } from "./user_company_preferences.js"; + +export { chatSlackManagerGrants } from "./chat_slack_manager_grants.js"; export { aiSubscriptions, aiSubscriptionPrices, aiSubscriptionConnections } from "./ai_subscriptions.js"; export * from "./fast_responses.js"; diff --git a/packages/shared/src/slack-app-manifest.ts b/packages/shared/src/slack-app-manifest.ts index d8ec7d59ee0..e6d3f251424 100644 --- a/packages/shared/src/slack-app-manifest.ts +++ b/packages/shared/src/slack-app-manifest.ts @@ -22,7 +22,7 @@ export function buildSlackAppManifest(input: { redirectUri?: string; }) { return { - display_information: { name: input.app.appName }, + display_information: { name: input.app.appName, description: `Talk to ${input.agentName} and manage work in Paperclip.`.slice(0, 140) }, features: { app_home: { home_tab_enabled: false, messages_tab_enabled: true, messages_tab_read_only_enabled: false }, agent_view: { agent_description: "Work with a Paperclip agent in a task-backed conversation." }, @@ -60,6 +60,7 @@ export const slackRegistrationStateSchema = z.object({ status: slackRegistrationStatusSchema, appId: z.string().nullable().optional(), managementUrl: z.string().url().nullable().optional(), + managerGrantId: z.string().uuid().nullable().optional(), errorCode: z.string().nullable().optional(), }).strict(); export type SlackRegistrationState = z.infer; @@ -79,11 +80,33 @@ export const slackAccountStateSchema = z.object({ }).strict(); export type SlackAccountState = z.infer; export const slackInstallAuthorizationSchema = z.object({ authorizationUrl: z.string().url(), expiresAt: z.string().datetime() }); +export const slackManagerAuthorizationSchema = slackInstallAuthorizationSchema.extend({ handoff: z.object({ kind: z.literal("paperclip_cloud"), session: z.string().min(16).max(512) }).optional() }); +export type SlackManagerAuthorization = z.infer; export type SlackInstallAuthorization = z.infer; export const slackSetupActionSchema = z.object({}).strict(); +export const slackTestMessageSchema = z.object({ + requestId: z.string().uuid(), + slackUserId: z.string().regex(/^[UW][A-Z0-9]+$/).optional(), + accountCursor: z.string().min(1).max(512).optional(), +}).strict(); +export type SlackTestMessageInput = z.infer; +export const slackTestMessageResultSchema = z.discriminatedUnion("status", [ + z.object({ status: z.literal("sent"), slackUrl: z.string().url() }).strict(), + z.object({ status: z.literal("connection_required"), accounts: z.array(z.object({ + id: z.string(), name: z.string(), + }).strict()), nextCursor: z.string().nullable() }).strict(), + z.object({ status: z.literal("connection_pending"), principalId: z.string().uuid(), expiresAt: z.string().datetime() }).strict(), +]); +export type SlackTestMessageResult = z.infer; + export function slackRegistrationErrorMessage(code: string): string { const messages: Record = { + slack_managed_unavailable: "Managed Slack setup is unavailable. Try again later or use your own app before creation starts.", + slack_manager_reauthorize: "Reconnect your Slack workspace to continue setting up this agent.", + slack_approval_pending: "Slack is waiting for your workspace administrator to approve this app.", + slack_approval_required: "Your Slack workspace requires approval. Continue in Slack to request it.", + slack_approval_denied: "Your Slack administrator declined this installation. Contact them to continue.", slack_configuration_token_invalid: "Slack rejected the app configuration access token. Generate a new access token and try again.", slack_manifest_invalid: "Slack rejected this app configuration. Check the app details or use manual setup.", slack_manifest_update_pending: "Your Slack app is saved, but its event settings still need to be applied. Enter an app configuration access token to retry configuring the same app.", @@ -96,9 +119,29 @@ export function slackRegistrationErrorMessage(code: string): string { slack_install_identity_mismatch: "Slack returned a different app, workspace, or bot. Install the app created for this connection.", slack_install_scopes_missing: "Slack did not grant all required bot permissions. Install the app again and approve its requested permissions.", slack_bot_already_connected: "This Slack bot is already connected to Paperclip. Resume its existing connection or use a different app.", + slack_install_token_invalid: "Slack rejected the saved bot credentials. Continue in Slack to authorize this app again.", slack_configuration_incomplete: "The app is installed, but Paperclip could not finish connecting it. Retry connecting the saved installation.", slack_install_account_missing: "Slack did not identify your account. Authorize the same app again to connect your Slack account.", slack_install_account_conflict: "This Slack account already has an existing link. Manage account links in connection settings, or authorize with your own Slack account.", }; return messages[code] ?? "Slack setup could not be completed. Resume setup and try again."; } + +/** Persisted legacy values keep their original meaning. Missing means manual. */ +export type SlackSetupMethod = "managed" | "automatic" | "manual" | "existing"; +export function isProvisionedSlackSetup(method: string | undefined): boolean { + return method === "automatic" || method === "managed"; +} +export const SLACK_MANAGER_SCOPES = ["app_configurations:read", "app_configurations:write", "managed_apps:install"] as const; +export const slackSetupOptionsSchema = z.object({ + managedAvailable: z.boolean(), + defaultMethod: z.enum(["managed", "automatic"]), + workspaces: z.array(z.object({ grantId: z.string().uuid(), workspaceId: z.string(), workspaceName: z.string(), userId: z.string() })), +}); +export type SlackSetupOptions = z.infer; +export const slackManagedProvisionSchema = z.object({ + requestId: z.string().uuid(), grantId: z.string().uuid(), confirmedNoAppCreated: z.boolean().optional(), reconnect: z.boolean().optional(), +}).strict(); +export type SlackManagedProvisionInput = z.infer; +export const slackManagedResultSchema = z.object({ authorization: slackInstallAuthorizationSchema.optional() }); +export type SlackManagedResult = z.infer; diff --git a/packages/shared/src/types/chat-channels.ts b/packages/shared/src/types/chat-channels.ts index af49abfb8da..5ebcda06a2b 100644 --- a/packages/shared/src/types/chat-channels.ts +++ b/packages/shared/src/types/chat-channels.ts @@ -190,7 +190,8 @@ export interface SlackAppConfiguration { } export interface ChatEndpointSetupState { - slackSetupMethod?: "automatic" | "manual" | "existing"; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; + slackManagerError?: string; slackRegistration?: import("../slack-app-manifest.js").SlackRegistrationState; /** Provider-confirmed app icon outcome; configuration tokens are never retained. */ slackAvatar?: import("../slack-app-manifest.js").SlackAvatarState; @@ -508,10 +509,11 @@ export interface CreateChatEndpointInput { applicationId?: string; name?: string; slackApp?: SlackAppConfiguration; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; } export interface UpdateChatEndpointInput { - slackSetupMethod?: "automatic" | "manual" | "existing"; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; slackApp?: SlackAppConfiguration; communicationInstructions?: string; allowDirectMessages?: boolean; diff --git a/packages/shared/src/validators/chat-channels.ts b/packages/shared/src/validators/chat-channels.ts index f2553588ef3..53b263462f0 100644 --- a/packages/shared/src/validators/chat-channels.ts +++ b/packages/shared/src/validators/chat-channels.ts @@ -80,9 +80,10 @@ export const createChatEndpointSchema = z applicationId: z.string().uuid().optional(), name: z.string().trim().min(1).max(160).optional(), slackApp: slackAppConfigurationSchema.optional(), + slackSetupMethod: z.enum(["managed", "automatic", "manual", "existing"]).optional(), }) .strict() - .refine(value => !value.slackApp || value.provider === "slack", { + .refine(value => (!value.slackApp && !value.slackSetupMethod) || value.provider === "slack", { message: "Slack app details only apply to Slack connections", path: ["slackApp"], }); @@ -90,7 +91,7 @@ export const updateChatEndpointSchema = z .object({ communicationInstructions: multilineTextSchema.pipe(z.string().trim().max(4000)).optional(), slackApp: slackAppConfigurationSchema.optional(), - slackSetupMethod: z.enum(["automatic", "manual", "existing"]).optional(), + slackSetupMethod: z.enum(["managed", "automatic", "manual", "existing"]).optional(), allowDirectMessages: z.boolean().optional(), requireAtMention: z.boolean().optional(), allowGroupChats: z.boolean().optional(), diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 8b8b0f29641..6893a548784 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -1,3 +1,7 @@ +import * as activityLogModule from "../services/activity-log.js"; +import { chatSlackManagerGrants, chatSlackRegistrations, toolOauthStates } from "@paperclipai/db"; +import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../services/paperclip-cloud-connector.js"; +import { buildSlackAppManifest, SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; import { registerChatActionWork, dueChatAction } from "../services/chat-action-work.js"; import { createDeliveryWorkCoordinator } from "../services/delivery-work-coordinator.js"; import { registerChatDeliveryWork } from "../services/chat-delivery-work.js"; @@ -7,14 +11,11 @@ import { aiConnectionService } from "../services/ai-connections.js"; import { fastResponseService } from "../services/fast-responses.js"; import { fastResponseReceipt } from "../services/fast-response-provider.js"; import { fastResponseRequests } from "@paperclipai/db"; -import { chatSlackRegistrations, toolOauthStates } from "@paperclipai/db"; -import { buildSlackAppManifest } from "@paperclipai/shared"; import { toolAccessService } from "../services/tool-access.js"; import { toolActionRequests, toolInvocations } from "@paperclipai/db"; import { GitHubPublicationLeaseLost, withGitHubPublicationLease } from "../services/chat-github-publication-lease.js"; import { secretService } from "../services/secrets.js"; import { githubChatWizardService } from "../services/chat-github-wizard.js"; -import type { PaperclipCloudConnector } from "../services/paperclip-cloud-connector.js"; import { dispatchGitHubBotCloudEvent } from "../services/chat-github-cloud-ingress.js"; import { githubChatManagementService } from "../services/chat-github-management.js"; import { githubChatReviewService } from "../services/chat-github-reviews.js"; @@ -6262,7 +6263,9 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const token = `xoxb-bot-token-canary-${id}`; const scopes = buildSlackAppManifest({ app: appDetails, agentName: "Maya", webhookUrl: "https://paperclip.example/hook" }).oauth_config.scopes.bot; const state = { - failCreate: false, failValidate: false, failInventory: false, failAuth: false, failExchange: false, + managedAccessToken: token, rejectOldBotToken: false, + userListCursor: null as string | null, userListNextCursor: "", + managedBotAppId: appId, grantedScopes: scopes, managedError: null as string | null, failCreate: false, failValidate: false, failInventory: false, failAuth: false, failExchange: false, failManifestUpdate: false, timeoutManifestUpdate: false, beforeManifestUpdate: null as null | (() => Promise), failIcon: false, timeoutIcon: false, failUser: false, failWelcome: false, timeoutWelcome: false, @@ -6270,20 +6273,23 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { user: { id: installerId, team_id: "TAUTO", name: "installer", real_name: "Installing Person", is_bot: false, deleted: false }, beforeIcon: null as null | (() => Promise), beforeAuth: null as null | (() => Promise), + beforeExchange: null as null | (() => Promise), oauth: { ok: true, app_id: appId, token_type: "bot", access_token: token, team: { id: "TAUTO" }, bot_user_id: botId, authed_user: { id: installerId }, scope: scopes.join(",") }, }; const fallback = fakeSlackFetch(botId); const provider = vi.fn(async (input: string | URL | Request, init?: RequestInit) => { const url = String(input); + if (url.endsWith("bots.info")) return Response.json({ ok: true, bot: { id: "BMANAGED", app_id: state.managedBotAppId, user_id: botId, deleted: false } }); + if (url.endsWith("apps.managedInstall")) return Response.json(state.managedError ? { ok: false, error: state.managedError } : { ok: true, app_id: appId, team_id: "TAUTO", api_access_tokens: { bot_access_token: state.managedAccessToken } }); if (url.endsWith("apps.manifest.validate")) return Response.json(state.failValidate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true }); if (url.endsWith("apps.manifest.create")) { if (state.failCreate) throw new Error(`Timeout ${configToken} ${clientSecret}`); - return Response.json({ ok: true, app_id: appId, credentials: { client_id: "123.456", client_secret: clientSecret, signing_secret: signingSecret } }); + return Response.json({ ok: true, app_id: appId, team_id: "TAUTO", credentials: { client_id: "123.456", client_secret: clientSecret, signing_secret: signingSecret } }); } if (url.endsWith("apps.manifest.update")) { await state.beforeManifestUpdate?.(); if (state.timeoutManifestUpdate) throw new Error(`Manifest timeout ${configToken} ${clientSecret}`); - return Response.json(state.failManifestUpdate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true, app_id: appId }); + return Response.json(state.failManifestUpdate ? { ok: false, error: "invalid_auth", detail: configToken } : { ok: true, app_id: appId, permissions_updated: true }); } if (url.endsWith("apps.icon.set")) { await state.beforeIcon?.(); @@ -6291,13 +6297,19 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { return Response.json(state.failIcon ? { ok: false, error: "missing_scope", detail: `${configToken} ${signingSecret}` } : { ok: true }); } if (url.endsWith("oauth.v2.access")) { + await state.beforeExchange?.(); if (state.failExchange) throw new Error(`Timeout ${clientSecret} ${token}`); return Response.json(state.oauth); } if (url.endsWith("auth.test")) { await state.beforeAuth?.(); + if (state.rejectOldBotToken && new Headers(init?.headers).get("authorization") !== `Bearer ${state.managedAccessToken}`) return Response.json({ ok: false, error: "token_revoked" }); if (state.failAuth) throw new Error(`Temporary failure ${token}`); - return Response.json({ ok: true, team_id: "TAUTO", user_id: botId, user: `maya-${id}`, team: "Test workspace" }, { headers: { "x-oauth-scopes": scopes.join(",") } }); + return Response.json({ ok: true, team_id: "TAUTO", user_id: botId, bot_id: "BMANAGED", user: `maya-${id}`, team: "Test workspace" }, { headers: { "x-oauth-scopes": state.grantedScopes.join(",") } }); + } + if (url.endsWith("users.list")) { + state.userListCursor = new URLSearchParams(String(init?.body)).get("cursor"); + return Response.json({ ok: true, members: [state.user], response_metadata: { next_cursor: state.userListNextCursor } }); } if (url.endsWith("users.info")) { await state.beforeUser?.(); @@ -6323,6 +6335,408 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { const install = async () => new URL((await service.slackRegistration.install(endpoint.id, actor)).authorizationUrl).searchParams.get("state")!; return { ...company, endpoint, service, sdk, app, state, provider, create, install, createInput, token, appId, botId, installerId, avatarBytes, renderAvatar }; } + async function managedFixture() { + const broker = { + getCapabilities: vi.fn(async () => ["slack.manager"]), + getSlackManagerAppId: vi.fn(async () => "AMANAGER"), + startAuthorization: vi.fn(async (_input: { returnState: string }) => ({ authorizationUrl: "https://slack.com/oauth/v2/authorize?state=broker", expiresAt: new Date(Date.now() + 600_000).toISOString() })), + claim: vi.fn(), refresh: vi.fn(), revoke: vi.fn(async () => undefined), + }; + const f = await fixture({ slackManaged: { enabled: true, broker: broker as unknown as PaperclipCloudConnector } }); + const credentials: SealedConnectorCredentials = { v: 1, provider: "slack", profile: "slack.manager", instanceId: "test", environment: "staging", subject: actor.userId, companyId: f.companyId, + accessToken: "xoxe.xoxp-manager-access-canary", refreshToken: "xoxe-manager-refresh-canary", tokenType: "user", accessTokenExpiresAt: new Date(Date.now() + 43_200_000).toISOString(), refreshTokenExpiresAt: null, + scopes: [...SLACK_MANAGER_SCOPES], slackManager: { appId: "AMANAGER", workspaceId: "TAUTO", workspaceName: "Test workspace", userId: f.installerId } }; + broker.claim.mockResolvedValue(credentials); + broker.refresh.mockResolvedValue({ ...credentials, accessToken: "xoxe.rotated-canary", refreshToken: "xoxe.rotated-refresh-canary" }); + await f.service.update(f.endpoint.id, { slackSetupMethod: "managed" }, actor.userId); + const grants = f.service.slackRegistration.managerGrants; + const authorize = async () => { await grants.authorize(f.endpoint.id, actor); return broker.startAuthorization.mock.calls.at(-1)![0].returnState; }; + const authorizeState = await authorize(); + await grants.complete(authorizeState, "claim-canary", null, actor); + const choices = await grants.choices(f.companyId, actor); + expect(choices.workspaces).toHaveLength(1); + const grantId = choices.workspaces[0]!.grantId; + const input = { requestId: randomUUID(), grantId }; + const provision = () => f.service.slackRegistration.managed.provision(f.endpoint.id, actor, input); + return { ...f, broker, credentials, grants, authorize, authorizeState, grantId, input, provision }; + } + describe("Slack test messages", () => { + const posts = (f: Awaited>) => f.provider.mock.calls.filter(([url]) => String(url).endsWith("chat.postMessage")); + it("sends to the current linked user and reuses a durable result on duplicate requests", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + const input = { requestId: randomUUID() }; + const result = await request(f.app).post(`/api/chat-endpoints/${f.endpoint.id}/slack/test-message`).send(input).expect(200); + expect(result.body).toEqual({ status: "sent", slackUrl: "https://app.slack.com/client/TAUTO/DWELCOME" }); + await f.service.sendSlackTestMessage(f.endpoint.id, actor, input); + expect(posts(f)).toHaveLength(1); + expect(new URLSearchParams(String(posts(f)[0]![1]!.body)).get("channel")).toBe(f.installerId); + }); + it("links the actor's own manager identity without requiring a connect command", async () => { + const f = await managedFixture(); await f.provision(); + await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).toMatchObject({ status: "sent" }); + expect(await db.select().from(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id))).toEqual([expect.objectContaining({ paperclipUserId: actor.userId, status: "linked" })]); + }); + it("requires a private Slack challenge for an unknown account before linking and sending the test", async () => { + const f = await managedFixture(); await f.provision(); + await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await db.update(chatSlackManagerGrants).set({ userId: "another-user" }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.provider.mockClear(); const input = { requestId: randomUUID() }; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toEqual({ status: "connection_required", accounts: [{ id: f.installerId, name: "Installing Person" }], nextCursor: null }); + expect(posts(f)).toHaveLength(0); + const pending = await f.service.sendSlackTestMessage(f.endpoint.id, actor, { ...input, slackUserId: f.installerId }); + expect(pending.status).toBe("connection_pending"); + const links = await db.select().from(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + expect(links).toEqual([expect.objectContaining({ status: "pending", paperclipUserId: null })]); + const fields = new URLSearchParams(String(posts(f)[0]![1]!.body)); + expect(fields.get("text")).not.toContain("This is your test message"); + const blocks = JSON.parse(fields.get("blocks")!); + const invitationUrl = /<([^|]+)\|/.exec(blocks[1].text.text)![1]!; + const token = new URL(invitationUrl).searchParams.get("token")!; + expect(JSON.stringify(pending)).not.toContain(token); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toEqual(pending); + expect(posts(f)).toHaveLength(1); + await f.service.confirmIdentityLink(token, actor.userId); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" }); + expect(new URLSearchParams(String(posts(f).at(-1)![1]!.body)).get("text")).toContain("This is your test message"); + }); + it("loads another workspace page instead of truncating account discovery", async () => { + const f = await managedFixture(); await f.provision(); + await db.delete(chatIdentityLinks).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await db.update(chatSlackManagerGrants).set({ userId: "another-user" }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.provider.mockClear(); f.state.userListNextCursor = "next-page"; + const input = { requestId: randomUUID() }; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "connection_required", nextCursor: "next-page" }); + f.state.userListNextCursor = ""; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, { ...input, accountCursor: "next-page" })).toMatchObject({ status: "connection_required", nextCursor: null }); + expect(f.state.userListCursor).toBe("next-page"); + expect(posts(f)).toHaveLength(0); + }); + it("does not downgrade confirmed delivery when activity logging fails", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + const original = activityLogModule.logActivity; + const audit = vi.spyOn(activityLogModule, "logActivity").mockImplementation(async (database, entry) => { + if (entry.action === "chat_slack.test_message_sent") throw new Error("Synthetic activity failure"); + return original(database, entry); + }); + onTestFinished(() => audit.mockRestore()); + const input = { requestId: randomUUID() }; + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" }); + expect(await f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).toMatchObject({ status: "sent" }); + expect(posts(f)).toHaveLength(1); + }); + it("does not restore revoked access or take another user's identity", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + await db.update(chatIdentityLinks).set({ status: "revoked" }).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("existing link"); + await db.update(chatIdentityLinks).set({ status: "linked", paperclipUserId: "another-user" }).where(eq(chatIdentityLinks.endpointId, f.endpoint.id)); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID(), slackUserId: f.installerId })).rejects.toThrow("existing link"); + expect(posts(f)).toHaveLength(0); + }); + it("rejects wrong-workspace users, disabled DMs, and membership revocation", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + f.state.user.team_id = "TOTHER"; + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("personal Slack account"); + f.state.user.team_id = "TAUTO"; + await db.update(chatEndpoints).set({ allowDirectMessages: false }).where(eq(chatEndpoints.id, f.endpoint.id)); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("Enable direct messages"); + await db.update(chatEndpoints).set({ allowDirectMessages: true }).where(eq(chatEndpoints.id, f.endpoint.id)); + await db.update(companyMemberships).set({ status: "inactive" }).where(and(eq(companyMemberships.companyId, f.companyId), eq(companyMemberships.principalId, actor.userId))); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, { requestId: randomUUID() })).rejects.toThrow("active Paperclip account"); + expect(posts(f)).toHaveLength(0); + }); + it("redacts provider failures and never retries an ambiguous send", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); f.state.timeoutWelcome = true; + const input = { requestId: randomUUID() }; + const result = await request(f.app).post(`/api/chat-endpoints/${f.endpoint.id}/slack/test-message`).send(input).expect(422); + expect(JSON.stringify(result.body)).not.toContain("canary"); + await expect(f.service.sendSlackTestMessage(f.endpoint.id, actor, input)).rejects.toThrow("may already have been sent"); + expect(posts(f)).toHaveLength(1); + const actions = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, f.endpoint.id), eq(chatActions.kind, "slack_test_message"))); + expect(JSON.stringify(actions)).not.toContain("canary"); + expect(actions[0]!.status).toBe("uncertain"); + }); + }); + describe("managed Slack", () => { + it("keeps open-source setup independent of the broker", async () => { + const f = await fixture(); + expect(await f.service.slackRegistration.managerGrants.choices(f.companyId, actor)).toEqual({ managedAvailable: false, defaultMethod: "automatic", workspaces: [] }); + await f.create(); + expect((await f.service.get(f.endpoint.id)).setup.slackSetupMethod).toBe("automatic"); + await expect(f.service.update(f.endpoint.id, { slackSetupMethod: "managed" }, actor.userId)).rejects.toThrow(); + }); + it("keeps configured Cloud drafts managed when the broker profile disappears", async () => { + const f = await managedFixture(); + f.broker.getSlackManagerAppId.mockResolvedValue(null); + expect(f.grants.defaultMethod()).toBe("managed"); + expect(await f.grants.choices(f.companyId, actor)).toEqual({ managedAvailable: false, defaultMethod: "managed", workspaces: [] }); + const next = await f.service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId }, actor.userId); + expect(next.setup.slackSetupMethod).toBe("managed"); + await expect(f.provision()).rejects.toThrow(); + }); + it("creates and installs once, uploads the avatar and links only the manager grant owner", async () => { + const f = await managedFixture(); + await Promise.all([f.provision(), f.provision()]); + const saved = await f.service.get(f.endpoint.id); + expect(saved.setup).toMatchObject({ slackSetupMethod: "managed", slackRegistration: { status: "configured", appId: f.appId }, slackAvatar: { status: "uploaded" }, slackAccount: { status: "linked", paperclipUserId: actor.userId, externalUserId: f.installerId } }); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + const activities = await db.select().from(activityLog).where(eq(activityLog.companyId, f.companyId)); + for (const secret of [f.credentials.accessToken, f.credentials.refreshToken!, f.token, clientSecret, signingSecret, "claim-canary"]) expect(JSON.stringify({ saved, activities, choices: await f.grants.choices(f.companyId, actor) })).not.toContain(secret); + await expect(f.service.update(f.endpoint.id, { slackSetupMethod: "automatic" }, actor.userId)).rejects.toThrow(); + }); + it("reuses the same personal workspace grant for another draft without another authorization", async () => { + const f = await managedFixture(); + const draft = await f.service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId, slackSetupMethod: "managed" }, actor.userId); + expect((await f.grants.choices(f.companyId, actor)).workspaces[0]!.grantId).toBe(f.grantId); + const otherActor = { ...actor, userId: "another-user", bypassPermissionCheck: true }; + expect((await f.grants.choices(f.companyId, otherActor)).workspaces).toEqual([]); + await expect(f.service.slackRegistration.managed.provision(draft.id, otherActor, { ...f.input, requestId: randomUUID() })).rejects.toThrow(); + expect(f.broker.startAuthorization).toHaveBeenCalledTimes(1); + }); + it("installs a second bot using the same grant without new manager consent", async () => { + const f = await managedFixture(); await f.provision(); + const secondBot = `${f.botId}SECOND`; + const secondApp = `${f.appId}SECOND`; + const scopes = buildSlackAppManifest({ app: appDetails, agentName: "Maya", webhookUrl: "https://paperclip.example/hook" }).oauth_config.scopes.bot; + const provider: typeof fetch = async (input, init) => { + const url = String(input); + if (url.endsWith("apps.manifest.create")) return Response.json({ ok: true, app_id: secondApp, team_id: "TAUTO", credentials: { client_id: "222.333", client_secret: clientSecret, signing_secret: signingSecret } }); + if (url.endsWith("apps.managedInstall")) return Response.json({ ok: true, app_id: secondApp, team_id: "TAUTO", api_access_tokens: { bot_access_token: "xoxb-second-managed" } }); + if (url.endsWith("auth.test")) return Response.json({ ok: true, team_id: "TAUTO", user_id: secondBot, bot_id: "BSECOND", team: "Test workspace" }, { headers: { "x-oauth-scopes": scopes.join(",") } }); + if (url.endsWith("bots.info")) return Response.json({ ok: true, bot: { id: "BSECOND", app_id: secondApp, user_id: secondBot, deleted: false } }); + return f.provider(input, init); + }; + const { service } = createService(new FakeChatSdkRuntime(), provider, { renderSlackAvatar: f.renderAvatar, slackManaged: { enabled: true, broker: f.broker as unknown as PaperclipCloudConnector } }); + const second = await service.create(f.companyId, { provider: "slack", assignedAgentId: f.assignedAgentId, slackSetupMethod: "managed", slackApp: appDetails }, actor.userId); + await service.slackRegistration.managed.provision(second.id, actor, { grantId: f.grantId, requestId: randomUUID() }); + expect((await service.get(second.id)).setup.slackRegistration).toMatchObject({ status: "configured", appId: secondApp }); + expect(f.broker.startAuthorization).toHaveBeenCalledTimes(1); + expect(f.broker.claim).toHaveBeenCalledTimes(1); + }); + it("rejects missing tool scopes before activating a managed bot", async () => { + const f = await managedFixture(); f.state.grantedScopes = []; + await f.provision(); + const saved = await f.service.get(f.endpoint.id); + expect(saved.providerAccountId).toBeNull(); + expect(saved.setup.slackRegistration).toMatchObject({ status: "credentials_saved", errorCode: "slack_install_scopes_missing" }); + }); + it.each(["revoked", "attention", "active"] as const)("reinstalls the configured app on an explicit %s reconnect", async status => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + await db.update(chatEndpoints).set({ status }).where(eq(chatEndpoints.id, f.endpoint.id)); + f.state.managedAccessToken = `xoxb-reconnected-${f.botId}`; f.state.rejectOldBotToken = true; + const reconnectInput = { ...f.input, requestId: randomUUID(), reconnect: true }; + await f.service.slackRegistration.managed.provision(f.endpoint.id, actor, reconnectInput); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(0); + expect((await f.service.get(f.endpoint.id)).status).not.toBe("revoked"); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "configured", appId: f.appId }); + await f.service.slackRegistration.managed.provision(f.endpoint.id, actor, reconnectInput); + await f.provision(); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + }); + it("preserves configured-app reconnect intent when managed installation fails", async () => { + const f = await managedFixture(); await f.provision(); f.provider.mockClear(); + await db.update(chatEndpoints).set({ status: "revoked" }).where(eq(chatEndpoints.id, f.endpoint.id)); + f.state.managedError = "temporary_error"; + await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "install", appId: f.appId }); + f.state.managedError = null; + await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(0); + }); + it("reinstalls the saved app after missing bot scopes without creating another app", async () => { + const f = await managedFixture(); const scopes = f.state.grantedScopes; + f.state.grantedScopes = []; await f.provision(); + f.state.grantedScopes = scopes; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(2); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("retains reinstall intent after a provider failure instead of retrying rejected credentials", async () => { + const f = await managedFixture(); const scopes = f.state.grantedScopes; + f.state.grantedScopes = []; await f.provision(); + f.state.managedError = "internal_error"; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration).toMatchObject({ status: "install", errorCode: "slack_provider_failure" }); + f.state.managedError = null; f.state.grantedScopes = scopes; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(3); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("clears a manager reconnect error after renewing the bound grant", async () => { + const f = await managedFixture(); f.state.managedError = "invalid_auth"; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_manager_reauthorize"); + await f.grants.complete(await f.authorize(), "new-claim", null, actor); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBeNull(); + f.state.managedError = null; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("reconnects a rejected manager token without losing pending manifest configuration", async () => { + const f = await managedFixture(); f.state.failManifestUpdate = true; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_manifest_update_pending"); + expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]); + expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("reauthorize"); + await f.grants.complete(await f.authorize(), "new-claim", null, actor); + f.state.failManifestUpdate = false; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect((await f.service.get(f.endpoint.id)).setup.slackAvatar?.status).toBe("uploaded"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.icon.set"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("does not restore a revoked grant when an in-flight refresh returns", async () => { + const f = await managedFixture(); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.broker.refresh.mockImplementation(async () => { + await f.grants.revoke(f.grantId, f.companyId, actor); + return f.credentials; + }); + await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow(); + expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("revoked"); + }); + it("claims concurrent manager callbacks once", async () => { + const f = await managedFixture(); const state = await f.authorize(); + const results = await Promise.allSettled([1, 2].map(() => f.grants.complete(state, "claim", null, actor))); + expect(results.filter(result => result.status === "fulfilled")).toHaveLength(1); + expect(f.broker.claim).toHaveBeenCalledTimes(2); + }); + it("atomically consumes manager state and rejects wrong actors and expired attempts", async () => { + const f = await managedFixture(); + await expect(f.grants.complete(f.authorizeState, "replay", null, actor)).rejects.toThrow(); + const state = await f.authorize(); + await expect(f.grants.complete(state, "claim", null, { ...actor, userId: "another-user", bypassPermissionCheck: true })).rejects.toThrow(); + await db.update(toolOauthStates).set({ expiresAt: new Date(0) }).where(eq(toolOauthStates.state, state)); + await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow(); + expect(f.broker.claim).toHaveBeenCalledTimes(1); + }); + it("preserves declined manager authorization as a safe recoverable outcome", async () => { + const f = await managedFixture(); + await f.grants.complete(await f.authorize(), null, "access_denied", actor); + expect((await f.service.get(f.endpoint.id)).setup.slackManagerError).toBe("slack_install_declined"); + }); + it("resumes saved installation credentials after inventory failure without creating or reinstalling", async () => { + const f = await managedFixture(); f.state.failInventory = true; + await f.provision(); + expect((await f.service.slackRegistration.registration(f.endpoint.id))?.status).toBe("credentials_saved"); + f.state.failInventory = false; await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("configured"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("never recreates an uncertain app", async () => { + const f = await managedFixture(); f.state.failCreate = true; + await f.provision(); await f.provision(); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.status).toBe("uncertain"); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.manifest.create"))).toHaveLength(1); + }); + it("uses bound OAuth for approval fallback and rejects a different installer", async () => { + const f = await managedFixture(); f.state.managedError = "app_approval_request_eligible"; + const result = await f.provision(); + const state = new URL(result.authorization!.authorizationUrl).searchParams.get("state")!; + f.state.oauth.authed_user.id = "UOTHER"; + await f.service.slackRegistration.complete(state, "code-canary", null, actor); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_install_identity_mismatch"); + expect((await f.service.get(f.endpoint.id)).providerAccountId).toBeNull(); + }); + it("does not activate a bot when the manager grant is revoked during child OAuth", async () => { + const f = await managedFixture(); f.state.managedError = "app_approval_request_eligible"; + const result = await f.provision(); + const state = new URL(result.authorization!.authorizationUrl).searchParams.get("state")!; + f.state.beforeExchange = () => f.grants.revoke(f.grantId, f.companyId, actor); + await f.service.slackRegistration.complete(state, "code-canary", null, actor); + const saved = await f.service.get(f.endpoint.id); + expect(saved.providerAccountId).toBeNull(); + expect(saved.setup.slackRegistration?.errorCode).toBe("slack_install_identity_mismatch"); + }); + it("uses the held transaction for grant queries without acquiring another pool connection", async () => { + const f = await managedFixture(); + const transactionContext = new AsyncLocalStorage(); + const originalTransaction = db.transaction.bind(db); + const transactionSpy = vi.spyOn(db, "transaction").mockImplementation((work, ...args) => + originalTransaction(tx => transactionContext.run(true, () => work(tx)), ...args)); + const querySpies = (["select", "insert", "update", "delete", "execute"] as const).map(method => { + const original = db[method].bind(db) as (...args: unknown[]) => unknown; + return vi.spyOn(db, method).mockImplementation(((...args: unknown[]) => { + if (transactionContext.getStore()) throw new Error("Acquired outer pool connection from held transaction"); + return original(...args); + }) as never); + }); + try { + await f.grants.token(f.grantId, f.companyId, actor); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + await f.grants.token(f.grantId, f.companyId, actor); + expect(f.broker.refresh).toHaveBeenCalledTimes(1); + await f.grants.request(f.grantId, f.companyId, actor, "apps.manifest.validate", { manifest: "{}" }); + await f.grants.complete(await f.authorize(), "renewed-claim", null, actor); + expect((await f.service.get(f.endpoint.id)).setup.slackManagerError).toBeUndefined(); + await f.grants.revoke(f.grantId, f.companyId, actor); + expect(f.broker.revoke).toHaveBeenCalledTimes(2); + } finally { + transactionSpy.mockRestore(); + for (const spy of querySpies) spy.mockRestore(); + } + }); + it("serializes refreshes across endpoints and never replays an ambiguous refresh", async () => { + const f = await managedFixture(); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + const tokens = await Promise.all([1, 2].map(() => f.grants.token(f.grantId, f.companyId, actor))); + expect(tokens.every(result => result.token === "xoxe.rotated-canary")).toBe(true); + expect(f.broker.refresh).toHaveBeenCalledTimes(1); + await db.update(chatSlackManagerGrants).set({ expiresAt: new Date(0) }).where(eq(chatSlackManagerGrants.id, f.grantId)); + f.broker.refresh.mockRejectedValue(new Error("provider echoed manager-canary")); + await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow(); + await expect(f.grants.token(f.grantId, f.companyId, actor)).rejects.toThrow(); + expect(f.broker.refresh).toHaveBeenCalledTimes(2); + }); + it("rejects a bot token belonging to a different Slack app before activation", async () => { + const f = await managedFixture(); f.state.managedBotAppId = "ADIFFERENT"; + await f.provision(); + const saved = await f.service.get(f.endpoint.id); + expect(saved.providerAccountId).toBeNull(); + expect(saved.setup.slackRegistration).toMatchObject({ status: "credentials_saved", errorCode: "slack_install_identity_mismatch" }); + }); + it("rejects manager identity changes and both callback-origin and ingress-origin drift", async () => { + const f = await managedFixture(); const state = await f.authorize(); + for (const change of [{ publicBaseUrl: "https://changed.example" }, { webhookPublicBaseUrl: "https://changed.example" }]) { + const changed = createService(new FakeChatSdkRuntime(), f.provider, { ...change, slackManaged: { enabled: true, broker: f.broker as unknown as PaperclipCloudConnector } }).service; + await expect(changed.slackRegistration.managerGrants.complete(state, "claim", null, actor)).rejects.toThrow(); + } + f.broker.getSlackManagerAppId.mockResolvedValue("ANEWMANAGER"); + await expect(f.provision()).rejects.toThrow(); + expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]); + }); + it("preserves provider rate-limit backoff across subsequent attempts", async () => { + const f = await managedFixture(); f.state.managedError = "ratelimited"; + await f.provision(); + f.state.managedError = null; await f.provision(); + expect(f.provider.mock.calls.filter(([url]) => String(url).endsWith("apps.managedInstall"))).toHaveLength(1); + expect((await f.service.get(f.endpoint.id)).setup.slackRegistration?.errorCode).toBe("slack_setup_rate_limited"); + }); + it("routes the broker claim_id callback back to the saved wizard without exposing the handoff", async () => { + const f = await managedFixture(); const state = await f.authorize(); + const response = await request(f.app).get(`/api/chat-slack/managed/oauth/callback?state=${state}&claim_id=sealed-claim-canary`).set("accept", "application/json").expect(303); + expect(f.broker.claim.mock.calls.at(-1)![0].claimId).toBe("sealed-claim-canary"); + expect(response.headers.location).toContain(f.endpoint.id); + expect(response.text).not.toContain("sealed-claim-canary"); + expect(response.headers["cache-control"]).toBe("no-store"); + }); + it("does not accept another company's grant or keep authorizations after removal", async () => { + const f = await managedFixture(); const other = await seedCompany(); + await expect(f.grants.token(f.grantId, other.companyId, { ...actor, bypassPermissionCheck: true })).rejects.toThrow(); + const state = await f.authorize(); + await f.service.configure(f.endpoint.id, { action: "remove" }, actor.userId); + await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow(); + expect((await f.grants.get(f.grantId, f.companyId, actor)).status).toBe("active"); + }); + it("revocation invalidates pending authorization and prevents future provisioning", async () => { + const f = await managedFixture(); const state = await f.authorize(); + await f.grants.revoke(f.grantId, f.companyId, actor); + await expect(f.grants.complete(state, "claim", null, actor)).rejects.toThrow(); + await expect(f.provision()).rejects.toThrow(); + expect((await f.grants.choices(f.companyId, actor)).workspaces).toEqual([]); + }); + }); function setupEvent(f: Awaited>, patch: Record = {}, url = f.endpoint.setup.webhookUrl!, secret = signingSecret, timestamp?: string) { return signedSlackWebhookRequest({ url, contentType: "application/json", signingSecret: secret, ...(timestamp ? { timestamp } : {}), body: JSON.stringify({ type: "event_callback", api_app_id: f.appId, team_id: "TAUTO", event_id: "EvSETUP", event: { diff --git a/server/src/__tests__/http-log-policy.test.ts b/server/src/__tests__/http-log-policy.test.ts index f6a92125283..0c57d5159b0 100644 --- a/server/src/__tests__/http-log-policy.test.ts +++ b/server/src/__tests__/http-log-policy.test.ts @@ -65,7 +65,7 @@ describe("isPrivateWebhookHttpRequest", () => { describe("isSecretSensitiveHttpRequest", () => { it("protects Slack registration, installation, recovery, and all callback methods", () => { for (const suffix of ["registration", "install", "resume"]) expect(isSecretSensitiveHttpRequest("POST", `/api/chat-endpoints/id/slack/${suffix}`)).toBe(true); - for (const method of ["GET", "POST", "DELETE"]) for (const path of ["/api/chat-slack/oauth/callback?code=canary", "https://paperclip.test/API/CHAT-SLACK/OAUTH/callback?code=canary"]) + for (const method of ["GET", "POST", "DELETE"]) for (const path of ["/api/chat-slack/oauth/callback?code=canary", "/api/chat-slack/managed/oauth/callback?claim=canary", "/api/chat-slack/managed/oauth/callback?code=canary", "/api/chat-endpoints/endpoint/slack/managed/authorize", "/api/chat-endpoints/endpoint/slack/managed/provision", "https://paperclip.test/API/CHAT-SLACK/OAUTH/callback?code=canary"]) expect(isSecretSensitiveHttpRequest(method, path)).toBe(true); }); it("identifies credential-bearing chat setup mutations", () => { diff --git a/server/src/__tests__/redact-sensitive.test.ts b/server/src/__tests__/redact-sensitive.test.ts index b0467a7fb96..151c9212a5d 100644 --- a/server/src/__tests__/redact-sensitive.test.ts +++ b/server/src/__tests__/redact-sensitive.test.ts @@ -6,6 +6,11 @@ import { stripSecretBearingUrlParts, } from "../middleware/redact-sensitive.js"; +it("redacts Slack manager grants and sealed OAuth handoffs", () => { + const result = redactSensitive({ managerToken: "manager-canary", refreshToken: "refresh-canary", sealed: { ct: "sealed-canary" }, handoff: { token: "handoff-canary" }, claim_id: "claim-canary" }); + expect(JSON.stringify(result)).not.toContain("canary"); +}); + describe("redactSensitive", () => { it("redacts a plaintext password field on a sign-in body", () => { const body = { diff --git a/server/src/__tests__/slack-development-manager.test.ts b/server/src/__tests__/slack-development-manager.test.ts new file mode 100644 index 00000000000..314e087843c --- /dev/null +++ b/server/src/__tests__/slack-development-manager.test.ts @@ -0,0 +1,73 @@ +import { chmodSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; +import { developmentSlackManager } from "../services/connectors/slack/setup/development-manager.js"; + +const roots: string[] = []; +afterEach(() => roots.splice(0).forEach(path => rmSync(path, { recursive: true, force: true }))); +function fixture() { + const root = mkdtempSync(join(tmpdir(), "slack-development-")); roots.push(root); + const path = join(root, "manager.json"); + writeFileSync(path, JSON.stringify({ appId: "A123", clientId: "123.456", clientSecret: "client-secret-canary" }), { mode: 0o600 }); + return { path, env: { NODE_ENV: "development", PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED: "true", PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG: path } }; +} +const origin = () => "https://local-test.trycloudflare.com"; +const identity = { subject: "actor", companyId: "company", profile: "slack.manager" as const }; +const user = { id: "U123", token_type: "user", access_token: "access-canary", refresh_token: "refresh-canary", expires_in: 43200, scope: SLACK_MANAGER_SCOPES.join(",") }; +const response = (value: unknown) => new Response(JSON.stringify(value), { headers: { "content-type": "application/json" } }); + +describe("local managed Slack qualification", () => { + it("requires explicit development opt-in and refuses production", () => { + const { env } = fixture(); + expect(developmentSlackManager(origin, {})).toBeNull(); + expect(developmentSlackManager(origin, { ...env, NODE_ENV: "production" })).toBeNull(); + expect(developmentSlackManager(origin, { ...env, PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED: "false" })).toBeNull(); + }); + it("rejects readable secrets and invalid HTTPS origins", async () => { + const { env, path } = fixture(); chmodSync(path, 0o644); + expect(() => developmentSlackManager(origin, env)).toThrow(); + chmodSync(path, 0o600); + await expect(developmentSlackManager(() => "http://local.test", env)!.getSlackManagerAppId()).rejects.toThrow(); + }); + it("builds a scoped authorization URL without exposing the client secret", async () => { + const { env } = fixture(); + const client = developmentSlackManager(origin, env)!; + const result = await client.startAuthorization({ ...identity, returnUri: `${origin()}/api/chat-slack/managed/oauth/callback`, returnState: "bound-state" }); + const url = new URL(result.authorizationUrl); + expect(url.origin).toBe("https://slack.com"); + expect(url.searchParams.get("state")).toBe("bound-state"); + expect(url.searchParams.get("user_scope")).toBe(SLACK_MANAGER_SCOPES.join(",")); + expect(JSON.stringify(result)).not.toContain("client-secret-canary"); + await expect(client.startAuthorization({ ...identity, returnUri: "https://other.test/callback", returnState: "bound-state" })).rejects.toThrow(); + }); + it("exchanges the code against the exact callback and preserves actor/workspace identity", async () => { + const { env } = fixture(); + const fetcher = vi.fn().mockResolvedValue(response({ ok: true, app_id: "A123", team: { id: "T123", name: "Paperclip" }, authed_user: user })); + const result = await developmentSlackManager(origin, env, fetcher)!.claim({ ...identity, claimId: "code-canary", redemptionId: "once" }); + expect(result.slackManager).toEqual({ appId: "A123", workspaceId: "T123", workspaceName: "Paperclip", userId: "U123" }); + expect(result.subject).toBe("actor"); expect(result.companyId).toBe("company"); + const [url, request] = fetcher.mock.calls[0]; + expect(url).toBe("https://slack.com/api/oauth.v2.access"); + expect(request.redirect).toBe("error"); + expect(request.body.get("redirect_uri")).toBe(`${origin()}/api/chat-slack/managed/oauth/callback`); + expect(request.body.get("code")).toBe("code-canary"); + }); + it.each(["wrong-app", "missing-scope", "provider-echo"])("rejects %s without echoing secrets", async reason => { + const { env } = fixture(); + const value = reason === "provider-echo" ? { ok: false, error: "client-secret-canary code-canary" } : { ok: true, app_id: reason === "wrong-app" ? "AOTHER" : "A123", team: { id: "T123", name: "Paperclip" }, authed_user: { ...user, scope: reason === "missing-scope" ? "" : user.scope } }; + const client = developmentSlackManager(origin, env, vi.fn().mockResolvedValue(response(value)))!; + const error = await client.claim({ ...identity, claimId: "code-canary", redemptionId: "once" }).catch(error => error); + expect(error).toBeInstanceOf(Error); + expect(String(error)).not.toMatch(/canary/); + }); + it("rotates refresh credentials and verifies revocation", async () => { + const { env } = fixture(); + const fetcher = vi.fn().mockResolvedValueOnce(response({ ok: true, ...user })).mockResolvedValueOnce(response({ ok: false, error: "invalid_auth" })); + const client = developmentSlackManager(origin, env, fetcher)!; + expect((await client.refresh({ ...identity, refreshToken: "old-refresh" })).refreshToken).toBe("refresh-canary"); + expect(fetcher.mock.calls[0][1].body.get("grant_type")).toBe("refresh_token"); + await expect(client.revoke({ ...identity, token: "access-canary" })).rejects.toThrow(); + }); +}); diff --git a/server/src/middleware/http-log-policy.ts b/server/src/middleware/http-log-policy.ts index 5c5731659d2..222f97d476c 100644 --- a/server/src/middleware/http-log-policy.ts +++ b/server/src/middleware/http-log-policy.ts @@ -93,7 +93,7 @@ export function isSecretSensitiveHttpRequest( // Avatar uploads and dedicated Dot MCP calls may contain private image bytes. if (url && /^(?:\/mcp\/runner(?:\/|$)|\/api\/companies\/[^/]+\/agents\/[^/]+\/avatar(?:\/|$))/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; if (!method || !url) return false; - if (/^\/api\/chat-slack\/oauth(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; + if (/^\/api\/chat-slack\/(?:managed\/)?oauth(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; if (/^\/api\/chat-endpoints\/[^/]+\/slack(?:\/|$)/i.test(normalizePath(url).replace(/^https?:\/\/[^/]*/i, ""))) return true; if (!SECRET_SENSITIVE_HTTP_METHODS.has(method.toUpperCase())) return false; const pathname = normalizePath(url); diff --git a/server/src/middleware/redact-sensitive.ts b/server/src/middleware/redact-sensitive.ts index 0b4ba2d39d4..3d6b874f6d7 100644 --- a/server/src/middleware/redact-sensitive.ts +++ b/server/src/middleware/redact-sensitive.ts @@ -16,6 +16,13 @@ const SENSITIVE_KEYS = new Set([ // material under `credentials`. Redact the whole subtree instead of trying // to keep an ever-changing allowlist of provider-specific field names in // sync with every connector. + "sealed", + "handoff", + "claim", + "claimid", + "claim_id", + "managertoken", + "manager_token", "configurationtoken", "configuration_token", "credential", diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index 6395e5ecd89..6dbde753c35 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -1,4 +1,4 @@ -import { slackRegistrationSchema, slackSetupActionSchema } from "@paperclipai/shared"; +import { slackTestMessageSchema, slackRegistrationSchema, slackSetupActionSchema, slackManagedProvisionSchema } from "@paperclipai/shared"; import { isCrossSiteOAuthCallbackNavigation, oauthCallbackInterstitialHtml } from "../lib/oauth-browser-return.js"; import { Router, @@ -164,6 +164,10 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { async (req, res) => { const companyId = req.params.companyId as string; await assertConnectionManager(req, companyId); + if (req.body.provider === "slack" && !req.body.slackSetupMethod) { + req.body.slackSetupMethod = service.slackRegistration.managerGrants.defaultMethod(); + } + if (req.body.slackSetupMethod === "managed" && service.slackRegistration.managerGrants.defaultMethod() !== "managed") throw forbidden("Managed Slack setup is unavailable"); res .status(201) .json(await service.create(companyId, req.body, actorUserId(req))); @@ -182,6 +186,50 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { return { userId: actor.actorId, sessionId: actor.sessionId, bypassPermissionCheck: req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true }; }; + router.get("/companies/:companyId/chat-slack/setup-options", async (req, res) => { + const companyId = req.params.companyId as string; + await assertConnectionManager(req, companyId); + res.set("Cache-Control", "no-store"); + res.json(await service.slackRegistration.managerGrants.choices(companyId, slackActor(req))); + }); + router.post("/chat-endpoints/:endpointId/slack/managed/authorize", validate(slackSetupActionSchema), async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.slackRegistration.managerGrants.authorize(endpointId(req), slackActor(req))); + }); + router.post("/chat-endpoints/:endpointId/slack/managed/provision", validate(slackManagedProvisionSchema), async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.slackRegistration.managed.provision(endpointId(req), slackActor(req), req.body)); + }); + router.delete("/companies/:companyId/chat-slack/manager-grants/:grantId", async (req, res) => { + const companyId = req.params.companyId as string; + await assertConnectionManager(req, companyId); + await service.slackRegistration.managerGrants.revoke(req.params.grantId as string, companyId, slackActor(req)); + res.sendStatus(204); + }); + router.get("/chat-slack/managed/oauth/callback", async (req, res) => { + res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer"); + const actor = slackActor(req); + const state = typeof req.query.state === "string" ? req.query.state : ""; + const expiredId = await service.slackRegistration.managerGrants.expiredReturn(state, actor); + if (expiredId) { res.redirect(303, await service.slackRegistration.returnPath(expiredId)); return; } + const pending = await service.slackRegistration.managerGrants.pending(state, actor); + await assertConnectionManager(req, pending.current.endpoint.companyId); + if (req.get("accept")?.includes("text/html") && isCrossSiteOAuthCallbackNavigation(req)) { + res.type("html").send(oauthCallbackInterstitialHtml()); return; + } + const id = await service.slackRegistration.managerGrants.complete(state, + typeof req.query.claim_id === "string" ? req.query.claim_id : null, + typeof req.query.error === "string" ? req.query.error : null, actor, + typeof req.query.code === "string" ? req.query.code : null); + res.redirect(303, await service.slackRegistration.returnPath(id)); + }); + router.post("/chat-endpoints/:endpointId/slack/test-message", validate(slackTestMessageSchema), async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + res.set("Cache-Control", "no-store"); + res.json(await service.sendSlackTestMessage(endpointId(req), slackActor(req), req.body)); + }); router.post("/chat-endpoints/:endpointId/slack/registration", validate(slackRegistrationSchema), async (req, res) => { if (!(await assertEndpointManagementAccess(req, res))) return; res.set("Cache-Control", "no-store"); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index f39cd53431a..9b113c62805 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1,5 +1,5 @@ import { updateFastResponseSchema } from "@paperclipai/shared"; -import { slackRegistrationSchema, slackSetupActionSchema, slackInstallAuthorizationSchema, slackRegistrationStateSchema, slackAppConfigurationSchema, slackAvatarStateSchema, slackAccountStateSchema } from "@paperclipai/shared"; +import { slackTestMessageSchema, slackTestMessageResultSchema, slackManagerAuthorizationSchema, slackSetupOptionsSchema, slackManagedProvisionSchema, slackManagedResultSchema, slackRegistrationSchema, slackSetupActionSchema, slackInstallAuthorizationSchema, slackRegistrationStateSchema, slackAppConfigurationSchema, slackAvatarStateSchema, slackAccountStateSchema } from "@paperclipai/shared"; import { experimentalApiMetadata } from "./experimental-api-metadata.js"; import { experimentalApiPaths, @@ -803,8 +803,9 @@ const chatAdapterCapabilitiesResponseSchema = z const chatEndpointSetupResponseSchema = z .object({ step: z.enum(["choose_agent", "provider_setup", "test", "complete"]), - slackSetupMethod: z.enum(["automatic", "manual", "existing"]).optional(), + slackSetupMethod: z.enum(["managed", "automatic", "manual", "existing"]).optional(), slackRegistration: slackRegistrationStateSchema.optional(), + slackManagerError: z.string().optional(), slackAvatar: slackAvatarStateSchema.optional(), slackAccount: slackAccountStateSchema.optional(), slackOAuthCallbackUri: z.string().nullable().optional(), @@ -2563,6 +2564,41 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "get", path: "/api/companies/{companyId}/chat-slack/setup-options", tags: ["chat-channels"], + summary: "Discover Slack setup methods and the current user's authorized workspaces", + description: "Managed setup is advertised only for approved Cloud deployments with the broker profile enabled. Self-hosted discovery makes no hosted-service requests. Grants are personal and company scoped.", + request: { params: z.object({ companyId: z.string().uuid() }) }, responses: { 200: r.ok(slackSetupOptionsSchema), 401: r.unauthorized, 403: r.forbidden }, +}); +for (const action of ["authorize", "provision"] as const) registry.registerPath({ + method: "post", path: `/api/chat-endpoints/{endpointId}/slack/managed/${action}`, tags: ["chat-channels"], + summary: action === "authorize" ? "Authorize a personal Slack manager workspace grant" : "Provision the saved managed Slack app", + description: "Requires the selected managed method, company membership, connection-management permission and Cloud availability. Authorization state is actor/session bound and single use. Provisioning accepts a personal grant ID and idempotent request ID; no provider credentials or arbitrary manifests are accepted. Explicit reconnect uses reconnect=true with a fresh request ID to reinstall the saved app. Repeating that reconnect request returns its saved result without reinstalling a healthy connection.", + request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(action === "authorize" ? slackSetupActionSchema : slackManagedProvisionSchema) }, + responses: { 200: r.ok(action === "authorize" ? slackManagerAuthorizationSchema : slackManagedResultSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable }, +}); +registry.registerPath({ + method: "delete", path: "/api/companies/{companyId}/chat-slack/manager-grants/{grantId}", tags: ["chat-channels"], + summary: "Revoke the current user's workspace grant for future managed provisioning", + description: "Removes local manager credentials and invalidates pending manager authorization. Installed bots remain connected; this does not uninstall Slack apps.", + request: { params: z.object({ companyId: z.string().uuid(), grantId: z.string().uuid() }) }, responses: { 204: { description: "Personal manager grant revoked" }, 401: r.unauthorized, 403: r.forbidden }, +}); +registry.registerPath({ + method: "get", path: "/api/chat-slack/managed/oauth/callback", tags: ["chat-channels"], + summary: "Redeem a sealed manager authorization and return to Slack setup", + description: "Authenticated, same-origin continuation with company, actor, session, endpoint revision and configured-origin checks. The broker handoff is single use; credentials are never returned to the browser. Explicit local development qualification accepts a Slack code instead of a broker claim; production ignores code.", + request: { query: z.object({ state: z.string(), claim_id: z.string().optional(), code: z.string().optional(), error: z.string().optional() }) }, + responses: { 200: { description: "Same-origin continuation page" }, 303: { description: "Return to saved setup" }, 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 409: r.conflict }, +}); + +registry.registerPath({ + method: "post", path: "/api/chat-endpoints/{endpointId}/slack/test-message", tags: ["chat-channels"], + summary: "Connect the current user's Slack identity and send a test DM", + description: "Requires a board connection manager and active company membership. Reuses only the actor's verified identity in the same workspace; otherwise returns paginated account choices and sends a private connection challenge. The account holder must confirm it while signed in before linking or test delivery. Revoked and conflicting links cannot be overwritten. The request ID prevents duplicate or ambiguous sends from being replayed.", + request: { params: z.object({ endpointId: z.string().uuid() }), body: jsonBody(slackTestMessageSchema) }, + responses: { 200: r.ok(slackTestMessageResultSchema), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable, 429: { description: "Wait before sending another test message" } }, +}); + for (const action of ["registration", "install", "resume"] as const) { registry.registerPath({ method: "post", path: `/api/chat-endpoints/{endpointId}/slack/${action}`, tags: ["chat-channels"], diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index 31338f7acc1..e1077280799 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -1,3 +1,6 @@ +import { slackTestMessageService } from "./connectors/slack/test-message.js"; +import type { SlackSetupOptions } from "./connectors/slack/setup/registration.js"; +import { isProvisionedSlackSetup } from "@paperclipai/shared"; import { createTeamsFileAuthority } from "./chat-channels/microsoft-teams/file-transfer-authority.js"; import { createPhotonEvents } from "./chat-channels/imessage-photon/events.js"; import { createTelegramPublicationDrafts } from "./chat-channels/telegram/publication-drafts.js"; @@ -1297,6 +1300,7 @@ export interface ChatChannelServiceOptions { claimId: string; }) => Promise; renderSlackAvatar?: (request: AgentAvatarRequest) => Promise; + slackManaged?: import("./connectors/slack/setup/registration.js").SlackSetupOptions["managed"]; storage?: StorageService; } @@ -1682,6 +1686,7 @@ function providerSetupState( slackApp: endpoint.setup.slackApp, slackSetupMethod: endpoint.setup.slackSetupMethod, slackAvatar: endpoint.setup.slackAvatar, + slackManagerError: endpoint.setup.slackManagerError, slackAccount: endpoint.setup.slackAccount, // Slack registers the slash command in the provider configuration. // Keep that identity immutable when the assigned agent is renamed; @@ -4414,7 +4419,8 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { actorUserId?: string | null, ) { if ((input.provider as string) === "agentmail") throw badRequest("Use the email inbox setup API for AgentMail"); - if (input.slackApp && input.provider !== "slack") throw unprocessable("Slack app details only apply to Slack connections"); + if ((input.slackApp || input.slackSetupMethod) && input.provider !== "slack") throw unprocessable("Slack app details only apply to Slack connections"); + if (input.slackSetupMethod === "managed" && slackRegistration.managerGrants.defaultMethod() !== "managed") throw conflict("Managed Slack setup is unavailable"); const agent = await db .select({ id: agents.id, name: agents.name, status: agents.status }) .from(agents) @@ -4526,7 +4532,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { step: "provider_setup", ...(input.provider === "slack" ? { slackApp: input.slackApp ?? defaultSlackAppConfiguration(agent.name), - command: (input.slackApp ?? defaultSlackAppConfiguration(agent.name)).command, slackSetupMethod: "automatic" } + command: (input.slackApp ?? defaultSlackAppConfiguration(agent.name)).command, slackSetupMethod: input.slackSetupMethod ?? slackRegistration.managerGrants.defaultMethod() } : {}), }, }); @@ -4572,13 +4578,14 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { } values.communicationInstructions = input.communicationInstructions; } + if (input.slackSetupMethod === "managed" && slackRegistration.managerGrants.defaultMethod() !== "managed") throw conflict("Managed Slack setup is unavailable"); if (input.slackSetupMethod && (existing.endpoint.provider !== "slack" || existing.endpoint.botExternalId || existing.endpoint.status !== "draft")) { throw conflict("Setup method can only change before connecting the app"); } if (input.slackApp || input.slackSetupMethod) { const [registration] = await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)); - if (registration && registration.status !== "failed") { - if (input.slackApp || input.slackSetupMethod === "automatic") throw conflict("Slack app details are locked after creation starts"); + if (registration && (registration.managerGrantId || registration.status !== "failed")) { + if (registration.managerGrantId || input.slackSetupMethod === "managed" || input.slackApp || input.slackSetupMethod === "automatic") throw conflict("Slack app details are locked after creation starts"); // Manual recovery fences pending authorization under the same credential lease. await slackRegistration.cleanup(endpointId, credentialLease); } @@ -6886,7 +6893,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { await syncSpekoVoiceTools(tx, endpoint, actorUserId, true, options.allowLocalVoiceBoard === true); } const observedSlackUrl = (current.setup as InternalSetupState).slackCallbackSurfaces?.events?.url; - const preserveSlackVerification = current.setup.slackSetupMethod === "automatic" + const preserveSlackVerification = isProvisionedSlackSetup(current.setup.slackSetupMethod) && (current.setup as InternalSetupState).slackVerificationSigningFingerprint === createHash("sha256").update(credentials.signingSecret ?? "").digest("hex") && Boolean(observedSlackUrl && slackCallbackMatchesPublicUrl(observedSlackUrl, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${endpoint.publicId}/slack`)); await notifyChatEndpointWork(tx); @@ -18637,7 +18644,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .where(and(eq(toolConnections.id, current.connectionId), eq(toolConnections.companyId, current.companyId))); if (!connection || connection.status === "archived") return new Response("ignored", { status: 200 }); let secret = (await resolveCredentialRefs(current, connection.refs.filter(ref => ref.configPath.replace(/^credentials\./, "") === "signingSecret"), tx)).signingSecret; - if (!secret && current.setup.slackSetupMethod === "automatic") { + if (!secret && isProvisionedSlackSetup(current.setup.slackSetupMethod)) { const [registration] = await tx.select().from(chatSlackRegistrations) .where(and(eq(chatSlackRegistrations.endpointId, current.id), eq(chatSlackRegistrations.companyId, current.companyId))); if (registration?.status !== "removed" && registration?.secretIds.signingSecret) { @@ -19252,7 +19259,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { // Recheck the signature under the current credential/runtime fence; // an SDK acknowledgement alone is not authentication evidence. let signingFingerprint: string | null = null; - if (!currentSetup.webhookVerifiedAt && currentSetup.slackSetupMethod === "automatic" + if (!currentSetup.webhookVerifiedAt && isProvisionedSlackSetup(currentSetup.slackSetupMethod) && callback.surface === "events" && !callback.isUrlVerification && slackCallbackMatchesPublicUrl(callback.url, `${getWebhookPublicBaseUrl()}/api/chat-webhooks/${current.publicId}/slack`)) { const payload = JSON.parse(eventBody) as { type?: string; team_id?: string; api_app_id?: string; event_id?: string; event?: { type?: string } }; @@ -26107,8 +26114,32 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return get(endpointId); } + const linkSlackAccount: SlackSetupOptions["linkInstaller"] = async (endpointId, user, actor, lease) => { + const record = await endpointRecord(endpointId); + if (!record || record.endpoint.provider !== "slack" || record.endpoint.providerAccountId !== user.team_id) throw conflict("Slack setup changed"); + const { principal } = await ensurePrincipal(record.endpoint, { + userId: String(user.id), userName: typeof user.name === "string" ? user.name : String(user.id), + fullName: typeof user.real_name === "string" ? user.real_name : String(user.id), isBot: false, isMe: false, + }); + await db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${record.endpoint.companyId}:${principal.id}`}, 0))`); + const membership = (await tx.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, record.endpoint.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, actor.userId))).for("update"))[0]; + if (membership?.status !== "active" || membership.membershipRole === "viewer") throw forbidden("The signed-in Paperclip account cannot use this connection"); + const links = await tx.select().from(chatIdentityLinks).where(and(eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.principalId, principal.id))).for("update"); + if (links.some(link => link.status === "linked" && link.paperclipUserId !== actor.userId) || links.some(link => link.endpointId === endpointId && link.status === "revoked")) throw conflict("This Slack account has an existing link", { code: "chat_identity_link_conflict" }); + if (links.some(link => link.endpointId === endpointId && link.status === "linked")) return; + await tx.insert(chatIdentityLinks).values({ companyId: record.endpoint.companyId, endpointId, principalId: principal.id, + paperclipUserId: actor.userId, status: "linked", confirmedAt: new Date() }) + .onConflictDoUpdate({ target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId], set: { + paperclipUserId: actor.userId, status: "linked", confirmationTokenHash: null, expiresAt: null, confirmedAt: new Date(), revokedAt: null, updatedAt: new Date(), + } }); + await lease.assertOwned(tx); + }); + }; + const slackRegistration = slackChatRegistrationService(db, { - publicOrigin: getPublicBaseUrl, webhookOrigin: getWebhookPublicBaseUrl, fetch: fetchImpl, renderAvatar: options.renderSlackAvatar, + publicOrigin: getPublicBaseUrl, webhookOrigin: getWebhookPublicBaseUrl, fetch: fetchImpl, renderAvatar: options.renderSlackAvatar, managed: options.slackManaged, withLock: async (endpointId, work) => { const record = await endpointRecord(endpointId); if (!record) throw notFound("Chat endpoint not found"); @@ -26136,29 +26167,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .where(and(eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.endpointId, endpointId), eq(chatIdentityLinks.status, "linked"), eq(chatIdentityLinks.paperclipUserId, account.paperclipUserId), eq(chatExternalPrincipals.externalId, account.externalUserId), eq(chatExternalPrincipals.providerAccountId, record.endpoint.providerAccountId!))); return Boolean(link); }, - linkInstaller: async (endpointId, user, actor, lease) => { - const record = await endpointRecord(endpointId); - if (!record || record.endpoint.provider !== "slack" || record.endpoint.providerAccountId !== user.team_id) throw conflict("Slack setup changed"); - const { principal } = await ensurePrincipal(record.endpoint, { - userId: String(user.id), userName: typeof user.name === "string" ? user.name : String(user.id), - fullName: typeof user.real_name === "string" ? user.real_name : String(user.id), isBot: false, isMe: false, - }); - await db.transaction(async tx => { - await lease.assertOwned(tx); - await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${record.endpoint.companyId}:${principal.id}`}, 0))`); - const membership = (await tx.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, record.endpoint.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, actor.userId))).for("update"))[0]; - if (membership?.status !== "active" || membership.membershipRole === "viewer") throw forbidden("The signed-in Paperclip account cannot use this connection"); - const links = await tx.select().from(chatIdentityLinks).where(and(eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.principalId, principal.id))).for("update"); - if (links.some(link => link.status === "linked" && link.paperclipUserId !== actor.userId) || links.some(link => link.endpointId === endpointId && link.status === "revoked")) throw conflict("This Slack account has an existing link", { code: "chat_identity_link_conflict" }); - if (links.some(link => link.endpointId === endpointId && link.status === "linked")) return; - await tx.insert(chatIdentityLinks).values({ companyId: record.endpoint.companyId, endpointId, principalId: principal.id, - paperclipUserId: actor.userId, status: "linked", confirmedAt: new Date() }) - .onConflictDoUpdate({ target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId], set: { - paperclipUserId: actor.userId, status: "linked", confirmationTokenHash: null, expiresAt: null, confirmedAt: new Date(), revokedAt: null, updatedAt: new Date(), - } }); - await lease.assertOwned(tx); - }); - }, + linkInstaller: linkSlackAccount, configure: async (endpointId, credentials, actor, lease) => { const record = await endpointRecord(endpointId); if (!record) throw notFound("Chat endpoint not found"); @@ -26168,6 +26177,28 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }, }); + const sendSlackTestMessage = slackTestMessageService(db, { + fetch: fetchImpl, + withLock: async (endpointId, work) => { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + return withCredentialMutationLease(record.endpoint, work); + }, + runtimeBotToken: async endpointId => { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + return (await resolveCredentials(record.endpoint)).botToken; + }, + linkInstaller: linkSlackAccount, + ensureAccount: async (endpointId, user) => { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + const { principal } = await ensurePrincipal(record.endpoint, { userId: String(user.id), userName: String(user.name ?? user.id), fullName: String(user.real_name ?? user.id), isBot: false, isMe: false }); + return principal.id; + }, + createLinkIntent, + }); + const githubRegistration = githubChatRegistrationService(db, { publicOrigin: () => getPublicBaseUrl(), webhookOrigin: () => getWebhookPublicBaseUrl(), fetch: fetchImpl, storeApp: async (endpointId, userId, app) => { await storeGitHubApp(endpointId, userId, { appId: app.appId, privateKey: app.privateKey, webhookSecret: app.webhookSecret, ...(app.clientId ? { clientId: app.clientId } : {}), ...(app.clientSecret ? { clientSecret: app.clientSecret } : {}) }, "manifest"); }, @@ -26345,6 +26376,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { voice, authorizeFastResponse, slackRegistration, + sendSlackTestMessage, saveGitHubSetupProgress: async (endpointId: string, stage: NonNullable["stage"]) => { const record = await endpointRecord(endpointId); if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found"); diff --git a/server/src/services/chat-slack-registration-cleanup.ts b/server/src/services/chat-slack-registration-cleanup.ts index 22a2b758598..97292a773a5 100644 --- a/server/src/services/chat-slack-registration-cleanup.ts +++ b/server/src/services/chat-slack-registration-cleanup.ts @@ -6,8 +6,11 @@ import type { CredentialMutationLeaseGuard } from "./chat-credential-mutation-le // Keep generic connection removal independent of the registration service's // authorization graph; access routes already load tool-access through the barrel. export async function removeSlackRegistration(db: Db, endpointId: string, lease: CredentialMutationLeaseGuard) { + await lease.assertOwned(); const vault = secretService(db); const [row] = await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)); + const [endpoint] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)); + if (endpoint) await db.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, endpoint.connectionId), like(toolOauthStates.state, "slack-manager.%"))); if (!row) return; await lease.assertOwned(); await db.update(chatSlackRegistrations).set({ status: "removed", updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); @@ -16,4 +19,4 @@ export async function removeSlackRegistration(db: Db, endpointId: string, lease: for (const id of Object.values(row.secretIds)) { await lease.assertOwned(); await vault.remove(id); } await lease.assertOwned(); await db.update(chatSlackRegistrations).set({ secretIds: {} }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - } +} diff --git a/server/src/services/chat-slack-registration.ts b/server/src/services/chat-slack-registration.ts index 905c7ab0346..ea187cfff2f 100644 --- a/server/src/services/chat-slack-registration.ts +++ b/server/src/services/chat-slack-registration.ts @@ -1,531 +1,3 @@ -import { notifyChatVerificationWork } from "./chat-work-notifications.js"; -import { createAgentAvatarPool } from "./agent-avatar-pool.js"; -import type { AgentAvatarRequest } from "./agent-avatars.js"; -import { removeSlackRegistration } from "./chat-slack-registration-cleanup.js"; -import { createHash, randomBytes } from "node:crypto"; -import { and, eq, gt, inArray, like, sql } from "drizzle-orm"; -import { agents, chatEndpoints, chatSlackRegistrations, companies, toolConnections, toolOauthStates, type Db } from "@paperclipai/db"; -import { resolveAgentAppearance, buildSlackAppManifest, slackAppConfigurationSchema, slackRegistrationErrorMessage, type SlackRegistrationInput, type SlackRegistrationState, type SlackAvatarState, type SlackAccountState } from "@paperclipai/shared"; -import { badRequest, conflict, forbidden, notFound, unprocessable } from "../errors.js"; -import { accessService } from "./access.js"; -import { logActivity } from "./activity-log.js"; -import { logger } from "../middleware/logger.js"; -import { instanceSettingsService } from "./instance-settings.js"; -import { secretService } from "./secrets.js"; -import { writeConnectionCredential } from "./connection-credentials.js"; -import type { CredentialMutationLeaseGuard } from "./chat-credential-mutation-lease.js"; - -export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean } -type Registration = typeof chatSlackRegistrations.$inferSelect; -const prefix = "slack-install."; -const timeoutMs = 20_000; -const object = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; -const providerFailure = (code: string) => unprocessable(slackRegistrationErrorMessage(code), { code }); -const knownProviderErrors: Record = { - invalid_auth: "slack_configuration_token_invalid", token_expired: "slack_configuration_token_invalid", - token_revoked: "slack_configuration_token_invalid", not_authed: "slack_configuration_token_invalid", - invalid_manifest: "slack_manifest_invalid", invalid_app: "slack_manifest_invalid", - ratelimited: "slack_setup_rate_limited", no_permission: "slack_setup_permission_denied", - missing_scope: "slack_setup_permission_denied", not_allowed_token_type: "slack_configuration_token_invalid", -}; - -export function slackRegistrationProjection(row: Registration): SlackRegistrationState { - return { - status: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "uncertain" : row.status, - appId: row.appId, - managementUrl: row.appId ? `https://api.slack.com/apps/${encodeURIComponent(row.appId)}` : "https://api.slack.com/apps", - errorCode: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "slack_creation_uncertain" : row.errorCode, - }; -} - -export function slackChatRegistrationService(db: Db, options: { - publicOrigin: () => string | null; - webhookOrigin: () => string | null; - fetch?: typeof fetch; - renderAvatar?: (request: AgentAvatarRequest) => Promise; - withLock: (endpointId: string, work: (lease: CredentialMutationLeaseGuard) => Promise) => Promise; - runtimeSigningSecret: (endpointId: string) => Promise; - runtimeBotToken: (endpointId: string) => Promise; - canWelcome: (endpointId: string, account: SlackAccountState) => Promise; - linkInstaller: (endpointId: string, user: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; - configure: (endpointId: string, credentials: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; -}) { - const fetchImpl = options.fetch ?? fetch; - const vault = secretService(db); - let avatarPool: ReturnType | undefined; - function origin(value: string | null) { - if (!value) throw badRequest("Configure a public HTTPS URL before creating a Slack app"); - const url = new URL(value); - if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) - throw badRequest("Slack setup requires a configured public HTTPS origin"); - return url.origin; - } - const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/oauth/callback`; - async function registration(endpointId: string) { - return (await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)))[0]; - } - async function endpoint(endpointId: string, actor: SlackSetupActor) { - if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); - const [row] = await db.select({ endpoint: chatEndpoints, connection: toolConnections, agentName: agents.name, agentAppearance: agents.appearance }) - .from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId))) - .innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId))) - .where(and(eq(chatEndpoints.id, endpointId), eq(chatEndpoints.provider, "slack"))); - if (!row || row.endpoint.status === "archived" || row.connection.status === "archived") throw notFound("Slack connection not found"); - if (!actor.bypassPermissionCheck && !await accessService(db).hasPermission(row.endpoint.companyId, "user", actor.userId, "tools:manage_connections")) - throw forbidden("Missing permission: tools:manage_connections"); - return row; - } - function assertOrigins(row: Registration, publicId: string) { - if (row.callbackUri !== callbackUri()) throw conflict("The Paperclip URL changed. Restore its configured address or update the Slack app and use manual setup."); - const expected = `${origin(options.webhookOrigin())}/api/chat-webhooks/${publicId}/slack`; - if (object(object(row.manifest.settings).event_subscriptions).request_url !== expected) - throw conflict("The Slack webhook address changed. Restore its configured address or update the Slack app and use manual setup."); - } - async function audit(row: Registration, actor: SlackSetupActor, action: string, code?: string) { - await logActivity(db, { companyId: row.companyId, actorType: "user", actorId: actor.userId, - action: `chat_slack.${action}`, entityType: "chat_endpoint", entityId: row.endpointId, - details: { endpointId: row.endpointId, appId: row.appId, ...(code ? { code } : {}) } }); - } - async function api(method: string, fields: Record | FormData, bearer?: string) { - const response = await fetchImpl(`https://slack.com/api/${method}`, { - method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs), - headers: { ...(fields instanceof FormData ? {} : { "content-type": "application/x-www-form-urlencoded" }), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }, - body: fields instanceof FormData ? fields : new URLSearchParams(fields), - }); - const result = object(await response.json()); - if (!response.ok || result.ok !== true) { - const code = knownProviderErrors[String(result.error)] ?? (response.status === 429 ? "slack_setup_rate_limited" : "slack_provider_failure"); - throw providerFailure(code); - } - return result; - } - async function saveSecrets(row: Registration, values: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, - patch: Partial = {}, account?: SlackAccountState) { - await db.transaction(async tx => { - await lease.assertOwned(tx); - const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; - if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); - const ids = { ...current.secretIds }; - for (const [key, value] of Object.entries(values)) { - const result = await writeConnectionCredential(tx, { companyId: row.companyId, connectionName: "Slack app registration", - configPath: `slack_registration.${key}`, label: key, value, actor: { userId: actor.userId }, - ...(ids[key] ? { existingRef: { secretId: ids[key], configPath: `slack_registration.${key}` } } : {}) }); - ids[key] = result.secret.id; - } - await notifyChatVerificationWork(tx); - await tx.update(chatSlackRegistrations).set({ ...patch, secretIds: ids, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, row.endpointId)); - if (account) { - const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; - if (!saved || saved.status === "archived") throw conflict("Slack setup changed"); - // Reauthorization never changes an established personal account binding. - if (saved.setup.slackAccount?.status !== "linked") await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); - } - await lease.assertOwned(tx); - }); - } - async function readSecret(row: Registration, key: string) { - if (!row.secretIds[key]) throw conflict("Slack setup credentials are unavailable. Resume the saved connection."); - return vault.resolveSecretValue(row.companyId, row.secretIds[key], "latest", { - accessContext: { consumerType: "system", consumerId: `slack-registration:${row.endpointId}`, configPath: `slack_registration.${key}`, actorType: "system" }, - }); - } - async function setFailure(row: Registration, status: Registration["status"], code: string, lease: CredentialMutationLeaseGuard) { - await lease.assertOwned(); - await db.update(chatSlackRegistrations).set({ status, errorCode: code, updatedAt: new Date() }) - .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); - } - async function saveAvatar(row: Registration, avatar: SlackAvatarState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - await endpoint(row.endpointId, actor); - await db.transaction(async tx => { - await lease.assertOwned(tx); - const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; - if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); - const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; - if (!saved || saved.status === "archived" || saved.setup.slackSetupMethod !== "automatic") throw conflict("Slack setup changed. Resume the saved connection."); - await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAvatar: avatar }, updatedAt: new Date() }) - .where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))); - await lease.assertOwned(tx); - }); - } - async function create(endpointId: string, actor: SlackSetupActor, input: SlackRegistrationInput) { - return options.withLock(endpointId, async lease => { - const current = await endpoint(endpointId, actor); - const previous = await registration(endpointId); - if (previous?.status === "removed") throw conflict("This registration was removed. Continue the selected manual setup."); - if (previous?.appId) { - if (previous.errorCode === "slack_manifest_update_pending") { - await configureManifest(previous, actor, input.credentials.configurationToken, lease); - } - return; - } - if (previous?.requestId === input.requestId) return; - if (previous && ["creating", "uncertain"].includes(previous.status) && !input.confirmedNoAppCreated) { - await setFailure(previous, "uncertain", "slack_creation_uncertain", lease); - return; - } - if (current.endpoint.status !== "draft" || current.endpoint.botExternalId || current.endpoint.setup.slackSetupMethod !== "automatic") - throw conflict("Automatic creation is only available for a new Slack connection"); - const app = slackAppConfigurationSchema.parse(current.endpoint.setup.slackApp); - const redirect = callbackUri(); - const manifest = buildSlackAppManifest({ app, agentName: current.agentName, - webhookUrl: `${origin(options.webhookOrigin())}/api/chat-webhooks/${current.endpoint.publicId}/slack`, redirectUri: redirect }); - // Slack cannot authenticate a challenge until its creation response gives us - // the signing secret. Add event subscriptions only after that secret is durable. - const { event_subscriptions: _events, ...creationSettings } = manifest.settings; - const creationManifest = { ...manifest, settings: creationSettings }; - try { await api("apps.manifest.validate", { manifest: JSON.stringify(manifest) }, input.credentials.configurationToken); } - catch (error) { - if (error instanceof Error && "status" in error) throw error; - throw providerFailure("slack_provider_failure"); - } - // No provider creation has happened until this intent is durable. - await endpoint(endpointId, actor); - await lease.assertOwned(); - const values = { companyId: current.endpoint.companyId, requestId: input.requestId, status: "creating" as const, - manifest, manifestHash: createHash("sha256").update(JSON.stringify(manifest)).digest("hex"), callbackUri: redirect, - createdByUserId: actor.userId, errorCode: null, updatedAt: new Date() }; - const [row] = await db.insert(chatSlackRegistrations).values({ endpointId, ...values }) - .onConflictDoUpdate({ target: chatSlackRegistrations.endpointId, set: values }).returning(); - await audit(row, actor, "creation_started"); - let createdAppId: string; - try { - const result = await api("apps.manifest.create", { manifest: JSON.stringify(creationManifest) }, input.credentials.configurationToken); - const credentials = object(result.credentials); - if (typeof result.app_id !== "string" || !/^A[A-Z0-9]+$/.test(result.app_id) - || typeof credentials.client_id !== "string" || !/^\d+\.\d+$/.test(credentials.client_id) - || typeof credentials.client_secret !== "string" || !credentials.client_secret - || typeof credentials.signing_secret !== "string" || !credentials.signing_secret) - throw new Error("Incomplete Slack creation response"); - await endpoint(endpointId, actor); - await saveSecrets(row, { signingSecret: credentials.signing_secret, clientSecret: credentials.client_secret }, actor, lease, - { appId: result.app_id, clientId: credentials.client_id, status: "install", errorCode: "slack_manifest_update_pending" }); - createdAppId = result.app_id; - } catch (error) { - // Only documented rejection responses prove that creation did not happen. - const code = object(object(error).details).code; - const rejected = typeof code === "string" && Object.values(knownProviderErrors).includes(code); - await setFailure(row, rejected ? "failed" : "uncertain", rejected ? code : "slack_creation_uncertain", lease); - await audit(row, actor, "creation_failed", rejected ? code : "slack_creation_uncertain"); - return; - } - // The app and credentials are durable before optional icon provisioning. - // A retry/restart always reuses this app, even if icon upload is interrupted. - const created = { ...row, appId: createdAppId }; - if (!await configureManifest(created, actor, input.credentials.configurationToken, lease)) return; - await saveAvatar(created, { status: "pending" }, actor, lease); - let avatar: SlackAvatarState; - try { - await endpoint(endpointId, actor); - await lease.assertOwned(); - // Cloud ingress requires a tenant session. Send the preset PNG bytes so - // Slack does not need to fetch an authenticated Paperclip URL. - const render = options.renderAvatar ?? (avatarPool ??= createAgentAvatarPool()).render; - const appearance = { ...resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId) }; - delete appearance.customAvatarAssetId; // The preset renderer cannot read private uploaded assets. - const png = await render({ appearance, - size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" }); - await endpoint(endpointId, actor); - await lease.assertOwned(); - const upload = new FormData(); - upload.set("app_id", createdAppId); - upload.set("file", new Blob([new Uint8Array(png)], { type: "image/png" }), "agent-avatar.png"); - await api("apps.icon.set", upload, input.credentials.configurationToken); - avatar = { status: "uploaded", uploadedAt: new Date().toISOString() }; - } catch { - // Provider echoes (including tokens) never become saved state or activity. - avatar = { status: "failed", errorCode: "slack_avatar_upload_failed" }; - } - await saveAvatar(created, avatar, actor, lease); - try { await audit(created, actor, avatar.status === "uploaded" ? "avatar_uploaded" : "avatar_upload_failed", avatar.status === "failed" ? avatar.errorCode : undefined); } - catch { logger.warn({ endpointId, appId: createdAppId }, "Slack avatar activity could not be recorded"); } - // An audit outage cannot change the saved creation outcome. - try { await audit({ ...row, appId: createdAppId }, actor, "app_created"); } - catch { logger.warn({ endpointId, appId: createdAppId }, "Slack app creation activity could not be recorded"); } - }); - } - async function configureManifest(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard) { - const current = await endpoint(row.endpointId, actor); - if (current.endpoint.status !== "draft" || current.endpoint.setup.slackSetupMethod !== "automatic") - throw conflict("Resume the saved Slack connection before configuring its app"); - assertOrigins(row, current.endpoint.publicId); - await lease.assertOwned(); - try { - await api("apps.manifest.update", { app_id: row.appId!, manifest: JSON.stringify(row.manifest) }, configurationToken); - } catch { - // The pending marker was saved with the app secrets before dispatch. Retrying - // the same manifest is safe even when Slack's response was lost. - await audit(row, actor, "manifest_update_failed", "slack_manifest_update_pending"); - return false; - } - await endpoint(row.endpointId, actor); - await lease.assertOwned(); - await db.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }) - .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); - await audit(row, actor, "manifest_updated"); - return true; - } - async function install(endpointId: string, actor: SlackSetupActor) { - return options.withLock(endpointId, async lease => { - const current = await endpoint(endpointId, actor); - const row = await registration(endpointId); - if (!row?.appId || !row.clientId || row.status === "removed") throw conflict("Create this Slack app before installing it"); - if (row.errorCode === "slack_manifest_update_pending") throw conflict("Finish configuring the saved Slack app before installing it"); - if (current.endpoint.setup.slackSetupMethod !== "automatic" || current.endpoint.status === "paused") throw conflict("Resume this connection before installing it"); - assertOrigins(row, current.endpoint.publicId); - const state = `${prefix}${randomBytes(32).toString("base64url")}`; - const expiresAt = new Date(Date.now() + 10 * 60_000); - const scopes = object(object(row.manifest.oauth_config).scopes).bot as string[]; - await db.transaction(async tx => { - await lease.assertOwned(tx); - await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, current.connection.id), like(toolOauthStates.state, `${prefix}%`))); - await tx.insert(toolOauthStates).values({ state, companyId: row.companyId, connectionId: current.connection.id, - codeVerifier: JSON.stringify({ endpointId, requestId: row.requestId, appId: row.appId, callbackUri: row.callbackUri, - endpointStatus: current.endpoint.status, runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }), - createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId, - requestedScopes: scopes, expiresAt }); - await tx.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - }); - const url = new URL("https://slack.com/oauth/v2/authorize"); - url.search = new URLSearchParams({ client_id: row.clientId, scope: scopes.join(","), state, redirect_uri: row.callbackUri, - ...(row.workspaceId ? { team: row.workspaceId } : {}) }).toString(); - await audit(row, actor, "installation_started"); - return { authorizationUrl: url.toString(), expiresAt: expiresAt.toISOString() }; - }); - } - async function expiredReturn(state: string, actor: SlackSetupActor) { - if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) return null; - const [attempt] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state)); - if (!attempt || attempt.expiresAt.getTime() > Date.now() || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) return null; - const binding = object(JSON.parse(attempt.codeVerifier)); - const current = await endpoint(String(binding.endpointId), actor); - return options.withLock(current.endpoint.id, async lease => { - const latest = await endpoint(current.endpoint.id, actor); - const row = await registration(current.endpoint.id); - if (!row || row.status === "removed" || row.requestId !== binding.requestId || row.companyId !== attempt.companyId || latest.connection.id !== attempt.connectionId - || latest.endpoint.setup.slackSetupMethod !== "automatic" || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri) return null; - assertOrigins(row, latest.endpoint.publicId); - await lease.assertOwned(); - await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); - await setFailure(row, row.status, "slack_install_expired", lease); - await audit(row, actor, "installation_failed", "slack_install_expired"); - return returnPath(row.endpointId); - }); - } - async function pending(state: string, actor: SlackSetupActor) { - if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) throw badRequest("Invalid Slack authorization state"); - const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))); - if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) - throw forbidden("Slack authorization expired or belongs to another session. Resume setup and install again."); - const binding = object(JSON.parse(attempt.codeVerifier)); - const current = await endpoint(String(binding.endpointId), actor); - const row = await registration(current.endpoint.id); - if (!row || row.status === "removed" || current.endpoint.setup.slackSetupMethod !== "automatic" - || current.endpoint.status === "paused" - || current.endpoint.status === "revoked" && binding.endpointStatus !== "revoked" - || binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0) - || row.requestId !== binding.requestId || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri - || row.callbackUri !== callbackUri() || attempt.companyId !== row.companyId || attempt.connectionId !== current.connection.id) - throw conflict("Slack setup changed. Resume the saved connection and install again."); - assertOrigins(row, current.endpoint.publicId); - return { attempt, current, row }; - } - async function saveAccount(row: Registration, account: SlackAccountState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - await endpoint(row.endpointId, actor); - await db.transaction(async tx => { - await lease.assertOwned(tx); - const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; - const registrationNow = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)))[0]; - if (!saved || saved.status === "archived" || registrationNow?.status === "removed" || registrationNow?.requestId !== row.requestId) throw conflict("Slack setup changed"); - await notifyChatVerificationWork(tx); - await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); - await lease.assertOwned(tx); - }); - } - async function linkAccount(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - const current = await endpoint(row.endpointId, actor); - const account = current.endpoint.setup.slackAccount; - if (!account) throw providerFailure("slack_install_account_missing"); - if (account.status === "linked") return; - const token = row.secretIds.botToken ? await readSecret(row, "botToken") : await options.runtimeBotToken(row.endpointId); - const user = object((await api("users.info", { user: account.externalUserId }, token)).user); - if (user.id !== account.externalUserId || user.team_id !== row.workspaceId || user.is_bot !== false || user.is_app_user === true || user.deleted !== false) throw providerFailure("slack_install_account_missing"); - await endpoint(row.endpointId, actor); - await options.linkInstaller(row.endpointId, user, { ...actor, userId: account.paperclipUserId }, lease); - await saveAccount(row, { ...account, status: "linked" }, actor, lease); - try { await audit(row, actor, "account_linked"); } - catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack account activity could not be recorded"); } - } - async function sendSetupMessage(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, kind: "welcome" | "verification") { - const current = await endpoint(row.endpointId, actor); - const account = current.endpoint.setup.slackAccount; - if (!account || account.status !== "linked") return; - if (kind === "verification" && (!current.endpoint.setup.webhookVerifiedAt || ["pending", "sending"].includes(account.welcomeStatus))) return; - const field = kind === "welcome" ? "welcomeStatus" : "verificationStatus"; - const status = account[field] ?? "pending"; - if (status === "sending") { - await saveAccount(row, { ...account, [field]: "uncertain" }, actor, lease); - return; // Never replay an ambiguous dispatch after a process restart. - } - if (status !== "pending") return; - await saveAccount(row, { ...account, [field]: "sending" }, actor, lease); - let result: SlackAccountState; - try { - await endpoint(row.endpointId, actor); - await lease.assertOwned(); - if (!await options.canWelcome(row.endpointId, account)) throw forbidden("Slack account access changed"); - const sent = await api("chat.postMessage", { channel: account.dmChannelId ?? account.externalUserId, - text: kind === "verification" ? "Your Slack connection is working! Now ask me a question about your Paperclip instance." - : `Hi, I’m ${current.agentName}. Your Slack account is connected to Paperclip.`, - unfurl_links: "false", unfurl_media: "false" }, await options.runtimeBotToken(row.endpointId)); - if (typeof sent.channel !== "string" || !/^D[A-Z0-9]+$/.test(sent.channel)) throw new Error("Incomplete message response"); - result = { ...account, [field]: "sent", dmChannelId: sent.channel }; - } catch (error) { - result = { ...account, [field]: error instanceof Error && "status" in error ? "failed" : "uncertain" }; - } - await saveAccount(row, result, actor, lease); - try { await audit(row, actor, `${kind}_${result[field] === "sent" ? "sent" : "failed"}`); } - catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack setup message activity could not be recorded"); } - } - async function welcome(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - await sendSetupMessage(row, actor, lease, "welcome"); - await sendSetupMessage(row, actor, lease, "verification"); - } - async function notifyVerified(endpointId: string) { - return options.withLock(endpointId, async lease => { - const row = await registration(endpointId); - if (!row || row.status !== "configured") return; - const [saved] = await db.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)); - const account = saved?.setup.slackAccount; - if (!account || !["verifying", "active"].includes(saved.status)) return; - await sendSetupMessage(row, { userId: account.paperclipUserId, sessionId: null, bypassPermissionCheck: false }, lease, "verification"); - }); - } - function pendingVerificationMessage() { - // A pending verification cannot progress until linking, welcome, and an - // authenticated callback finish. Their commits wake this lane. - return and(eq(chatSlackRegistrations.status, "configured"), - inArray(chatEndpoints.status, ["verifying", "active"]), - sql`${chatEndpoints.setup}->'slackAccount'->>'status' = 'linked'`, - sql`${chatEndpoints.setup}->>'webhookVerifiedAt' is not null`, - sql`coalesce(${chatEndpoints.setup}->'slackAccount'->>'welcomeStatus', '') not in ('pending', 'sending')`, - sql`${chatEndpoints.setup}->'slackAccount'->>'verificationStatus' in ('pending', 'sending')`); - } - function pendingVerificationMessages(limit: number) { - return db.select({ endpointId: chatEndpoints.id }).from(chatEndpoints) - .innerJoin(chatSlackRegistrations, and(eq(chatSlackRegistrations.endpointId, chatEndpoints.id), eq(chatSlackRegistrations.companyId, chatEndpoints.companyId))) - .where(pendingVerificationMessage()).limit(limit); - } - async function nextVerificationMessageAt(): Promise { - return (await pendingVerificationMessages(1)).length ? Date.now() : null; - } - async function processPendingVerificationMessages(limit = 25) { - for (const row of await pendingVerificationMessages(limit)) { - try { await notifyVerified(row.endpointId); } - catch { logger.warn({ endpointId: row.endpointId }, "Slack verification message could not be processed"); } - } - } - async function resumeLocked(endpointId: string, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { - const current = await endpoint(endpointId, actor); - const row = await registration(endpointId); - if (!row || row.status === "removed") throw conflict("Slack registration is unavailable"); - if (row.status === "configured") { await cleanupStaged(endpointId, lease); await welcome(row, actor, lease); return; } - if (row.status !== "credentials_saved") throw conflict("Install this Slack app before connecting it"); - try { - assertOrigins(row, current.endpoint.publicId); - const botToken = await readSecret(row, "botToken"); - const auth = await api("auth.test", {}, botToken); - if (auth.team_id !== row.workspaceId || auth.user_id !== row.botUserId) throw providerFailure("slack_install_identity_mismatch"); - await options.configure(endpointId, { botToken, signingSecret: row.secretIds.signingSecret - ? await readSecret(row, "signingSecret") : await options.runtimeSigningSecret(endpointId) }, actor, { - assertOwned: async database => { - await lease.assertOwned(database); - await endpoint(endpointId, actor); - assertOrigins(row, current.endpoint.publicId); - }, - }); - await linkAccount(row, actor, lease); - } catch (error) { - const code = object(object(error).details).code; - const safeCode = code === "slack_install_account_missing" ? code : code === "chat_identity_link_conflict" || code === "chat_identity_already_linked" ? "slack_install_account_conflict" : code === "slack_install_identity_mismatch" || code === "chat_bot_identity_changed" - ? "slack_install_identity_mismatch" : code === "chat_provider_permissions_missing" ? "slack_install_scopes_missing" - : code === "chat_bot_identity_in_use" ? "slack_bot_already_connected" : "slack_configuration_incomplete"; - await setFailure(row, "credentials_saved", safeCode, lease); - await audit(row, actor, "configuration_failed", safeCode); - return; - } - await lease.assertOwned(); - await db.transaction(async tx => { - await notifyChatVerificationWork(tx); - await tx.update(chatSlackRegistrations).set({ status: "configured", errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - }); - // Runtime now owns a separate vaulted copy. Keep the registration IDs until - // deletion succeeds so cleanup itself is retryable. - await cleanupStaged(endpointId, lease); - try { await audit(row, actor, "installation_completed"); } - catch { logger.warn({ endpointId, appId: row.appId }, "Slack installation activity could not be recorded"); } - await welcome(row, actor, lease); - } - async function cleanupStaged(endpointId: string, lease: CredentialMutationLeaseGuard) { - const row = await registration(endpointId); - if (!row || row.status !== "configured") return; - const ids = { ...row.secretIds }; - for (const key of ["botToken", "signingSecret"]) { - if (ids[key]) { await lease.assertOwned(); await vault.remove(ids[key]); delete ids[key]; } - } - await lease.assertOwned(); - await db.update(chatSlackRegistrations).set({ secretIds: ids }).where(eq(chatSlackRegistrations.endpointId, endpointId)); - } - async function complete(state: string, code: string | null, error: string | null, actor: SlackSetupActor) { - const before = await pending(state, actor); - return options.withLock(before.row.endpointId, async lease => { - const { row, attempt, current } = await pending(state, actor); - const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning(); - if (!claimed) throw conflict("This Slack authorization was already used"); - if (error) { - const failureCode = error === "access_denied" ? "slack_install_declined" : "slack_install_failed"; - await setFailure(row, row.status, failureCode, lease); - await audit(row, actor, "installation_failed", failureCode); - return row.endpointId; - } - try { - if (!code || code.length > 4096) throw providerFailure("slack_install_failed"); - const result = await api("oauth.v2.access", { code, client_id: row.clientId!, client_secret: await readSecret(row, "clientSecret"), redirect_uri: row.callbackUri }); - const teamId = object(result.team).id; - const installerId = object(result.authed_user).id; - if (result.app_id !== row.appId || result.token_type !== "bot" || typeof result.access_token !== "string" || !result.access_token.startsWith("xoxb-") - || typeof teamId !== "string" || !/^T[A-Z0-9]+$/.test(teamId) || typeof result.bot_user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(result.bot_user_id) - || row.workspaceId && row.workspaceId !== teamId || row.botUserId && row.botUserId !== result.bot_user_id) - throw providerFailure("slack_install_identity_mismatch"); - const granted = new Set(String(result.scope ?? "").split(",")); - if ((attempt.requestedScopes ?? []).some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing"); - if (current.endpoint.providerAccountId && current.endpoint.providerAccountId !== teamId - || current.endpoint.botExternalId && current.endpoint.botExternalId !== result.bot_user_id) - throw providerFailure("slack_install_identity_mismatch"); - if (typeof installerId !== "string" || !/^[UW][A-Z0-9]+$/.test(installerId) || installerId === result.bot_user_id) throw providerFailure("slack_install_account_missing"); - await endpoint(row.endpointId, actor); - // Reauthorization reuses the runtime signing secret after staging was cleaned. - await saveSecrets(row, { botToken: result.access_token }, actor, lease, - { status: "credentials_saved", workspaceId: teamId, botUserId: result.bot_user_id, errorCode: null }, - { externalUserId: installerId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" }); - } catch (failure) { - const code = object(object(failure).details).code; - const safeCode = typeof code === "string" && code.startsWith("slack_install_") ? code : "slack_install_failed"; - await setFailure(row, row.status, safeCode, lease); - await audit(row, actor, "installation_failed", safeCode); - return row.endpointId; - } - await resumeLocked(row.endpointId, actor, lease); - return row.endpointId; - }); - } - const cleanup = (endpointId: string, lease: CredentialMutationLeaseGuard) => removeSlackRegistration(db, endpointId, lease); - async function returnPath(endpointId: string) { - const [row] = await db.select({ prefix: companies.issuePrefix }).from(chatEndpoints) - .innerJoin(companies, eq(companies.id, chatEndpoints.companyId)).where(eq(chatEndpoints.id, endpointId)); - if (!row) throw notFound("Slack connection not found"); - return `/${encodeURIComponent(row.prefix)}/apps/chat/connect?provider=slack&resume=${encodeURIComponent(endpointId)}`; - } - return { create, install, pending, expiredReturn, complete, cleanup, returnPath, registration, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt, - close: async () => { await avatarPool?.close(); }, - resume: (endpointId: string, actor: SlackSetupActor) => options.withLock(endpointId, lease => resumeLocked(endpointId, actor, lease)) }; -} +// Compatibility boundary: both provisioning paths converge on the same Slack runtime. +export { slackChatRegistrationService } from "./connectors/slack/setup/service.js"; +export { slackRegistrationProjection, type SlackSetupActor } from "./connectors/slack/setup/registration.js"; diff --git a/server/src/services/connectors/slack/setup/completion.ts b/server/src/services/connectors/slack/setup/completion.ts new file mode 100644 index 00000000000..afefa935789 --- /dev/null +++ b/server/src/services/connectors/slack/setup/completion.ts @@ -0,0 +1,164 @@ +import { notifyChatVerificationWork } from "../../../chat-work-notifications.js"; +import { and, eq, inArray, sql } from "drizzle-orm"; +import { chatEndpoints, chatSlackRegistrations } from "@paperclipai/db"; +import { SLACK_CHAT_BOT_SCOPES, SLACK_BOT_TOOL_SCOPES, type SlackAccountState } from "@paperclipai/shared"; +import { conflict, forbidden } from "../../../../errors.js"; +import { logger } from "../../../../middleware/logger.js"; +import { secretService } from "../../../secrets.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; +import { object, providerFailure, type Registration, type SlackSetupActor, type SlackRegistrationStore } from "./registration.js"; +export function createSlackCompletion(store: SlackRegistrationStore) { + const { db, options, endpoint, registration, assertOrigins, api, readSecret, audit, setFailure } = store; + const vault = secretService(db); + async function saveAccount(row: Registration, account: SlackAccountState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + await endpoint(row.endpointId, actor); + await db.transaction(async tx => { + await lease.assertOwned(tx); + const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; + const registrationNow = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)))[0]; + if (!saved || saved.status === "archived" || registrationNow?.status === "removed" || registrationNow?.requestId !== row.requestId) throw conflict("Slack setup changed"); + await notifyChatVerificationWork(tx); + await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); + await lease.assertOwned(tx); + }); + } + async function linkAccount(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + const current = await endpoint(row.endpointId, actor); + const account = current.endpoint.setup.slackAccount; + if (!account) throw providerFailure("slack_install_account_missing"); + if (account.status === "linked") return; + const token = row.secretIds.botToken ? await readSecret(row, "botToken") : await options.runtimeBotToken(row.endpointId); + const user = object((await api("users.info", { user: account.externalUserId }, token)).user); + if (user.id !== account.externalUserId || user.team_id !== row.workspaceId || user.is_bot !== false || user.is_app_user === true || user.deleted !== false) throw providerFailure("slack_install_account_missing"); + await endpoint(row.endpointId, actor); + await options.linkInstaller(row.endpointId, user, { ...actor, userId: account.paperclipUserId }, lease); + await saveAccount(row, { ...account, status: "linked" }, actor, lease); + try { await audit(row, actor, "account_linked"); } + catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack account activity could not be recorded"); } + } + async function sendSetupMessage(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, kind: "welcome" | "verification") { + const current = await endpoint(row.endpointId, actor); + const account = current.endpoint.setup.slackAccount; + if (!account || account.status !== "linked") return; + if (kind === "verification" && (!current.endpoint.setup.webhookVerifiedAt || ["pending", "sending"].includes(account.welcomeStatus))) return; + const field = kind === "welcome" ? "welcomeStatus" : "verificationStatus"; + const status = account[field] ?? "pending"; + if (status === "sending") { + await saveAccount(row, { ...account, [field]: "uncertain" }, actor, lease); + return; // Never replay an ambiguous dispatch after a process restart. + } + if (status !== "pending") return; + await saveAccount(row, { ...account, [field]: "sending" }, actor, lease); + let result: SlackAccountState; + try { + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + if (!await options.canWelcome(row.endpointId, account)) throw forbidden("Slack account access changed"); + const sent = await api("chat.postMessage", { channel: account.dmChannelId ?? account.externalUserId, + text: kind === "verification" ? "Your Slack connection is working! Now ask me a question about your Paperclip instance." + : `Hi, I’m ${current.agentName}. Your Slack account is connected to Paperclip.`, + unfurl_links: "false", unfurl_media: "false" }, await options.runtimeBotToken(row.endpointId)); + if (typeof sent.channel !== "string" || !/^D[A-Z0-9]+$/.test(sent.channel)) throw new Error("Incomplete message response"); + result = { ...account, [field]: "sent", dmChannelId: sent.channel }; + } catch (error) { + result = { ...account, [field]: error instanceof Error && "status" in error ? "failed" : "uncertain" }; + } + await saveAccount(row, result, actor, lease); + try { await audit(row, actor, `${kind}_${result[field] === "sent" ? "sent" : "failed"}`); } + catch { logger.warn({ endpointId: row.endpointId, appId: row.appId }, "Slack setup message activity could not be recorded"); } + } + async function welcome(row: Registration, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + await sendSetupMessage(row, actor, lease, "welcome"); + await sendSetupMessage(row, actor, lease, "verification"); + } + async function notifyVerified(endpointId: string) { + return options.withLock(endpointId, async lease => { + const row = await registration(endpointId); + if (!row || row.status !== "configured") return; + const [saved] = await db.select({ setup: chatEndpoints.setup, status: chatEndpoints.status }).from(chatEndpoints).where(eq(chatEndpoints.id, endpointId)); + const account = saved?.setup.slackAccount; + if (!account || !["verifying", "active"].includes(saved.status)) return; + await sendSetupMessage(row, { userId: account.paperclipUserId, sessionId: null, bypassPermissionCheck: false }, lease, "verification"); + }); + } + function pendingVerificationMessages(limit: number) { + return db.select({ endpointId: chatEndpoints.id }).from(chatEndpoints) + .innerJoin(chatSlackRegistrations, and(eq(chatSlackRegistrations.endpointId, chatEndpoints.id), eq(chatSlackRegistrations.companyId, chatEndpoints.companyId))) + .where(and(eq(chatSlackRegistrations.status, "configured"), inArray(chatEndpoints.status, ["verifying", "active"]), + sql`${chatEndpoints.setup}->'slackAccount'->>'status' = 'linked'`, + sql`${chatEndpoints.setup}->>'webhookVerifiedAt' is not null`, + sql`coalesce(${chatEndpoints.setup}->'slackAccount'->>'welcomeStatus', '') not in ('pending', 'sending')`, + sql`${chatEndpoints.setup}->'slackAccount'->>'verificationStatus' in ('pending', 'sending')`)).limit(limit); + } + async function nextVerificationMessageAt(): Promise { + return (await pendingVerificationMessages(1)).length ? Date.now() : null; + } + async function processPendingVerificationMessages(limit = 25) { + for (const row of await pendingVerificationMessages(limit)) { + try { await notifyVerified(row.endpointId); } + catch { logger.warn({ endpointId: row.endpointId }, "Slack verification message could not be processed"); } + } + } + async function resumeLocked(endpointId: string, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + const current = await endpoint(endpointId, actor); + const row = await registration(endpointId); + if (!row || row.status === "removed") throw conflict("Slack registration is unavailable"); + if (row.status === "configured") { await cleanupStaged(endpointId, lease); await welcome(row, actor, lease); return; } + if (row.status !== "credentials_saved") throw conflict("Install this Slack app before connecting it"); + try { + assertOrigins(row, current.endpoint.publicId); + const botToken = await readSecret(row, "botToken"); + const auth = await api("auth.test", {}, botToken, row.managerGrantId ? [...SLACK_CHAT_BOT_SCOPES, ...SLACK_BOT_TOOL_SCOPES] : undefined); + if (auth.team_id !== row.workspaceId || row.botUserId && auth.user_id !== row.botUserId || typeof auth.user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(auth.user_id)) throw providerFailure("slack_install_identity_mismatch"); + if (row.managerGrantId) { + if (typeof auth.bot_id !== "string" || !/^B[A-Z0-9]+$/.test(auth.bot_id)) throw providerFailure("slack_install_identity_mismatch"); + const bot = object((await api("bots.info", { bot: auth.bot_id }, botToken)).bot); + if (bot.id !== auth.bot_id || bot.app_id !== row.appId || bot.user_id !== auth.user_id || bot.deleted !== false) throw providerFailure("slack_install_identity_mismatch"); + } + if (!row.botUserId) { + if (!row.managerGrantId) throw providerFailure("slack_install_identity_mismatch"); + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ botUserId: auth.user_id }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + } + await options.configure(endpointId, { botToken, signingSecret: row.secretIds.signingSecret + ? await readSecret(row, "signingSecret") : await options.runtimeSigningSecret(endpointId) }, actor, { + assertOwned: async database => { + await lease.assertOwned(database); + await endpoint(endpointId, actor); + assertOrigins(row, current.endpoint.publicId); + }, + }); + await linkAccount(row, actor, lease); + } catch (error) { + const code = object(object(error).details).code; + const safeCode = code === "slack_configuration_token_invalid" ? "slack_install_token_invalid" : code === "slack_install_account_missing" ? code : code === "chat_identity_link_conflict" || code === "chat_identity_already_linked" ? "slack_install_account_conflict" : code === "slack_install_identity_mismatch" || code === "chat_bot_identity_changed" + ? "slack_install_identity_mismatch" : (code === "chat_provider_permissions_missing" || code === "slack_install_scopes_missing" || code === "slack_setup_permission_denied") ? "slack_install_scopes_missing" + : code === "chat_bot_identity_in_use" ? "slack_bot_already_connected" : "slack_configuration_incomplete"; + await setFailure(row, "credentials_saved", safeCode, lease); + await audit(row, actor, "configuration_failed", safeCode); + return; + } + await lease.assertOwned(); + await db.transaction(async tx => { + await notifyChatVerificationWork(tx); + await tx.update(chatSlackRegistrations).set({ status: "configured", errorCode: null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + }); + // Runtime now owns a separate vaulted copy. Keep the registration IDs until + // deletion succeeds so cleanup itself is retryable. + await cleanupStaged(endpointId, lease); + try { await audit(row, actor, "installation_completed"); } + catch { logger.warn({ endpointId, appId: row.appId }, "Slack installation activity could not be recorded"); } + await welcome(row, actor, lease); + } + async function cleanupStaged(endpointId: string, lease: CredentialMutationLeaseGuard) { + const row = await registration(endpointId); + if (!row || row.status !== "configured") return; + const ids = { ...row.secretIds }; + for (const key of ["botToken", "signingSecret"]) { + if (ids[key]) { await lease.assertOwned(); await vault.remove(ids[key]); delete ids[key]; } + } + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ secretIds: ids }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + } + return { resumeLocked, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt }; +} diff --git a/server/src/services/connectors/slack/setup/customer-owned.ts b/server/src/services/connectors/slack/setup/customer-owned.ts new file mode 100644 index 00000000000..6edd552d3fe --- /dev/null +++ b/server/src/services/connectors/slack/setup/customer-owned.ts @@ -0,0 +1,139 @@ +import { createHash } from "node:crypto"; +import { and, eq } from "drizzle-orm"; +import { chatSlackRegistrations } from "@paperclipai/db"; +import { resolveAgentAppearance, buildSlackAppManifest, slackAppConfigurationSchema, type SlackRegistrationInput, type SlackAvatarState } from "@paperclipai/shared"; +import { conflict } from "../../../../errors.js"; +import { logger } from "../../../../middleware/logger.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; +import { object, knownProviderErrors, providerFailure, type Registration, type SlackSetupActor, type SlackRegistrationStore } from "./registration.js"; +export function createCustomerOwnedSetup(store: SlackRegistrationStore) { + const { db, options, endpoint, registration, callbackUri, origin, api, audit, saveSecrets, setFailure, saveAvatar, assertOrigins, renderAvatar } = store; + async function create(endpointId: string, actor: SlackSetupActor, input: SlackRegistrationInput, managed?: { grantId: string; workspaceId: string; request: typeof api }) { + return options.withLock(endpointId, async lease => { + const api = managed?.request ?? store.api; + const current = await endpoint(endpointId, actor); + const previous = await registration(endpointId); + const method = managed ? "managed" : "automatic"; + if (current.endpoint.setup.slackSetupMethod !== method) throw conflict("Resume the selected Slack setup method"); + if (previous && previous.managerGrantId !== (managed?.grantId ?? null)) throw conflict("Resume setup with the original Slack workspace"); + if (previous?.status === "removed") throw conflict("This registration was removed. Continue the selected manual setup."); + if (previous?.appId) { + if (previous.errorCode === "slack_manifest_update_pending") { + if (!await configureManifest(previous, actor, input.credentials.configurationToken, lease, method, api)) return; + if (current.endpoint.setup.slackAvatar?.status !== "uploaded") await configureAvatar(previous, actor, input.credentials.configurationToken, lease, api); + } + return; + } + if (previous?.requestId === input.requestId) return; + if (previous && ["creating", "uncertain"].includes(previous.status) && !input.confirmedNoAppCreated) { + await setFailure(previous, "uncertain", "slack_creation_uncertain", lease); + return; + } + if (current.endpoint.status !== "draft" || current.endpoint.botExternalId || current.endpoint.setup.slackSetupMethod !== method) + throw conflict("Automatic creation is only available for a new Slack connection"); + const app = slackAppConfigurationSchema.parse(current.endpoint.setup.slackApp); + const redirect = callbackUri(); + const manifest = buildSlackAppManifest({ app, agentName: current.agentName, + webhookUrl: `${origin(options.webhookOrigin())}/api/chat-webhooks/${current.endpoint.publicId}/slack`, redirectUri: redirect }); + // Slack cannot authenticate a challenge until its creation response gives us + // the signing secret. Add event subscriptions only after that secret is durable. + const { event_subscriptions: _events, ...creationSettings } = manifest.settings; + const creationManifest = { ...manifest, settings: creationSettings }; + try { await api("apps.manifest.validate", { manifest: JSON.stringify(manifest) }, input.credentials.configurationToken); } + catch (error) { + if (error instanceof Error && "status" in error) throw error; + throw providerFailure("slack_provider_failure"); + } + // No provider creation has happened until this intent is durable. + await endpoint(endpointId, actor); + await lease.assertOwned(); + const values = { companyId: current.endpoint.companyId, requestId: input.requestId, status: "creating" as const, + manifest, manifestHash: createHash("sha256").update(JSON.stringify(manifest)).digest("hex"), callbackUri: redirect, + createdByUserId: actor.userId, managerGrantId: managed?.grantId ?? null, workspaceId: managed?.workspaceId ?? null, errorCode: null, updatedAt: new Date() }; + const [row] = await db.insert(chatSlackRegistrations).values({ endpointId, ...values }) + .onConflictDoUpdate({ target: chatSlackRegistrations.endpointId, set: values }).returning(); + await audit(row, actor, "creation_started"); + let createdAppId: string; + try { + const result = await api("apps.manifest.create", { manifest: JSON.stringify(creationManifest) }, input.credentials.configurationToken); + const credentials = object(result.credentials); + if (typeof result.app_id !== "string" || !/^A[A-Z0-9]+$/.test(result.app_id) + || typeof credentials.client_id !== "string" || !/^\d+\.\d+$/.test(credentials.client_id) + || typeof credentials.client_secret !== "string" || !credentials.client_secret + || typeof credentials.signing_secret !== "string" || !credentials.signing_secret) + throw new Error("Incomplete Slack creation response"); + if (managed && result.team_id !== managed.workspaceId) throw new Error("Slack creation workspace mismatch"); + await endpoint(endpointId, actor); + await saveSecrets(row, { signingSecret: credentials.signing_secret, clientSecret: credentials.client_secret }, actor, lease, + { appId: result.app_id, clientId: credentials.client_id, status: "install", errorCode: "slack_manifest_update_pending" }); + createdAppId = result.app_id; + } catch (error) { + // Only documented rejection responses prove that creation did not happen. + const code = object(object(error).details).code; + const rejected = typeof code === "string" && [...Object.values(knownProviderErrors), "slack_manager_reauthorize"].includes(code); + await setFailure(row, rejected ? "failed" : "uncertain", rejected ? code : "slack_creation_uncertain", lease); + await audit(row, actor, "creation_failed", rejected ? code : "slack_creation_uncertain"); + return; + } + // The app and credentials are durable before optional icon provisioning. + // A retry/restart always reuses this app, even if icon upload is interrupted. + const created = { ...row, appId: createdAppId }; + if (!await configureManifest(created, actor, input.credentials.configurationToken, lease, method, api)) return; + await configureAvatar(created, actor, input.credentials.configurationToken, lease, api); + // An audit outage cannot change the saved creation outcome. + try { await audit({ ...row, appId: createdAppId }, actor, "app_created"); } + catch { logger.warn({ endpointId, appId: createdAppId }, "Slack app creation activity could not be recorded"); } + }); + } + async function configureAvatar(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard, api = store.api) { + const current = await endpoint(row.endpointId, actor); + await saveAvatar(row, { status: "pending" }, actor, lease); + let avatar: SlackAvatarState; + try { + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + // Cloud ingress requires a tenant session. Send the preset PNG bytes so + // Slack does not need to fetch an authenticated Paperclip URL. + const render = renderAvatar; + const appearance = { ...resolveAgentAppearance(current.agentAppearance, current.endpoint.assignedAgentId) }; + delete appearance.customAvatarAssetId; // The preset renderer cannot read private uploaded assets. + const png = await render({ appearance, + size: 512, scale: 1, pose: "rest", muted: false, background: "paperclip-dark" }); + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + const upload = new FormData(); + upload.set("app_id", row.appId!); + upload.set("file", new Blob([new Uint8Array(png)], { type: "image/png" }), "agent-avatar.png"); + await api("apps.icon.set", upload, configurationToken); + avatar = { status: "uploaded", uploadedAt: new Date().toISOString() }; + } catch { + // Provider echoes (including tokens) never become saved state or activity. + avatar = { status: "failed", errorCode: "slack_avatar_upload_failed" }; + } + await saveAvatar(row, avatar, actor, lease); + try { await audit(row, actor, avatar.status === "uploaded" ? "avatar_uploaded" : "avatar_upload_failed", avatar.status === "failed" ? avatar.errorCode : undefined); } + catch { logger.warn({ endpointId: row.endpointId, appId: row.appId! }, "Slack avatar activity could not be recorded"); } + } + async function configureManifest(row: Registration, actor: SlackSetupActor, configurationToken: string, lease: CredentialMutationLeaseGuard, method: "automatic" | "managed" = "automatic", api = store.api) { + const current = await endpoint(row.endpointId, actor); + if (current.endpoint.status !== "draft" || current.endpoint.setup.slackSetupMethod !== method) + throw conflict("Resume the saved Slack connection before configuring its app"); + assertOrigins(row, current.endpoint.publicId); + await lease.assertOwned(); + try { + await api("apps.manifest.update", { app_id: row.appId!, manifest: JSON.stringify(row.manifest) }, configurationToken); + } catch { + // The pending marker was saved with the app secrets before dispatch. Retrying + // the same manifest is safe even when Slack's response was lost. + await audit(row, actor, "manifest_update_failed", "slack_manifest_update_pending"); + return false; + } + await endpoint(row.endpointId, actor); + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }) + .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); + await audit(row, actor, "manifest_updated"); + return true; + } + return { create, configureManifest }; +} diff --git a/server/src/services/connectors/slack/setup/development-manager.ts b/server/src/services/connectors/slack/setup/development-manager.ts new file mode 100644 index 00000000000..d4e71fd868a --- /dev/null +++ b/server/src/services/connectors/slack/setup/development-manager.ts @@ -0,0 +1,73 @@ +import { readFileSync, statSync } from "node:fs"; +import { isAbsolute } from "node:path"; +import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; +import type { PaperclipCloudConnector, SealedConnectorCredentials } from "../../../paperclip-cloud-connector.js"; +import { createSlackSetupClient, object, providerFailure } from "./provider-client.js"; + +export type SlackManagerClient = Pick & { authorizationMode?: "code" }; + +// Explicit local qualification only. Production continues using the sealed Cloud +// broker; ordinary self-hosted installs neither load credentials nor contact it. +export function developmentSlackManager( + publicOrigin: () => string, + env: NodeJS.ProcessEnv = process.env, + fetchImpl: typeof fetch = fetch, +): SlackManagerClient | null { + if (env.NODE_ENV !== "development" || env.PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED !== "true" + || !env.PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG) return null; + let config: Record; + try { + const path = env.PAPERCLIP_SLACK_MANAGER_DEVELOPMENT_CONFIG; + if (!isAbsolute(path)) throw new Error(); + const stat = statSync(path); + if (!stat.isFile() || stat.size > 16_384 || (stat.mode & 0o077) !== 0) throw new Error(); + config = object(JSON.parse(readFileSync(path, "utf8"))); + } catch { throw providerFailure("slack_managed_unavailable"); } + const { appId, clientId, clientSecret } = config; + if (typeof appId !== "string" || !/^A[A-Z0-9]+$/.test(appId) + || typeof clientId !== "string" || !/^\d+\.\d+$/.test(clientId) + || typeof clientSecret !== "string" || !clientSecret) throw providerFailure("slack_managed_unavailable"); + const callback = () => { + const url = new URL(publicOrigin()); + if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) throw providerFailure("slack_managed_unavailable"); + return `${url.origin}/api/chat-slack/managed/oauth/callback`; + }; + const api = createSlackSetupClient(fetchImpl); + async function credentials(fields: Record, subject: string, companyId: string, initial: boolean): Promise { + let result: Record; + try { result = await api("oauth.v2.access", { client_id: clientId as string, client_secret: clientSecret as string, ...fields }); } + catch { throw providerFailure("slack_manager_reauthorize"); } + const user = initial ? object(result.authed_user) : result; + const scopes = typeof user.scope === "string" ? user.scope.split(/[, ]+/) : []; + const team = object(result.team); + if (typeof user.access_token !== "string" || !user.access_token || typeof user.refresh_token !== "string" || !user.refresh_token + || user.token_type !== "user" || typeof user.expires_in !== "number" || !Number.isFinite(user.expires_in) || user.expires_in <= 0 + || SLACK_MANAGER_SCOPES.some(scope => !scopes.includes(scope)) + || (initial && (result.app_id !== appId || typeof team.id !== "string" || !/^T[A-Z0-9]+$/.test(team.id) + || typeof team.name !== "string" || !team.name || typeof user.id !== "string" || !/^[UW][A-Z0-9]+$/.test(user.id)))) throw providerFailure("slack_manager_reauthorize"); + return { + v: 1, accessToken: user.access_token, refreshToken: user.refresh_token, tokenType: "user", + accessTokenExpiresAt: new Date(Date.now() + user.expires_in * 1000).toISOString(), refreshTokenExpiresAt: null, + scopes, subject, companyId, instanceId: "local-slack-qualification", environment: "development", provider: "slack", profile: "slack.manager", + ...(initial ? { slackManager: { appId: appId as string, workspaceId: team.id as string, workspaceName: team.name as string, userId: user.id as string } } : {}), + }; + } + return { + authorizationMode: "code", + async getSlackManagerAppId() { callback(); return appId as string; }, + async startAuthorization(values) { + if (values.returnUri !== callback() || values.profile !== "slack.manager") throw providerFailure("slack_manager_reauthorize"); + const url = new URL("https://slack.com/oauth/v2/authorize"); + url.search = new URLSearchParams({ client_id: clientId as string, user_scope: SLACK_MANAGER_SCOPES.join(","), redirect_uri: callback(), state: values.returnState }).toString(); + return { authorizationUrl: url.toString(), expiresAt: new Date(Date.now() + 600_000).toISOString() }; + }, + async claim(values) { return credentials({ code: values.claimId, redirect_uri: callback() }, values.subject, values.companyId, true); }, + async refresh(values) { return credentials({ grant_type: "refresh_token", refresh_token: values.refreshToken }, values.subject, values.companyId, false); }, + async revoke(values) { + try { await api("auth.revoke", {}, values.token); } + catch { throw providerFailure("slack_manager_reauthorize"); } + }, + }; +} diff --git a/server/src/services/connectors/slack/setup/managed.ts b/server/src/services/connectors/slack/setup/managed.ts new file mode 100644 index 00000000000..fc529fc7b9b --- /dev/null +++ b/server/src/services/connectors/slack/setup/managed.ts @@ -0,0 +1,81 @@ +import { and, eq } from "drizzle-orm"; +import { chatActions, chatSlackRegistrations } from "@paperclipai/db"; +import { type SlackManagedProvisionInput, type SlackManagedResult } from "@paperclipai/shared"; +import { conflict } from "../../../../errors.js"; +import type { createCustomerOwnedSetup } from "./customer-owned.js"; +import type { createSlackManagerGrants } from "./manager-grants.js"; +import type { createSlackCompletion } from "./completion.js"; +import { object, providerFailure, type SlackRegistrationStore, type SlackSetupActor } from "./registration.js"; + +/** Managed provisioning ends at the same saved-credential boundary as child OAuth. */ +export function createManagedSlackSetup(store: SlackRegistrationStore, grants: ReturnType, + provisioner: ReturnType, completion: ReturnType, + installFallback: (endpointId: string, actor: SlackSetupActor) => Promise<{ authorizationUrl: string; expiresAt: string }>) { + async function provision(endpointId: string, actor: SlackSetupActor, input: SlackManagedProvisionInput): Promise { + const current = await store.endpoint(endpointId, actor); + if (current.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Resume the selected Slack setup method"); + const authorized = await grants.token(input.grantId, current.endpoint.companyId, actor); + await provisioner.create(endpointId, actor, { requestId: input.requestId, confirmedNoAppCreated: input.confirmedNoAppCreated, + credentials: { configurationToken: authorized.token } }, { grantId: authorized.grant.id, workspaceId: authorized.grant.workspaceId, request: (method, fields) => grants.request(input.grantId, current.endpoint.companyId, actor, method, fields) }); + let fallback = false; + await store.options.withLock(endpointId, async lease => { + const latest = await store.endpoint(endpointId, actor); + const row = await store.registration(endpointId); + if (!row?.appId || row.status === "removed" || row.errorCode === "slack_manifest_update_pending") return; + if (row.managerGrantId !== authorized.grant.id || latest.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Slack setup changed"); + store.assertOrigins(row, latest.endpoint.publicId); + const reconnectKey = input.reconnect ? `slack_managed_reconnect:${actor.userId}:${input.requestId}` : null; + const [previous] = reconnectKey ? await store.db.select().from(chatActions).where(and(eq(chatActions.companyId, row.companyId), eq(chatActions.endpointId, endpointId), eq(chatActions.providerActionId, reconnectKey))) : []; + async function completeSaved() { + await completion.resumeLocked(endpointId, actor, lease); + if (reconnectKey && (await store.registration(endpointId))?.status === "configured") { + await lease.assertOwned(); + await store.db.update(chatActions).set({ status: "processed", updatedAt: new Date() }).where(and(eq(chatActions.companyId, row!.companyId), eq(chatActions.endpointId, endpointId), eq(chatActions.providerActionId, reconnectKey))); + } + } + if (previous && row.status === "configured" && !["revoked", "attention"].includes(latest.endpoint.status)) { + await completeSaved(); return; + } + const needsReinstall = row.status === "configured" && (input.reconnect || ["revoked", "attention"].includes(latest.endpoint.status)) + || row.status === "credentials_saved" && ["slack_install_token_invalid", "slack_install_scopes_missing"].includes(row.errorCode ?? ""); + if (reconnectKey && !previous) { + // Atomically bind an explicit reconnect to its durable installation intent. + // Duplicate requests can return the saved connection without reinstalling. + await store.db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.insert(chatActions).values({ companyId: row.companyId, endpointId, kind: "slack_managed_reconnect", providerActionId: reconnectKey, status: "processing", payload: { grantId: row.managerGrantId, registrationRequestId: row.requestId } }); + if (needsReinstall) await tx.update(chatSlackRegistrations).set({ status: "install", errorCode: row.errorCode ?? "slack_install_failed", updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + }); + } + if ((row.status === "configured" || row.status === "credentials_saved") && !needsReinstall) { + await completeSaved(); + return; + } + // Keep reinstall intent durable even if this dispatch fails or the process exits. + if (needsReinstall && (!reconnectKey || previous)) await store.setFailure(row, "install", row.errorCode ?? "slack_install_failed", lease); + const { grant } = await grants.token(input.grantId, row.companyId, actor); + try { + await lease.assertOwned(); + const result = await grants.request(grant.id, row.companyId, actor, "apps.managedInstall", { app_id: row.appId, team_id: grant.workspaceId }); + const botToken = object(result.api_access_tokens).bot_access_token; + if (result.app_id !== row.appId || result.team_id !== grant.workspaceId || typeof botToken !== "string" || !botToken.startsWith("xoxb-")) throw providerFailure("slack_install_identity_mismatch"); + if ((await grants.get(grant.id, row.companyId, actor)).status !== "active") throw providerFailure("slack_manager_reauthorize"); + await store.endpoint(endpointId, actor); + // Vault before auth.test/inventory so network failures never lose this installation. + await store.saveSecrets(row, { botToken }, actor, lease, { status: "credentials_saved", workspaceId: grant.workspaceId, errorCode: null }, + { externalUserId: grant.slackUserId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" }); + } catch (error) { + const code = object(object(error).details).code; + const safe = typeof code === "string" ? code : "slack_install_failed"; + await store.setFailure(row, needsReinstall ? "install" : row.status, safe, lease); + await store.audit(row, actor, "installation_failed", safe); + fallback = ["slack_approval_required", "slack_approval_pending", "slack_approval_denied"].includes(safe); + return; + } + await completeSaved(); + }); + // OAuth's state/actor binding is ours; never trust a provider-supplied redirect. + return fallback ? { authorization: await installFallback(endpointId, actor) } : {}; + } + return { provision }; +} diff --git a/server/src/services/connectors/slack/setup/manager-grants.ts b/server/src/services/connectors/slack/setup/manager-grants.ts new file mode 100644 index 00000000000..3d902d9928e --- /dev/null +++ b/server/src/services/connectors/slack/setup/manager-grants.ts @@ -0,0 +1,269 @@ +import { randomBytes, randomUUID } from "node:crypto"; +import { and, eq, gt, like, sql } from "drizzle-orm"; +import { chatEndpoints, chatSlackManagerGrants, chatSlackRegistrations, companySecrets, toolOauthStates } from "@paperclipai/db"; +import { SLACK_MANAGER_SCOPES, type SlackSetupOptions as SetupChoices } from "@paperclipai/shared"; +import { conflict, forbidden } from "../../../../errors.js"; +import { getCloudRuntimeIdentity } from "../../../cloud-runtime-identity.js"; +import { createPaperclipCloudConnector, paperclipCloudConnectorConfigFromEnv, type SealedConnectorCredentials } from "../../../paperclip-cloud-connector.js"; +import { instanceSettingsService } from "../../../instance-settings.js"; +import { accessService } from "../../../access.js"; +import { secretService } from "../../../secrets.js"; +import { writeConnectionCredential } from "../../../connection-credentials.js"; +import { logActivity } from "../../../activity-log.js"; +import { object, providerFailure, type SlackRegistrationStore, type SlackSetupActor } from "./registration.js"; +import { developmentSlackManager, type SlackManagerClient } from "./development-manager.js"; + +export const SLACK_MANAGER_STATE_PREFIX = "slack-manager."; +export function createSlackManagerGrants(store: SlackRegistrationStore) { + const { db, options, endpoint, origin, api } = store; + type Database = Parameters[0]; + function broker(): SlackManagerClient | null { + if (options.managed) return options.managed.enabled ? options.managed.broker : null; + if (!getCloudRuntimeIdentity()) { + const development = developmentSlackManager(() => origin(options.publicOrigin())); + if (development) return development; + } + // Open-source instances never contact a hosted service to discover this path. + if (!getCloudRuntimeIdentity() || process.env.PAPERCLIP_SLACK_MANAGED_SETUP_ENABLED !== "true") return null; + const config = paperclipCloudConnectorConfigFromEnv(); + return config ? createPaperclipCloudConnector({ config }) : null; + } + async function access(companyId: string, actor: SlackSetupActor, database: Database = db) { + if (!(await instanceSettingsService(database as typeof db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); + if (!actor.bypassPermissionCheck && !await accessService(database as typeof db).hasPermission(companyId, "user", actor.userId, "tools:manage_connections")) throw forbidden("Missing permission: tools:manage_connections"); + } + // Deployment intent is stable across broker outages and preview rollbacks. + // A missing live profile pauses managed setup; it must not select token entry. + function defaultMethod(): "managed" | "automatic" { return broker() ? "managed" : "automatic"; } + async function available() { + const client = broker(); + if (!client || !(await instanceSettingsService(db).getExperimental()).enableChatConnectors) return false; + try { origin(options.publicOrigin()); origin(options.webhookOrigin()); return Boolean(await client.getSlackManagerAppId()); } + catch { return false; } + } + async function requiredBroker() { + if (!await available()) throw providerFailure("slack_managed_unavailable"); + return broker()!; + } + async function choices(companyId: string, actor: SlackSetupActor): Promise { + await access(companyId, actor); + const managedAvailable = await available(); + const managerAppId = managedAvailable ? await broker()!.getSlackManagerAppId() : null; + const grants = managedAvailable && managerAppId ? await db.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.status, "active"), eq(chatSlackManagerGrants.managerAppId, managerAppId))) : []; + return { managedAvailable, defaultMethod: defaultMethod(), workspaces: grants.map(g => ({ grantId: g.id, workspaceId: g.workspaceId, workspaceName: g.workspaceName, userId: g.slackUserId })) }; + } + const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/managed/oauth/callback`; + async function authorize(endpointId: string, actor: SlackSetupActor) { + const client = await requiredBroker(); + return options.withLock(endpointId, async lease => { + const current = await endpoint(endpointId, actor); + if (current.endpoint.setup.slackSetupMethod !== "managed") throw conflict("Resume the selected Slack setup method"); + const row = await store.registration(endpointId); + const revisions = await db.select({ id: chatSlackManagerGrants.id, revision: chatSlackManagerGrants.revision }).from(chatSlackManagerGrants) + .where(and(eq(chatSlackManagerGrants.companyId, current.endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId))); + const state = `${SLACK_MANAGER_STATE_PREFIX}${randomBytes(32).toString("base64url")}`; + const expiresAt = new Date(Date.now() + 10 * 60_000); + await lease.assertOwned(); + await db.insert(toolOauthStates).values({ state, companyId: current.endpoint.companyId, connectionId: current.connection.id, + codeVerifier: JSON.stringify({ endpointId, revisions, authorizationMode: client.authorizationMode ?? "claim", requestId: row?.requestId ?? null, callbackUri: callbackUri(), webhookOrigin: origin(options.webhookOrigin()), runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }), + createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId, requestedScopes: [...SLACK_MANAGER_SCOPES], expiresAt }); + try { return await client.startAuthorization({ profile: "slack.manager", subject: actor.userId, companyId: current.endpoint.companyId, returnUri: callbackUri(), returnState: state }); } + catch { await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); throw providerFailure("slack_manager_reauthorize"); } + }); + } + async function pending(state: string, actor: SlackSetupActor, allowExpired = false) { + if (!/^slack-manager\.[A-Za-z0-9_-]{43}$/.test(state)) throw forbidden("Invalid Slack authorization state"); + const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), ...(allowExpired ? [] : [gt(toolOauthStates.expiresAt, new Date())]))); + if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) throw forbidden("Slack authorization expired or belongs to another session"); + const binding = object(JSON.parse(attempt.codeVerifier)); + const current = await endpoint(String(binding.endpointId), actor); + const row = await store.registration(current.endpoint.id); + if (current.endpoint.setup.slackSetupMethod !== "managed" || current.connection.id !== attempt.connectionId || current.endpoint.companyId !== attempt.companyId + || ["paused", "revoked"].includes(current.endpoint.status) || row?.status === "removed" + || (binding.authorizationMode ?? "claim") !== (broker()?.authorizationMode ?? "claim") + || (row?.requestId ?? null) !== binding.requestId || binding.callbackUri !== callbackUri() || binding.webhookOrigin !== origin(options.webhookOrigin()) + || binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0)) throw conflict("Slack setup changed. Resume authorization."); + return { attempt, current, row, binding }; + } + async function expiredReturn(state: string, actor: SlackSetupActor) { + const { current, attempt } = await pending(state, actor, true); + if (attempt.expiresAt.getTime() > Date.now()) return null; + return options.withLock(current.endpoint.id, async lease => { + const latest = await pending(state, actor, true); + await lease.assertOwned(); + await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); + await db.update(chatEndpoints).set({ setup: { ...latest.current.endpoint.setup, slackManagerError: "slack_install_expired" } }).where(eq(chatEndpoints.id, current.endpoint.id)); + return current.endpoint.id; + }); + } + async function complete(state: string, claimId: string | null, error: string | null, actor: SlackSetupActor, authorizationCode: string | null = null) { + const client = await requiredBroker(); + claimId = client.authorizationMode === "code" ? authorizationCode : claimId; + const before = await pending(state, actor); + return options.withLock(before.current.endpoint.id, async lease => { + const { current, row, binding } = await pending(state, actor); + await lease.assertOwned(); + const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning(); + if (!claimed) throw conflict("Slack authorization was already used"); + async function outcome(code: string | null) { + await lease.assertOwned(); + const latest = await endpoint(current.endpoint.id, actor); + await db.update(chatEndpoints).set({ setup: { ...latest.endpoint.setup, slackManagerError: code ?? undefined } }).where(eq(chatEndpoints.id, current.endpoint.id)); + if (code) await logActivity(db, { companyId: current.endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_authorization_failed", entityType: "chat_endpoint", entityId: current.endpoint.id, details: { code } }); + return current.endpoint.id; + } + if (error || !claimId || claimId.length > 4096) return outcome(error === "access_denied" ? "slack_install_declined" : "slack_manager_reauthorize"); + try { + let grant: SealedConnectorCredentials; + try { grant = await client.claim({ profile: "slack.manager", subject: actor.userId, companyId: current.endpoint.companyId, claimId, redemptionId: randomUUID() }); } + catch { throw providerFailure("slack_manager_reauthorize"); } + const identity = grant.slackManager; + if (!identity || identity.appId !== await client.getSlackManagerAppId() || !/^A[A-Z0-9]+$/.test(identity.appId) || !/^T[A-Z0-9]+$/.test(identity.workspaceId) || !/^[UW][A-Z0-9]+$/.test(identity.userId) || typeof identity.workspaceName !== "string" || !identity.workspaceName || identity.workspaceName.length > 255) throw providerFailure("slack_manager_reauthorize"); + if (row?.managerGrantId) { + const existing = await get(row.managerGrantId, current.endpoint.companyId, actor); + if (existing.managerAppId !== identity.appId || existing.workspaceId !== identity.workspaceId || existing.slackUserId !== identity.userId) throw providerFailure("slack_install_identity_mismatch"); + } + await endpoint(current.endpoint.id, actor); + await access(current.endpoint.companyId, actor); + await db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager:${current.endpoint.companyId}:${actor.userId}:${identity.appId}:${identity.workspaceId}`}, 0))`); + const [existing] = await tx.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, current.endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.managerAppId, identity.appId), eq(chatSlackManagerGrants.workspaceId, identity.workspaceId))); + if (existing) { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${existing.id}`}, 0))`); + const [latest] = await tx.select().from(chatSlackManagerGrants).where(eq(chatSlackManagerGrants.id, existing.id)); + const revision = Array.isArray(binding.revisions) ? binding.revisions.map(object).find(value => value.id === existing.id)?.revision : undefined; + if (!latest || latest.revision !== revision) throw conflict("Workspace authorization changed. Start again."); + } + const [saved] = await tx.insert(chatSlackManagerGrants).values({ companyId: current.endpoint.companyId, userId: actor.userId, slackUserId: identity.userId, workspaceId: identity.workspaceId, workspaceName: identity.workspaceName, managerAppId: identity.appId }) + .onConflictDoUpdate({ target: [chatSlackManagerGrants.companyId, chatSlackManagerGrants.userId, chatSlackManagerGrants.managerAppId, chatSlackManagerGrants.workspaceId], set: { updatedAt: new Date() } }).returning(); + // A reauthorization must never change which Slack person this grant represents. + if (saved.slackUserId !== identity.userId) throw conflict("Reconnect using the original Slack account"); + await access(current.endpoint.companyId, actor, tx); + await save(tx, saved, grant, actor); + if (row?.managerGrantId === saved.id) { + await tx.update(chatSlackRegistrations).set({ errorCode: null, updatedAt: new Date() }) + .where(and(eq(chatSlackRegistrations.endpointId, current.endpoint.id), eq(chatSlackRegistrations.managerGrantId, saved.id), eq(chatSlackRegistrations.errorCode, "slack_manager_reauthorize"))); + } + }); + await logActivity(db, { companyId: current.endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_authorized", entityType: "chat_endpoint", entityId: current.endpoint.id, details: { workspaceId: identity.workspaceId, managerAppId: identity.appId } }); + return outcome(null); + } catch { return outcome("slack_manager_reauthorize"); } + }); + } + type Grant = typeof chatSlackManagerGrants.$inferSelect; + async function get(id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor); + return readGrant(db, id, companyId, actor); + } + async function readGrant(database: Database, id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor, database); + const [grant] = await database.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.id, id), eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.userId, actor.userId))); + if (!grant) throw forbidden("This Slack workspace authorization belongs to another account"); + return grant; + } + async function save(tx: Parameters[0]>[0], row: Grant, credentials: SealedConnectorCredentials, actor: SlackSetupActor) { + if (credentials.tokenType !== "user" || !credentials.accessToken || !credentials.refreshToken || !credentials.accessTokenExpiresAt || !Number.isFinite(Date.parse(credentials.accessTokenExpiresAt)) || Date.parse(credentials.accessTokenExpiresAt) <= Date.now() || credentials.provider !== "slack" || credentials.profile !== "slack.manager" || credentials.subject !== row.userId || credentials.companyId !== row.companyId || SLACK_MANAGER_SCOPES.some(scope => !credentials.scopes.includes(scope))) throw providerFailure("slack_manager_reauthorize"); + const write = (key: string, value: string, secretId: string | null) => writeConnectionCredential(tx, { companyId: row.companyId, ownerUserId: row.userId, actor: { userId: actor.userId }, connectionName: "Slack workspace management", configPath: `slack_manager.${key}`, definitionKey: `slack_manager.${row.id}.${key}`, label: key, value, ...(secretId ? { existingRef: { secretId, configPath: `slack_manager.${key}` } } : {}) }); + const accessToken = await write("access_token", credentials.accessToken, row.accessSecretId); + const refreshToken = credentials.refreshToken ? await write("refresh_token", credentials.refreshToken, row.refreshSecretId) : null; + await tx.update(chatSlackManagerGrants).set({ accessSecretId: accessToken.secret.id, refreshSecretId: refreshToken?.secret.id ?? row.refreshSecretId, expiresAt: credentials.accessTokenExpiresAt ? new Date(credentials.accessTokenExpiresAt) : null, status: "active", revision: row.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, row.id)); + } + async function secret(row: Grant, id: string | null, database: Database = db) { + if (!id) throw providerFailure("slack_manager_reauthorize"); + const [ref] = await database.select().from(companySecrets).where(and(eq(companySecrets.id, id), eq(companySecrets.companyId, row.companyId), eq(companySecrets.scope, "user"), eq(companySecrets.ownerUserId, row.userId))); + if (!ref?.userSecretDefinitionId) throw providerFailure("slack_manager_reauthorize"); + // Owner-scoped system consumer; the durable grant is its authority, not a tool binding. + const value = await secretService(database).resolveUserSecretValue(row.companyId, { definitionId: ref.userSecretDefinitionId, responsibleUserId: row.userId }, { consumerType: "system", consumerId: `slack-manager:${row.id}`, actorType: "user", actorId: row.userId, responsibleUserId: row.userId }); + if (!value) throw providerFailure("slack_manager_reauthorize"); + return value.value; + } + const tokenRequests = new Map>(); + async function token(id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor); + const client = await requiredBroker(); + const managerAppId = await client.getSlackManagerAppId(); + const key = `${companyId}:${actor.userId}:${id}`; + const running = tokenRequests.get(key); + if (running) return running; + const operation = (async () => { + // Commit the single-use refresh claim before dispatch, without holding a + // pool connection while another query or the broker needs it. + const prepared = await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const grant = await readGrant(tx, id, companyId, actor); + if (grant.managerAppId !== managerAppId || grant.status !== "active") throw providerFailure("slack_manager_reauthorize"); + if (!grant.expiresAt || grant.expiresAt.getTime() > Date.now() + 60_000) return { grant, token: await secret(grant, grant.accessSecretId, tx) }; + const refreshToken = await secret(grant, grant.refreshSecretId, tx); + const [claimed] = await tx.update(chatSlackManagerGrants).set({ status: "reauthorize", revision: grant.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)).returning(); + return { grant: claimed!, refreshToken }; + }); + if ("token" in prepared) return { grant: prepared.grant, token: prepared.token! }; + try { + const refreshed = await client.refresh({ profile: "slack.manager", subject: actor.userId, companyId, refreshToken: prepared.refreshToken }); + await access(companyId, actor); + return await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const current = await readGrant(tx, id, companyId, actor); + if (current.status !== "reauthorize" || current.revision !== prepared.grant.revision) throw providerFailure("slack_manager_reauthorize"); + await save(tx, current, refreshed, actor); + return { grant: await readGrant(tx, id, companyId, actor), token: refreshed.accessToken }; + }); + } catch { throw providerFailure("slack_manager_reauthorize"); } + })(); + tokenRequests.set(key, operation); + try { return await operation; } finally { if (tokenRequests.get(key) === operation) tokenRequests.delete(key); } + } + async function revoke(id: string, companyId: string, actor: SlackSetupActor) { + await access(companyId, actor); + let providerRevoked = true; + const grant = await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const current = await readGrant(tx, id, companyId, actor); + await tx.update(chatSlackManagerGrants).set({ status: "revoked", revision: current.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)); + await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.companyId, companyId), eq(toolOauthStates.createdByActorId, actor.userId), like(toolOauthStates.state, `${SLACK_MANAGER_STATE_PREFIX}%`))); + const client = broker(); + for (const secretId of [current.accessSecretId, current.refreshSecretId]) if (secretId) { + try { + if (!client) throw providerFailure("slack_managed_unavailable"); + await client.revoke({ subject: actor.userId, companyId, profile: "slack.manager", token: await secret(current, secretId, tx) }); + } catch { providerRevoked = false; } + await secretService(tx).remove(secretId); + } + await tx.update(chatSlackManagerGrants).set({ accessSecretId: null, refreshSecretId: null }).where(eq(chatSlackManagerGrants.id, id)); + return current; + }); + await logActivity(db, { companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.manager_revoked", entityType: "slack_manager_grant", entityId: id, details: { workspaceId: grant.workspaceId, providerRevoked } }); + } + async function request(id: string, companyId: string, actor: SlackSetupActor, method: string, fields: Record | FormData) { + const authorized = await token(id, companyId, actor); + const { grant } = authorized; + // Slack shares a method budget across manager tokens in the same workspace. + // Persist Retry-After across grants and process restarts; never retry a dispatch. + const result = await db.transaction(async tx => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-api:${companyId}:${grant.managerAppId}:${grant.workspaceId}:${method}`}, 0))`); + // Serialize provider dispatch against refresh/revocation for this personal grant. + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`slack-manager-refresh:${id}`}, 0))`); + const peers = await tx.select().from(chatSlackManagerGrants).where(and(eq(chatSlackManagerGrants.companyId, companyId), eq(chatSlackManagerGrants.managerAppId, grant.managerAppId), eq(chatSlackManagerGrants.workspaceId, grant.workspaceId))).orderBy(chatSlackManagerGrants.id); + if (peers.some(peer => Date.parse(peer.rateLimits[method] ?? "") > Date.now())) return { error: providerFailure("slack_setup_rate_limited") }; + const current = await readGrant(tx, id, companyId, actor); + if (current.status !== "active") return { error: providerFailure("slack_manager_reauthorize") }; + try { return { value: await api(method, fields, await secret(current, current.accessSecretId, tx)) }; } + catch (error) { + const details = object(object(error).details); + if (details.code === "slack_setup_rate_limited") { + const until = new Date(Date.now() + Math.min(Number(details.retryAfterSeconds) || 60, 86_400) * 1000).toISOString(); + for (const peer of peers) await tx.update(chatSlackManagerGrants).set({ rateLimits: sql`${chatSlackManagerGrants.rateLimits} || ${JSON.stringify({ [method]: until })}::jsonb` }).where(eq(chatSlackManagerGrants.id, peer.id)); + } + if (details.code === "slack_configuration_token_invalid") { + await tx.update(chatSlackManagerGrants).set({ status: "reauthorize", revision: current.revision + 1, updatedAt: new Date() }).where(eq(chatSlackManagerGrants.id, id)); + return { error: providerFailure("slack_manager_reauthorize") }; + } + return { error }; + } + }); + if ("error" in result) throw result.error; + return result.value!; + } + return { choices, defaultMethod, available, authorize, pending, expiredReturn, complete, get, token, revoke, request }; +} diff --git a/server/src/services/connectors/slack/setup/provider-client.ts b/server/src/services/connectors/slack/setup/provider-client.ts new file mode 100644 index 00000000000..bc051d651cd --- /dev/null +++ b/server/src/services/connectors/slack/setup/provider-client.ts @@ -0,0 +1,58 @@ +import { slackRegistrationErrorMessage } from "@paperclipai/shared"; +import { unprocessable } from "../../../../errors.js"; +export const object = (value: unknown): Record => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +export const providerFailure = (code: string) => unprocessable(slackRegistrationErrorMessage(code), { code }); +export const knownProviderErrors: Record = { + app_approval_request_eligible: "slack_approval_required", app_approval_request_pending: "slack_approval_pending", + app_approval_request_denied: "slack_approval_denied", admin_approval_required: "slack_approval_required", + manager_app_not_eligible: "slack_managed_unavailable", feature_not_enabled: "slack_managed_unavailable", + invalid_auth: "slack_configuration_token_invalid", token_expired: "slack_configuration_token_invalid", + token_revoked: "slack_configuration_token_invalid", not_authed: "slack_configuration_token_invalid", + invalid_manifest: "slack_manifest_invalid", invalid_app: "slack_manifest_invalid", + ratelimited: "slack_setup_rate_limited", no_permission: "slack_setup_permission_denied", + missing_scope: "slack_setup_permission_denied", not_allowed_token_type: "slack_configuration_token_invalid", +}; + +const timeoutMs = 20_000; +export function createSlackSetupClient(fetchImpl: typeof fetch = fetch) { + return async function api(method: string, fields: Record | FormData, bearer?: string, requiredScopes?: readonly string[]) { + const response = await fetchImpl(`https://slack.com/api/${method}`, { + method: "POST", redirect: "error", signal: AbortSignal.timeout(timeoutMs), + headers: { ...(fields instanceof FormData ? {} : { "content-type": "application/x-www-form-urlencoded" }), ...(bearer ? { authorization: `Bearer ${bearer}` } : {}) }, + body: fields instanceof FormData ? fields : new URLSearchParams(fields), + }); + // Bound the streamed response as well as the request lifetime. + const reader = response.body?.getReader(); + const chunks: Uint8Array[] = []; + let size = 0; + if (reader) { + try { + while (true) { + const part = await reader.read(); + if (part.done) break; + size += part.value.byteLength; + if (size > 1_048_576) { await reader.cancel(); throw providerFailure("slack_provider_failure"); } + chunks.push(part.value); + } + } finally { reader.releaseLock(); } + } + let result: Record; + try { result = object(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } + catch { result = {}; } + if (!response.ok || result.ok !== true) { + const code = knownProviderErrors[String(result.error)] ?? (response.status === 429 ? "slack_setup_rate_limited" : "slack_provider_failure"); + const retry = Number(response.headers.get("retry-after")); + if (response.status === 429 || result.error === "ratelimited") { + throw unprocessable(slackRegistrationErrorMessage("slack_setup_rate_limited"), { + code: "slack_setup_rate_limited", retryAfterSeconds: Number.isFinite(retry) && retry > 0 ? Math.ceil(retry) : 60, + }); + } + throw providerFailure(code); + } + if (requiredScopes) { + const granted = new Set((response.headers.get("x-oauth-scopes") ?? "").split(/[, ]+/)); + if (requiredScopes.some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing"); + } + return result; + } +} diff --git a/server/src/services/connectors/slack/setup/registration.ts b/server/src/services/connectors/slack/setup/registration.ts new file mode 100644 index 00000000000..ca44f0e7f50 --- /dev/null +++ b/server/src/services/connectors/slack/setup/registration.ts @@ -0,0 +1,136 @@ +import { notifyChatVerificationWork } from "../../../chat-work-notifications.js"; +import { createAgentAvatarPool } from "../../../agent-avatar-pool.js"; +import type { AgentAvatarRequest } from "../../../agent-avatars.js"; +import { and, eq } from "drizzle-orm"; +import { agents, chatEndpoints, chatSlackRegistrations, toolConnections, type Db } from "@paperclipai/db"; +import { slackRegistrationErrorMessage, type SlackRegistrationState, type SlackAvatarState, type SlackAccountState, isProvisionedSlackSetup } from "@paperclipai/shared"; +import { badRequest, conflict, forbidden, notFound, unprocessable } from "../../../../errors.js"; +import { accessService } from "../../../access.js"; +import { logActivity } from "../../../activity-log.js"; +import { instanceSettingsService } from "../../../instance-settings.js"; +import { secretService } from "../../../secrets.js"; +import { writeConnectionCredential } from "../../../connection-credentials.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; + +export interface SlackSetupActor { userId: string; sessionId: string | null; bypassPermissionCheck: boolean } +export type Registration = typeof chatSlackRegistrations.$inferSelect; + + +export { object, providerFailure, knownProviderErrors } from "./provider-client.js"; +import { object } from "./provider-client.js"; + +export function slackRegistrationProjection(row: Registration): SlackRegistrationState { + return { + status: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "uncertain" : row.status, + appId: row.appId, + ...(row.managerGrantId ? { managerGrantId: row.managerGrantId } : {}), + managementUrl: row.appId ? `https://api.slack.com/apps/${encodeURIComponent(row.appId)}` : "https://api.slack.com/apps", + errorCode: row.status === "creating" && Date.now() - row.updatedAt.getTime() > 60_000 ? "slack_creation_uncertain" : row.errorCode, + }; +} + +import { createSlackSetupClient } from "./provider-client.js"; +export type SlackSetupOptions = { + managed?: { enabled: boolean; broker: import("../../../paperclip-cloud-connector.js").PaperclipCloudConnector }; + publicOrigin: () => string | null; + webhookOrigin: () => string | null; + fetch?: typeof fetch; + renderAvatar?: (request: AgentAvatarRequest) => Promise; + withLock: (endpointId: string, work: (lease: CredentialMutationLeaseGuard) => Promise) => Promise; + runtimeSigningSecret: (endpointId: string) => Promise; + runtimeBotToken: (endpointId: string) => Promise; + canWelcome: (endpointId: string, account: SlackAccountState) => Promise; + linkInstaller: (endpointId: string, user: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; + configure: (endpointId: string, credentials: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise; +}; +export function createSlackRegistrationStore(db: Db, options: SlackSetupOptions) { + const fetchImpl = options.fetch ?? fetch; + const vault = secretService(db); + let avatarPool: ReturnType | undefined; + function origin(value: string | null) { + if (!value) throw badRequest("Configure a public HTTPS URL before creating a Slack app"); + const url = new URL(value); + if (url.protocol !== "https:" || url.username || url.password || url.pathname !== "/" || url.search || url.hash) + throw badRequest("Slack setup requires a configured public HTTPS origin"); + return url.origin; + } + const callbackUri = () => `${origin(options.publicOrigin())}/api/chat-slack/oauth/callback`; + async function registration(endpointId: string) { + return (await db.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, endpointId)))[0]; + } + async function endpoint(endpointId: string, actor: SlackSetupActor) { + if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); + const [row] = await db.select({ endpoint: chatEndpoints, connection: toolConnections, agentName: agents.name, agentAppearance: agents.appearance }) + .from(chatEndpoints).innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId))) + .innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId))) + .where(and(eq(chatEndpoints.id, endpointId), eq(chatEndpoints.provider, "slack"))); + if (!row || row.endpoint.status === "archived" || row.connection.status === "archived") throw notFound("Slack connection not found"); + if (!actor.bypassPermissionCheck && !await accessService(db).hasPermission(row.endpoint.companyId, "user", actor.userId, "tools:manage_connections")) + throw forbidden("Missing permission: tools:manage_connections"); + return row; + } + function assertOrigins(row: Registration, publicId: string) { + if (row.callbackUri !== callbackUri()) throw conflict("The Paperclip URL changed. Restore its configured address or update the Slack app and use manual setup."); + const expected = `${origin(options.webhookOrigin())}/api/chat-webhooks/${publicId}/slack`; + if (object(object(row.manifest.settings).event_subscriptions).request_url !== expected) + throw conflict("The Slack webhook address changed. Restore its configured address or update the Slack app and use manual setup."); + } + async function audit(row: Registration, actor: SlackSetupActor, action: string, code?: string) { + await logActivity(db, { companyId: row.companyId, actorType: "user", actorId: actor.userId, + action: `chat_slack.${action}`, entityType: "chat_endpoint", entityId: row.endpointId, + details: { endpointId: row.endpointId, appId: row.appId, ...(code ? { code } : {}) } }); + } + const api = createSlackSetupClient(fetchImpl); + async function saveSecrets(row: Registration, values: Record, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard, + patch: Partial = {}, account?: SlackAccountState) { + await db.transaction(async tx => { + await lease.assertOwned(tx); + const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; + if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); + const ids = { ...current.secretIds }; + for (const [key, value] of Object.entries(values)) { + const result = await writeConnectionCredential(tx, { companyId: row.companyId, connectionName: "Slack app registration", + configPath: `slack_registration.${key}`, label: key, value, actor: { userId: actor.userId }, + ...(ids[key] ? { existingRef: { secretId: ids[key], configPath: `slack_registration.${key}` } } : {}) }); + ids[key] = result.secret.id; + } + await notifyChatVerificationWork(tx); + await tx.update(chatSlackRegistrations).set({ ...patch, secretIds: ids, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, row.endpointId)); + if (account) { + const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; + if (!saved || saved.status === "archived") throw conflict("Slack setup changed"); + // Reauthorization never changes an established personal account binding. + if (saved.setup.slackAccount?.status !== "linked") await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAccount: account }, updatedAt: new Date() }).where(eq(chatEndpoints.id, row.endpointId)); + } + await lease.assertOwned(tx); + }); + } + async function readSecret(row: Registration, key: string) { + if (!row.secretIds[key]) throw conflict("Slack setup credentials are unavailable. Resume the saved connection."); + return vault.resolveSecretValue(row.companyId, row.secretIds[key], "latest", { + accessContext: { consumerType: "system", consumerId: `slack-registration:${row.endpointId}`, configPath: `slack_registration.${key}`, actorType: "system" }, + }); + } + async function setFailure(row: Registration, status: Registration["status"], code: string, lease: CredentialMutationLeaseGuard) { + await lease.assertOwned(); + await db.update(chatSlackRegistrations).set({ status, errorCode: code, updatedAt: new Date() }) + .where(and(eq(chatSlackRegistrations.endpointId, row.endpointId), eq(chatSlackRegistrations.requestId, row.requestId))); + } + async function saveAvatar(row: Registration, avatar: SlackAvatarState, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) { + await endpoint(row.endpointId, actor); + await db.transaction(async tx => { + await lease.assertOwned(tx); + const current = (await tx.select().from(chatSlackRegistrations).where(eq(chatSlackRegistrations.endpointId, row.endpointId)).for("update"))[0]; + if (!current || current.requestId !== row.requestId || current.status === "removed") throw conflict("Slack setup changed. Resume the saved connection."); + const saved = (await tx.select().from(chatEndpoints).where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))).for("update"))[0]; + if (!saved || saved.status === "archived" || !isProvisionedSlackSetup(saved.setup.slackSetupMethod)) throw conflict("Slack setup changed. Resume the saved connection."); + await tx.update(chatEndpoints).set({ setup: { ...saved.setup, slackAvatar: avatar }, updatedAt: new Date() }) + .where(and(eq(chatEndpoints.id, row.endpointId), eq(chatEndpoints.companyId, row.companyId))); + await lease.assertOwned(tx); + }); + } + const renderAvatar = (request: AgentAvatarRequest) => (options.renderAvatar ?? (avatarPool ??= createAgentAvatarPool()).render)(request); + return { db, options, origin, callbackUri, registration, endpoint, assertOrigins, audit, api, saveSecrets, readSecret, setFailure, saveAvatar, renderAvatar, + close: async () => { await avatarPool?.close(); } }; +} +export type SlackRegistrationStore = ReturnType; diff --git a/server/src/services/connectors/slack/setup/service.ts b/server/src/services/connectors/slack/setup/service.ts new file mode 100644 index 00000000000..f6b61281aea --- /dev/null +++ b/server/src/services/connectors/slack/setup/service.ts @@ -0,0 +1,151 @@ +import { isProvisionedSlackSetup } from "@paperclipai/shared"; +import { createSlackManagerGrants } from "./manager-grants.js"; +import { createManagedSlackSetup } from "./managed.js"; +import { randomBytes } from "node:crypto"; +import { and, eq, gt, like } from "drizzle-orm"; +import { chatEndpoints, chatSlackRegistrations, companies, toolOauthStates, type Db } from "@paperclipai/db"; +import { badRequest, conflict, forbidden, notFound } from "../../../../errors.js"; +import { removeSlackRegistration } from "../../../chat-slack-registration-cleanup.js"; +import type { CredentialMutationLeaseGuard } from "../../../chat-credential-mutation-lease.js"; +import { object, providerFailure, createSlackRegistrationStore, type SlackSetupOptions, type SlackSetupActor } from "./registration.js"; +import { createCustomerOwnedSetup } from "./customer-owned.js"; +import { createSlackCompletion } from "./completion.js"; +const prefix = "slack-install."; +export function slackChatRegistrationService(db: Db, options: SlackSetupOptions) { + const store = createSlackRegistrationStore(db, options); + const { endpoint, registration, assertOrigins, callbackUri, audit, api, readSecret, setFailure, saveSecrets } = store; + const provisioner = createCustomerOwnedSetup(store); + const { create } = provisioner; + const completion = createSlackCompletion(store); + const { resumeLocked, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt } = completion; + const managerGrants = createSlackManagerGrants(store); + const managed = createManagedSlackSetup(store, managerGrants, provisioner, completion, install); + async function install(endpointId: string, actor: SlackSetupActor) { + return options.withLock(endpointId, async lease => { + const current = await endpoint(endpointId, actor); + const row = await registration(endpointId); + if (!row?.appId || !row.clientId || row.status === "removed") throw conflict("Create this Slack app before installing it"); + if (row.errorCode === "slack_manifest_update_pending") throw conflict("Finish configuring the saved Slack app before installing it"); + if (!isProvisionedSlackSetup(current.endpoint.setup.slackSetupMethod) || current.endpoint.status === "paused") throw conflict("Resume this connection before installing it"); + if (row.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor); + assertOrigins(row, current.endpoint.publicId); + const state = `${prefix}${randomBytes(32).toString("base64url")}`; + const expiresAt = new Date(Date.now() + 10 * 60_000); + const scopes = object(object(row.manifest.oauth_config).scopes).bot as string[]; + await db.transaction(async tx => { + await lease.assertOwned(tx); + await tx.delete(toolOauthStates).where(and(eq(toolOauthStates.connectionId, current.connection.id), like(toolOauthStates.state, `${prefix}%`))); + await tx.insert(toolOauthStates).values({ state, companyId: row.companyId, connectionId: current.connection.id, + codeVerifier: JSON.stringify({ endpointId, requestId: row.requestId, appId: row.appId, callbackUri: row.callbackUri, + endpointStatus: current.endpoint.status, runtimeGeneration: object(current.endpoint.setup).runtimeGeneration ?? 0 }), + createdByActorType: "user", createdByActorId: actor.userId, createdBySessionId: actor.sessionId, + requestedScopes: scopes, expiresAt }); + await tx.update(chatSlackRegistrations).set({ errorCode: row.managerGrantId ? row.errorCode : null, updatedAt: new Date() }).where(eq(chatSlackRegistrations.endpointId, endpointId)); + }); + const url = new URL("https://slack.com/oauth/v2/authorize"); + url.search = new URLSearchParams({ client_id: row.clientId, scope: scopes.join(","), state, redirect_uri: row.callbackUri, + ...(row.workspaceId ? { team: row.workspaceId } : {}) }).toString(); + await audit(row, actor, "installation_started"); + return { authorizationUrl: url.toString(), expiresAt: expiresAt.toISOString() }; + }); + } + async function expiredReturn(state: string, actor: SlackSetupActor) { + if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) return null; + const [attempt] = await db.select().from(toolOauthStates).where(eq(toolOauthStates.state, state)); + if (!attempt || attempt.expiresAt.getTime() > Date.now() || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) return null; + const binding = object(JSON.parse(attempt.codeVerifier)); + const current = await endpoint(String(binding.endpointId), actor); + return options.withLock(current.endpoint.id, async lease => { + const latest = await endpoint(current.endpoint.id, actor); + const row = await registration(current.endpoint.id); + if (!row || row.status === "removed" || row.requestId !== binding.requestId || row.companyId !== attempt.companyId || latest.connection.id !== attempt.connectionId + || !isProvisionedSlackSetup(latest.endpoint.setup.slackSetupMethod) || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri) return null; + assertOrigins(row, latest.endpoint.publicId); + await lease.assertOwned(); + await db.delete(toolOauthStates).where(eq(toolOauthStates.state, state)); + await setFailure(row, row.status, "slack_install_expired", lease); + await audit(row, actor, "installation_failed", "slack_install_expired"); + return returnPath(row.endpointId); + }); + } + async function pending(state: string, actor: SlackSetupActor) { + if (!/^slack-install\.[A-Za-z0-9_-]{43}$/.test(state)) throw badRequest("Invalid Slack authorization state"); + const [attempt] = await db.select().from(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))); + if (!attempt || attempt.createdByActorId !== actor.userId || attempt.createdBySessionId !== actor.sessionId) + throw forbidden("Slack authorization expired or belongs to another session. Resume setup and install again."); + const binding = object(JSON.parse(attempt.codeVerifier)); + const current = await endpoint(String(binding.endpointId), actor); + const row = await registration(current.endpoint.id); + if (!row || row.status === "removed" || !isProvisionedSlackSetup(current.endpoint.setup.slackSetupMethod) + || current.endpoint.status === "paused" + || current.endpoint.status === "revoked" && binding.endpointStatus !== "revoked" + || binding.runtimeGeneration !== (object(current.endpoint.setup).runtimeGeneration ?? 0) + || row.requestId !== binding.requestId || row.appId !== binding.appId || row.callbackUri !== binding.callbackUri + || row.callbackUri !== callbackUri() || attempt.companyId !== row.companyId || attempt.connectionId !== current.connection.id) + throw conflict("Slack setup changed. Resume the saved connection and install again."); + if (row.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor); + assertOrigins(row, current.endpoint.publicId); + return { attempt, current, row }; + } + async function complete(state: string, code: string | null, error: string | null, actor: SlackSetupActor) { + const before = await pending(state, actor); + return options.withLock(before.row.endpointId, async lease => { + const { row, attempt, current } = await pending(state, actor); + const [claimed] = await db.delete(toolOauthStates).where(and(eq(toolOauthStates.state, state), gt(toolOauthStates.expiresAt, new Date()))).returning(); + if (!claimed) throw conflict("This Slack authorization was already used"); + if (error) { + const failureCode = error === "access_denied" ? "slack_install_declined" : "slack_install_failed"; + await setFailure(row, row.status, failureCode, lease); + await audit(row, actor, "installation_failed", failureCode); + return row.endpointId; + } + try { + if (!code || code.length > 4096) throw providerFailure("slack_install_failed"); + const result = await api("oauth.v2.access", { code, client_id: row.clientId!, client_secret: await readSecret(row, "clientSecret"), redirect_uri: row.callbackUri }); + const teamId = object(result.team).id; + const installerId = object(result.authed_user).id; + if (result.app_id !== row.appId || result.token_type !== "bot" || typeof result.access_token !== "string" || !result.access_token.startsWith("xoxb-") + || typeof teamId !== "string" || !/^T[A-Z0-9]+$/.test(teamId) || typeof result.bot_user_id !== "string" || !/^[UW][A-Z0-9]+$/.test(result.bot_user_id) + || row.workspaceId && row.workspaceId !== teamId || row.botUserId && row.botUserId !== result.bot_user_id) + throw providerFailure("slack_install_identity_mismatch"); + const granted = new Set(String(result.scope ?? "").split(",")); + if ((attempt.requestedScopes ?? []).some(scope => !granted.has(scope))) throw providerFailure("slack_install_scopes_missing"); + if (current.endpoint.providerAccountId && current.endpoint.providerAccountId !== teamId + || current.endpoint.botExternalId && current.endpoint.botExternalId !== result.bot_user_id) + throw providerFailure("slack_install_identity_mismatch"); + if (typeof installerId !== "string" || !/^[UW][A-Z0-9]+$/.test(installerId) || installerId === result.bot_user_id) throw providerFailure("slack_install_account_missing"); + if (row.managerGrantId) { + const grant = await managerGrants.get(row.managerGrantId, row.companyId, actor); + if (grant.status !== "active" || installerId !== grant.slackUserId || teamId !== grant.workspaceId) throw providerFailure("slack_install_identity_mismatch"); + } + await endpoint(row.endpointId, actor); + // Reauthorization reuses the runtime signing secret after staging was cleaned. + await saveSecrets(row, { botToken: result.access_token }, actor, lease, + { status: "credentials_saved", workspaceId: teamId, botUserId: result.bot_user_id, errorCode: null }, + { externalUserId: installerId, paperclipUserId: actor.userId, status: "pending", welcomeStatus: "pending" }); + } catch (failure) { + const code = object(object(failure).details).code; + const safeCode = typeof code === "string" && code.startsWith("slack_install_") ? code : "slack_install_failed"; + await setFailure(row, row.status, safeCode, lease); + await audit(row, actor, "installation_failed", safeCode); + return row.endpointId; + } + await resumeLocked(row.endpointId, actor, lease); + return row.endpointId; + }); + } + const cleanup = (endpointId: string, lease: CredentialMutationLeaseGuard) => removeSlackRegistration(db, endpointId, lease); + async function returnPath(endpointId: string) { + const [row] = await db.select({ prefix: companies.issuePrefix }).from(chatEndpoints) + .innerJoin(companies, eq(companies.id, chatEndpoints.companyId)).where(eq(chatEndpoints.id, endpointId)); + if (!row) throw notFound("Slack connection not found"); + return `/${encodeURIComponent(row.prefix)}/apps/chat/connect?provider=slack&resume=${encodeURIComponent(endpointId)}`; + } + return { create, managerGrants, managed, install, pending, expiredReturn, complete, cleanup, returnPath, registration, notifyVerified, processPendingVerificationMessages, nextVerificationMessageAt, + close: store.close, + resume: (endpointId: string, actor: SlackSetupActor) => options.withLock(endpointId, async lease => { + const row = await registration(endpointId); + if (row?.managerGrantId) await managerGrants.token(row.managerGrantId, row.companyId, actor); + return resumeLocked(endpointId, actor, lease); + }) }; +} diff --git a/server/src/services/connectors/slack/test-message.ts b/server/src/services/connectors/slack/test-message.ts new file mode 100644 index 00000000000..47996f0acf5 --- /dev/null +++ b/server/src/services/connectors/slack/test-message.ts @@ -0,0 +1,134 @@ +import { and, eq, gte, inArray, ne } from "drizzle-orm"; +import { agents, chatActions, chatEndpoints, chatExternalPrincipals, chatIdentityLinks, chatSlackManagerGrants, companyMemberships, toolConnections, type Db } from "@paperclipai/db"; +import { slackTestMessageSchema, type SlackTestMessageInput, type SlackTestMessageResult } from "@paperclipai/shared"; +import { conflict, forbidden, notFound, tooManyRequests, unprocessable } from "../../../errors.js"; +import { logger } from "../../../middleware/logger.js"; +import { logActivity } from "../../activity-log.js"; +import { instanceSettingsService } from "../../instance-settings.js"; +import { createSlackSetupClient, object } from "./setup/provider-client.js"; +import type { SlackSetupActor, SlackSetupOptions } from "./setup/registration.js"; + +type Options = Pick & { + ensureAccount: (endpointId: string, user: Record) => Promise; + createLinkIntent: (endpointId: string, principalId: string, expiresInSeconds: number) => Promise<{ confirmationUrl: string; expiresAt: string }>; +}; +const human = (user: Record, workspaceId: string) => + typeof user.id === "string" && /^[UW][A-Z0-9]+$/.test(user.id) && user.team_id === workspaceId && + user.is_bot === false && user.is_app_user !== true && user.deleted !== true && user.id !== "USLACKBOT"; + +export function slackTestMessageService(db: Db, options: Options) { + const api = createSlackSetupClient(options.fetch); + return async (endpointId: string, actor: SlackSetupActor, rawInput: SlackTestMessageInput): Promise => { + const input = slackTestMessageSchema.parse(rawInput); + if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) throw forbidden("Chat connectors are disabled"); + return options.withLock(endpointId, async lease => { + const [record] = await db.select({ endpoint: chatEndpoints, agentName: agents.name }).from(chatEndpoints) + .innerJoin(agents, and(eq(agents.id, chatEndpoints.assignedAgentId), eq(agents.companyId, chatEndpoints.companyId))) + .innerJoin(toolConnections, and(eq(toolConnections.id, chatEndpoints.connectionId), eq(toolConnections.companyId, chatEndpoints.companyId), eq(toolConnections.enabled, true), eq(toolConnections.status, "active"))) + .where(eq(chatEndpoints.id, endpointId)); + if (!record || record.endpoint.provider !== "slack") throw notFound("Active Slack connection not found"); + const endpoint = record.endpoint; + if (!["active", "verifying"].includes(endpoint.status) || !endpoint.providerAccountId || !endpoint.allowDirectMessages) throw conflict("Enable direct messages on an active Slack connection first"); + const [membership] = await db.select({ id: companyMemberships.id }).from(companyMemberships).where(and( + eq(companyMemberships.companyId, endpoint.companyId), eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, actor.userId), eq(companyMemberships.status, "active"), ne(companyMemberships.membershipRole, "viewer"), + )); + if (!membership) throw forbidden("An active Paperclip account is required"); + const actionKey = `slack_test_message:${actor.userId}:${input.requestId}`; + const [previous] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), eq(chatActions.providerActionId, actionKey))); + if (previous) { + if (previous.status === "processed" && typeof previous.result?.slackUrl === "string") return { status: "sent", slackUrl: previous.result.slackUrl }; + throw conflict("This message may already have been sent. Check your Slack DMs before sending another."); + } + const recent = await db.select({ id: chatActions.id }).from(chatActions).where(and( + eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), inArray(chatActions.kind, ["slack_test_message", "slack_test_message_invitation"]), gte(chatActions.createdAt, new Date(Date.now() - 60_000)), + )).limit(5); + if (recent.length >= 5) throw tooManyRequests("Wait a minute before sending another test message"); + const links = await db.select({ endpointId: chatIdentityLinks.endpointId, slackUserId: chatExternalPrincipals.externalId }).from(chatIdentityLinks) + .innerJoin(chatExternalPrincipals, and(eq(chatExternalPrincipals.id, chatIdentityLinks.principalId), eq(chatExternalPrincipals.companyId, chatIdentityLinks.companyId))) + .where(and(eq(chatIdentityLinks.companyId, endpoint.companyId), eq(chatIdentityLinks.paperclipUserId, actor.userId), eq(chatIdentityLinks.status, "linked"), + eq(chatExternalPrincipals.provider, "slack"), eq(chatExternalPrincipals.providerAccountId, endpoint.providerAccountId), eq(chatExternalPrincipals.isBot, false))); + const current = links.filter(link => link.endpointId === endpointId); + const grants = await db.select({ slackUserId: chatSlackManagerGrants.slackUserId }).from(chatSlackManagerGrants).where(and( + eq(chatSlackManagerGrants.companyId, endpoint.companyId), eq(chatSlackManagerGrants.userId, actor.userId), eq(chatSlackManagerGrants.workspaceId, endpoint.providerAccountId), eq(chatSlackManagerGrants.status, "active"), + )); + const known = [...new Set((current.length ? current : [...links, ...grants]).map(link => link.slackUserId))]; + const [invitation] = await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpointId), eq(chatActions.companyId, endpoint.companyId), eq(chatActions.providerActionId, `${actionKey}:invite`))); + const invitedUser = typeof invitation?.payload.slackUserId === "string" ? invitation.payload.slackUserId : undefined; + if (invitedUser && input.slackUserId && input.slackUserId !== invitedUser) throw conflict("This request already invited a different Slack account"); + const userId = input.slackUserId ?? invitedUser ?? (known.length === 1 ? known[0] : undefined); + const token = await options.runtimeBotToken(endpointId); + if (!token) throw conflict("Reconnect this Slack app before sending a message"); + async function call(method: string, fields: Record) { + try { return await api(method, fields, token); } + catch { throw unprocessable("Slack could not complete this request. Check the connection and try again."); } + } + if (!userId) { + const accounts: { id: string; name: string }[] = []; + const result = await call("users.list", { limit: "200", ...(input.accountCursor ? { cursor: input.accountCursor } : {}) }); + for (const value of Array.isArray(result.members) ? result.members : []) { + const user = object(value); + if (human(user, endpoint.providerAccountId)) accounts.push({ id: String(user.id), name: String(user.real_name || user.name || user.id) }); + } + const cursor = object(result.response_metadata).next_cursor; + return { status: "connection_required", accounts, nextCursor: typeof cursor === "string" && cursor ? cursor : null }; + } + const user = object((await call("users.info", { user: userId })).user); + if (user.id !== userId || !human(user, endpoint.providerAccountId)) throw unprocessable("Choose your personal Slack account in this workspace"); + if (!known.includes(userId)) { + const principalId = await options.ensureAccount(endpointId, user); + const principalLinks = await db.select().from(chatIdentityLinks).where(and(eq(chatIdentityLinks.companyId, endpoint.companyId), eq(chatIdentityLinks.principalId, principalId))); + if (principalLinks.some(link => link.status === "linked" && link.paperclipUserId !== actor.userId) + || principalLinks.some(link => link.endpointId === endpointId && link.status === "revoked")) throw conflict("This Slack account has an existing link"); + if (invitation) { + if (invitation.status !== "processed") throw conflict("The invitation may already have been sent. Check Slack before requesting another."); + const expiresAt = String(invitation.result?.expiresAt ?? ""); + if (!expiresAt || new Date(expiresAt).getTime() <= Date.now()) throw conflict("The Slack invitation expired. Request a new test message."); + return { status: "connection_pending", principalId, expiresAt }; + } + // The account holder must follow the private Slack challenge and sign in. + // Selecting a workspace member does not prove identity ownership. + const intent = await options.createLinkIntent(endpointId, principalId, 600); + const [pending] = await db.insert(chatActions).values({ companyId: endpoint.companyId, endpointId, principalId, + kind: "slack_test_message_invitation", providerActionId: `${actionKey}:invite`, status: "processing", payload: { userId: actor.userId, slackUserId: userId } }).returning(); + try { + const response = await call("chat.postMessage", { channel: userId, + text: `Connect your Slack account to ${record.agentName} in Paperclip to receive your test message.`, + blocks: JSON.stringify([{ type: "section", text: { type: "plain_text", text: `Connect your Slack account to ${record.agentName} in Paperclip.` } }, + { type: "section", text: { type: "mrkdwn", text: `<${intent.confirmationUrl}|Connect my account> to receive your test message.` } }]), + unfurl_links: "false", unfurl_media: "false" }); + if (typeof response.channel !== "string" || !/^D[A-Z0-9]+$/.test(response.channel) || typeof response.ts !== "string") throw unprocessable("Slack did not confirm invitation delivery"); + await lease.assertOwned(); + await db.update(chatActions).set({ status: "processed", result: { expiresAt: intent.expiresAt }, updatedAt: new Date() }).where(eq(chatActions.id, pending!.id)); + } catch { + await db.update(chatActions).set({ status: "uncertain", result: { code: "slack_invitation_unconfirmed" }, updatedAt: new Date() }).where(eq(chatActions.id, pending!.id)); + throw unprocessable("Invitation delivery was not confirmed. Check Slack before requesting another."); + } + try { await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_invited", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); } + catch { logger.warn({ endpointId }, "Slack test invitation activity could not be recorded"); } + return { status: "connection_pending", principalId, expiresAt: intent.expiresAt }; + } + await options.linkInstaller(endpointId, user, actor, lease); + await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_requested", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); + await lease.assertOwned(); + // Persist before dispatch: a crash or ambiguous response must never replay the DM. + const [action] = await db.insert(chatActions).values({ companyId: endpoint.companyId, endpointId, kind: "slack_test_message", providerActionId: actionKey, + status: "processing", payload: { userId: actor.userId, slackUserId: userId } }).returning(); + let slackUrl: string; + try { + const response = await call("chat.postMessage", { channel: userId, + text: `Hi, I’m ${record.agentName}. This is your test message. Reply here to talk to me.`, unfurl_links: "false", unfurl_media: "false" }); + if (typeof response.channel !== "string" || !/^D[A-Z0-9]+$/.test(response.channel) || typeof response.ts !== "string") throw unprocessable("Slack did not confirm delivery. Check your DMs before trying again."); + slackUrl = `https://app.slack.com/client/${encodeURIComponent(endpoint.providerAccountId)}/${response.channel}`; + await lease.assertOwned(); + await db.update(chatActions).set({ status: "processed", result: { slackUrl }, updatedAt: new Date() }).where(eq(chatActions.id, action!.id)); + } catch { + await db.update(chatActions).set({ status: "uncertain", result: { code: "slack_test_message_unconfirmed" }, updatedAt: new Date() }).where(eq(chatActions.id, action!.id)); + throw unprocessable("Delivery was not confirmed. Check your Slack DMs before sending another test message."); + } + try { await logActivity(db, { companyId: endpoint.companyId, actorType: "user", actorId: actor.userId, action: "chat_slack.test_message_sent", entityType: "tool_connection", entityId: endpoint.connectionId, details: { endpointId, slackUserId: userId } }); } + catch { logger.warn({ endpointId }, "Slack test message activity could not be recorded"); } + return { status: "sent", slackUrl }; + }); + }; +} diff --git a/server/src/services/paperclip-cloud-connector.test.ts b/server/src/services/paperclip-cloud-connector.test.ts index 42e8a3cd4bb..a2a0b37abe0 100644 --- a/server/src/services/paperclip-cloud-connector.test.ts +++ b/server/src/services/paperclip-cloud-connector.test.ts @@ -7,6 +7,7 @@ import { randomBytes, type KeyObject, } from "node:crypto"; +import { SLACK_MANAGER_SCOPES } from "@paperclipai/shared"; import { describe, expect, it, vi } from "vitest"; import { readFileSync } from "node:fs"; @@ -406,6 +407,16 @@ describe("Paperclip Cloud connector", () => { })).rejects.toMatchObject({ code: "CONNECTOR_BAD_RESPONSE" }); }); + it("opens Slack manager handoffs with their exact profile and personal identity", async () => { + const keys = config(); + const credentials = { v: 1, accessToken: "manager-access-canary", refreshToken: "manager-refresh-canary", tokenType: "user", accessTokenExpiresAt: "2030-01-01T00:00:00.000Z", refreshTokenExpiresAt: null, + scopes: [...SLACK_MANAGER_SCOPES], subject, companyId, instanceId, environment: "staging", provider: "slack", profile: "slack.manager", + slackManager: { appId: "AMANAGER", workspaceId: "TWORKSPACE", workspaceName: "Paperclip", userId: "UOWNER" } }; + const sealed = seal(credentials, keys.sealPublicKey, "initial", keys.config, "slack.manager"); + const connector = createPaperclipCloudConnector({ config: keys.config, request: vi.fn(async () => Response.json({ claimId: "claim", scopes: [...SLACK_MANAGER_SCOPES], sealed })) as typeof fetch }); + await expect(connector.claim({ subject, companyId, profile: "slack.manager", claimId: "claim", redemptionId: "manager-state" })).resolves.toEqual(credentials); + await expect(connector.claim({ subject: "another-user", companyId, profile: "slack.manager", claimId: "claim", redemptionId: "manager-state" })).rejects.toThrow(); + }); it("opens an instance-sealed claim and verifies its user, company, and exact scopes", async () => { const keys = config(); const credentials = { @@ -612,10 +623,10 @@ function seal( recipientPublicKey: KeyObject, purpose: "initial" | "access" | "events", configValue: PaperclipCloudConnectorConfig, - profile: keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES | "github.bot" | "github.code", + profile: keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES | "slack.manager" | "github.bot" | "github.code", ) { - const provider = profile.startsWith("github.") ? "github" : "google"; - const scopes = provider === "github" ? [] : GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile as keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES].scopes; + const provider = profile.startsWith("github.") ? "github" : profile === "slack.manager" ? "slack" : "google"; + const scopes = provider === "github" ? [] : profile === "slack.manager" ? SLACK_MANAGER_SCOPES : GOOGLE_WORKSPACE_CONNECTOR_PROFILES[profile as keyof typeof GOOGLE_WORKSPACE_CONNECTOR_PROFILES].scopes; const ephemeral = generateKeyPairSync("x25519"); const ephemeralJwk = ephemeral.publicKey.export({ format: "jwk" }) as { x: string }; const recipientJwk = recipientPublicKey.export({ format: "jwk" }) as { x: string }; diff --git a/server/src/services/paperclip-cloud-connector.ts b/server/src/services/paperclip-cloud-connector.ts index 7785deeb596..241ff937b57 100644 --- a/server/src/services/paperclip-cloud-connector.ts +++ b/server/src/services/paperclip-cloud-connector.ts @@ -1,4 +1,4 @@ -import { ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; +import { SLACK_MANAGER_SCOPES, ASANA_CONNECTOR_SCOPES, isAsanaConnectorProfileId, type AsanaConnectorProfileId } from "@paperclipai/shared"; import { createDecipheriv, createHash, @@ -34,8 +34,8 @@ export { GOOGLE_WORKSPACE_CONNECTOR_PROFILES }; export type PaperclipCloudConnectorEnvironment = "development" | "staging" | "production"; export type PaperclipCloudConnectorOperation = "status" | "session" | "claim" | "refresh" | "revoke" | "webhook-bind" | "event-lease" | "event-ack" | "github-app"; -export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "github.bot"; -export type PaperclipCloudConnectorProvider = "google" | "github" | "asana"; +export type PaperclipCloudConnectorProfileId = GoogleWorkspaceConnectorProfileId | GitHubConnectorProfileId | AsanaConnectorProfileId | "slack.manager" | "github.bot"; +export type PaperclipCloudConnectorProvider = "google" | "github" | "asana" | "slack"; export type PaperclipCloudConnectorConfig = { baseUrl: string; @@ -60,6 +60,7 @@ export type SealedConnectorCredentials = { provider: PaperclipCloudConnectorProvider; profile: string; appSlug?: string; + slackManager?: { appId: string; workspaceId: string; workspaceName: string; userId: string }; }; export type SealedGmailCredentials = SealedConnectorCredentials & { provider: "google" }; @@ -95,6 +96,7 @@ type SealedEnvelope = { }; type ConnectorResponse = { + slackManager?: unknown; confirmationUrl?: unknown; authorizationUrl?: unknown; handoff?: unknown; @@ -413,6 +415,13 @@ export function createPaperclipCloudConnector(input: { typeof value === "string" && isPaperclipCloudConnectorProfileId(value) ? [value] : [] ))]; }, + async getSlackManagerAppId(): Promise { + const response = await call("status", { subject: "instance-capabilities", companyId: "instance-capabilities" }); + const manager = response.slackManager; + if (response.active !== true || response.status !== "active" || !Array.isArray(response.profiles) || !response.profiles.includes("slack.manager") + || !manager || typeof manager !== "object" || !("appId" in manager) || typeof manager.appId !== "string" || !/^A[A-Z0-9]+$/.test(manager.appId)) return null; + return manager.appId; + }, async startAuthorization(values: { subject: string; companyId: string; profile?: PaperclipCloudConnectorProfileId; returnUri: string; returnState: string }) { const profile = values.profile ?? "gmail.draft"; const response = await call("session", { ...values, profile }); @@ -782,6 +791,7 @@ function connectorProfileDefinition(profile: PaperclipCloudConnectorProfileId): provider: PaperclipCloudConnectorProvider; scopes: readonly string[]; } { + if (profile === "slack.manager") return { provider: "slack", scopes: SLACK_MANAGER_SCOPES }; if (profile === "github.bot") return { provider: "github", scopes: [] }; if (isAsanaConnectorProfileId(profile)) { return { provider: "asana", scopes: ASANA_CONNECTOR_SCOPES }; @@ -796,6 +806,7 @@ function isExpectedProviderAuthorizationUrl( profile: PaperclipCloudConnectorProfileId, url: URL, ): boolean { + if (profile === "slack.manager") return url.origin === "https://slack.com" && url.pathname === "/oauth/v2/authorize"; if (isAsanaConnectorProfileId(profile)) { return url.origin === "https://app.asana.com" && url.pathname === "/-/oauth_authorize"; } @@ -806,7 +817,7 @@ function isExpectedProviderAuthorizationUrl( } function isPaperclipCloudConnectorProfileId(value: string): value is PaperclipCloudConnectorProfileId { - return isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value); + return value === "slack.manager" || isGoogleWorkspaceConnectorProfileId(value) || isGitHubConnectorProfileId(value) || isAsanaConnectorProfileId(value); } async function sha256Base64Url(value: string): Promise { diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts index c64cb950ca8..a7795120a94 100644 --- a/tests/e2e/chat-adapters-ui-providers.spec.ts +++ b/tests/e2e/chat-adapters-ui-providers.spec.ts @@ -156,6 +156,114 @@ test.describe.serial("native chat adapter UI", () => { seed = await seedCompanyAndAgent(request); }); + test("Slack: managed workspace setup has two stops, resumes and completes without pasting credentials", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat`); + await selectMaya(page); + await expect(page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button")).toHaveCount(2); + await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible(); + await expect(page.getByLabel("App configuration access token", { exact: true })).toHaveCount(0); + await expect(page.getByLabel("Signing Secret", { exact: true })).toHaveCount(0); + await page.reload(); + expect(mock.slackCreations).toBe(1); expect(mock.slackInstallations).toBe(1); + mock.setWebhookVerified(); + await expect(page.getByRole("heading", { name: "Success", exact: true })).toBeVisible(); + await expect(page.getByRole("navigation", { name: "Connection setup progress" }).getByRole("button")).toHaveCount(2); + await page.getByRole("button", { name: "Done", exact: true }).click(); + await expect(page).toHaveURL(/\/apps\/chat\/endpoint-slack\/settings$/); + }); + test("Slack: a failed manager refresh reloads workspace authorization before retry", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + let refreshFailed = false; + let authorizationRequests = 0; + await page.route("**/chat-slack/setup-options", async route => { + if (!refreshFailed) return route.fallback(); + await route.fulfill({ json: { managedAvailable: true, defaultMethod: "managed", workspaces: [] } }); + }); + await page.route("**/slack/managed/provision", async route => { + refreshFailed = true; + await route.fulfill({ status: 422, json: { error: "Reconnect your Slack workspace.", details: { code: "slack_manager_reauthorize" } } }); + }); + await page.route("**/slack/managed/authorize", async route => { + authorizationRequests++; + await route.fulfill({ json: { authorizationUrl: "https://slack.test/manager-consent", expiresAt: new Date(Date.now() + 600_000).toISOString() } }); + }); + await page.route("https://slack.test/manager-consent", route => route.fulfill({ contentType: "text/html", body: "Slack authorization fixture" })); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + await expect(page.getByRole("alert")).toBeVisible(); + await page.getByRole("button", { name: "Continue", exact: true }).click(); + await expect.poll(() => authorizationRequests).toBe(1); + await expect(page).toHaveURL("https://slack.test/manager-consent"); + }); + test("Slack: every uncertain creation retry needs a fresh confirmation", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + mock.setSlackUncertain(); + const attempts: { requestId: string; confirmedNoAppCreated?: boolean }[] = []; + await page.route("**/slack/managed/provision", async route => { + attempts.push(route.request().postDataJSON()); + await route.fulfill({ json: {} }); + }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + const confirm = page.getByRole("checkbox", { name: "I checked Slack and no app was created. Create a new app." }); + const create = page.getByRole("button", { name: "Continue", exact: true }); + await expect(create).toBeDisabled(); + await confirm.check(); await create.click(); + await expect(confirm).not.toBeChecked(); await expect(create).toBeDisabled(); + expect(attempts).toHaveLength(1); expect(attempts[0]!.confirmedNoAppCreated).toBe(true); + await confirm.check(); await create.click(); + await expect(confirm).not.toBeChecked(); await expect(create).toBeDisabled(); + expect(attempts).toHaveLength(2); expect(attempts[1]!.requestId).not.toBe(attempts[0]!.requestId); + }); + test("Slack: own-app creation consumes confirmation even when the response is lost", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, automaticSlack: true }); + mock.setSlackUncertain(); + let attempts = 0; + await page.route("**/slack/registration", async route => { + attempts++; expect(route.request().postDataJSON().confirmedNoAppCreated).toBe(true); + await route.fulfill({ status: 502, json: { error: "Creation response unavailable" } }); + }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + const confirm = page.getByRole("checkbox"); + await confirm.check(); + await page.getByLabel("App configuration access token", { exact: true }).fill("fixture-config-token"); + await page.getByRole("button", { name: "Create Slack app", exact: true }).click(); + await expect(page.getByRole("alert")).toBeVisible(); + await expect(confirm).not.toBeChecked(); + await expect(page.getByRole("button", { name: "Create Slack app", exact: true })).toBeDisabled(); + expect(attempts).toBe(1); + }); + test("Slack: temporarily unavailable managed setup can retry in place", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + let available = false; + await page.route("**/chat-slack/setup-options", async route => { + if (available) return route.fallback(); + await route.fulfill({ json: { managedAvailable: false, defaultMethod: "automatic", workspaces: [] } }); + }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat&resume=endpoint-slack`); + await expect(page.getByText("Managed Slack setup is unavailable. Try again later.")).toBeVisible(); + available = true; await page.getByRole("button", { name: "Retry", exact: true }).click(); + await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible(); + }); + test("Slack: own-app selection persists before provisioning", async ({ page }) => { + const slack = PROVIDERS.find(provider => provider.provider === "slack")!; + await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, managedSlack: true }); + await page.goto(`/${seed.prefix}/apps/chat/connect?provider=slack&purpose=chat`); + await page.getByRole("button", { name: "Choose an active agent" }).click(); + await page.getByRole("button", { name: "Select Maya" }).click(); + await page.getByText("Advanced", { exact: true }).click(); + await page.getByRole("button", { name: "Use your own app" }).click(); + await expect(page.getByRole("heading", { name: "App configuration access token", exact: true })).toBeVisible(); + await page.reload(); + await expect(page.getByRole("heading", { name: "App configuration access token", exact: true })).toBeVisible(); + await page.getByText("Advanced", { exact: true }).click(); + await page.getByRole("button", { name: "Use managed setup" }).click(); + await expect(page.getByRole("heading", { name: "Send a message to your agent", exact: true })).toBeVisible(); + }); test("Slack: automatic creation survives refresh, consent, and connection evidence without copying durable secrets", async ({ page }) => { const slack = PROVIDERS.find(provider => provider.provider === "slack")!; const mock = await installChatControlPlaneMock(page, slack, seed, { enableChatConnectors: true, automaticSlack: true }); @@ -440,6 +548,10 @@ test.describe.serial("native chat adapter UI", () => { ).toBeVisible(); await expectSetupRail(page, true); await selectMaya(page); + if (provider.provider === "slack") { + await page.getByText("Advanced", { exact: true }).click(); + await page.getByRole("button", { name: "Create manually", exact: true }).click(); + } await expect.poll(() => mock.createdWithAgentId).toBe(seed.agentId); expect(mock.createdWithAgentId).not.toBe(seed.otherAgentId); await expect( @@ -573,18 +685,12 @@ test.describe.serial("native chat adapter UI", () => { page.getByRole("heading", { name: provider.provider === "slack" ? "Allowed Channels" : "Destinations" }), ).toBeVisible(); if (provider.provider === "slack") { - await page.locator("summary").filter({ hasText: "Message in a channel" }).click(); - await expect(page.getByText("@maya-paperclip you there?", { exact: true })).toBeVisible(); - await expect(page.getByRole("button", { name: "Copy message" })).toBeVisible(); await expect(page.getByRole("heading", { name: "Allowed Channels" })).toBeVisible(); - await page.locator("summary").filter({ hasText: /^Agent avatar$/ }).click(); - const avatarSection = page.getByRole("region", { name: "Slack avatar" }); - await expect(avatarSection.getByRole("link", { name: "Download avatar" })).toBeVisible(); - await avatarSection.getByText("How to upload in Slack", { exact: true }).click(); - await expect(avatarSection.getByRole("link", { name: "Open Slack app Settings" })).toBeVisible(); - const settingsDownloadEvent = page.waitForEvent("download"); - await avatarSection.getByRole("link", { name: "Download avatar" }).click(); - expect((await settingsDownloadEvent).suggestedFilename()).toBe("maya-paperclip-avatar.png"); + await expect(page.getByRole("button", { name: "Send me a test message" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Open Slack configuration" })).toBeVisible(); + await expect(page.getByText("Message in a channel", { exact: true })).toHaveCount(0); + await expect(page.getByText("Agent avatar", { exact: true })).toHaveCount(0); + await expect(page.getByRole("link", { name: "View message" })).toHaveCount(0); } await expect( diff --git a/tests/e2e/chat-adapters-ui.shared.ts b/tests/e2e/chat-adapters-ui.shared.ts index e8904fc98ed..afa8a9686b9 100644 --- a/tests/e2e/chat-adapters-ui.shared.ts +++ b/tests/e2e/chat-adapters-ui.shared.ts @@ -281,7 +281,7 @@ export function endpointFixture(provider: ProviderCase, seed: Seed) { proactiveDirectMessages: false, }, setup: { - slackSetupMethod: undefined as "automatic" | "manual" | "existing" | undefined, + slackSetupMethod: undefined as "managed" | "automatic" | "manual" | "existing" | undefined, slackRegistration: undefined as SlackRegistrationState | undefined, slackApp: undefined as SlackAppConfiguration | undefined, slackOAuthCallbackUri: "https://paperclip.example.test/api/chat-slack/oauth/callback", @@ -353,10 +353,11 @@ export async function installChatControlPlaneMock( resourceCount = 2, photonShared = false, automaticSlack = false, - }: { enableChatConnectors: boolean; enableGitHubReviewBots?: boolean; resourceCount?: number; photonShared?: boolean; automaticSlack?: boolean }, + managedSlack = false, + }: { enableChatConnectors: boolean; enableGitHubReviewBots?: boolean; resourceCount?: number; photonShared?: boolean; automaticSlack?: boolean; managedSlack?: boolean }, ): Promise { const endpoint = endpointFixture(provider, seed); - if (automaticSlack) endpoint.setup.slackSetupMethod = "automatic"; + if (automaticSlack || managedSlack) endpoint.setup.slackSetupMethod = managedSlack ? "managed" : "automatic"; let slackIdentityLinked = false; let githubAppConnected = false; let githubConfiguration = { revision: 0, configuration: { @@ -510,6 +511,7 @@ export async function installChatControlPlaneMock( assignedAgentId: seed.agentId, }); state.createdWithAgentId = String(body.assignedAgentId); + if (body.slackSetupMethod) endpoint.setup.slackSetupMethod = body.slackSetupMethod as "managed" | "automatic" | "manual" | "existing"; if (provider.provider === "slack" && body.slackApp) { endpoint.setup.slackApp = body.slackApp as SlackAppConfiguration; endpoint.setup.command = endpoint.setup.slackApp.command; @@ -536,7 +538,7 @@ export async function installChatControlPlaneMock( if (method === "PATCH") { const body = bodyOf(route); if (body.slackApp) endpoint.setup.slackApp = body.slackApp as SlackAppConfiguration; - if (body.slackSetupMethod) endpoint.setup.slackSetupMethod = body.slackSetupMethod as "automatic" | "manual" | "existing"; + if (body.slackSetupMethod) endpoint.setup.slackSetupMethod = body.slackSetupMethod as "managed" | "automatic" | "manual" | "existing"; if (typeof body.allowDirectMessages === "boolean") state.allowDirectMessages = body.allowDirectMessages; if (typeof body.allowGroupChats === "boolean") @@ -563,6 +565,16 @@ export async function installChatControlPlaneMock( return; } + if (pathname === `/api/companies/${seed.companyId}/chat-slack/setup-options`) { + await fulfill(route, { managedAvailable: managedSlack, defaultMethod: managedSlack ? "managed" : "automatic", workspaces: managedSlack ? [{ grantId: "11111111-1111-4111-8111-111111111111", workspaceId: "TE2E", workspaceName: "Paperclip", userId: "UINSTALLER" }] : [] }); return; + } + if (pathname === `/api/chat-endpoints/${endpoint.id}/slack/managed/provision`) { + const body = bodyOf(route); + expect(body.grantId).toBe("11111111-1111-4111-8111-111111111111"); + expect(body).not.toHaveProperty("credentials"); + state.slackCreations++; state.slackInstallations++; + state.setSlackInstalled(); await fulfill(route, {}); return; + } if (pathname === `/api/chat-endpoints/${endpoint.id}/slack/registration`) { const body = bodyOf(route); expect(body.credentials).toEqual({ configurationToken: "fixture-config-token" }); diff --git a/ui/src/api/chatEndpoints.ts b/ui/src/api/chatEndpoints.ts index a640a8e3f98..73aa8080ef4 100644 --- a/ui/src/api/chatEndpoints.ts +++ b/ui/src/api/chatEndpoints.ts @@ -1,3 +1,4 @@ +import type { SlackSetupOptions, SlackManagedProvisionInput, SlackManagedResult } from "@paperclipai/shared"; import { api } from "./client"; import type { SlackAppConfiguration, @@ -140,7 +141,8 @@ export interface ChatEndpoint { messagingEndpoint?: string | null; command?: string | null; slackApp?: SlackAppConfiguration; - slackSetupMethod?: "automatic" | "manual" | "existing"; + slackSetupMethod?: "managed" | "automatic" | "manual" | "existing"; + slackManagerError?: string; slackRegistration?: SlackRegistrationState; slackAvatar?: SlackAvatarState; slackAccount?: SlackAccountState; @@ -202,7 +204,7 @@ export const chatEndpointsApi = { api.get(`/chat-endpoints/${endpointId}`), create: ( companyId: string, - input: { provider: ChatProvider; assignedAgentId: string; slackApp?: SlackAppConfiguration }, + input: { provider: ChatProvider; assignedAgentId: string; slackApp?: SlackAppConfiguration; slackSetupMethod?: "managed" | "automatic" | "manual" | "existing" }, ) => api.post(`/companies/${companyId}/chat-endpoints`, input), update: ( endpointId: string, @@ -216,6 +218,11 @@ export const chatEndpointsApi = { photon?: PhotonChannelConfiguration; }, ) => api.post(`/chat-endpoints/${endpointId}/setup`, input), + sendSlackTestMessage: (endpointId: string, input: import("@paperclipai/shared").SlackTestMessageInput) => + api.post(`/chat-endpoints/${endpointId}/slack/test-message`, input), + slackSetupOptions: (companyId: string) => api.get(`/companies/${companyId}/chat-slack/setup-options`), + authorizeSlackManager: (endpointId: string) => api.post(`/chat-endpoints/${endpointId}/slack/managed/authorize`, {}), + provisionManagedSlack: (endpointId: string, input: SlackManagedProvisionInput) => api.post(`/chat-endpoints/${endpointId}/slack/managed/provision`, input), createSlackApp: (endpointId: string, input: SlackRegistrationInput) => api.post(`/chat-endpoints/${endpointId}/slack/registration`, input), installSlackApp: (endpointId: string) => diff --git a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx index dedf6127417..adf87af5089 100644 --- a/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx +++ b/ui/src/pages/apps/chat/ChatEndpoint.clipboard.test.tsx @@ -503,23 +503,15 @@ describe("chat setup and identity-link clipboard actions", () => { expect(container.querySelector('aside button[aria-current="step"]')?.textContent).toBe("4Send a message"); }); - it("keeps a terminated agent’s saved avatar available in settings", async () => { - mocks.tab = "settings"; - mocks.listAgents.mockResolvedValue([]); - mocks.getAgent.mockResolvedValue({ id: "agent-a", name: "Maya", status: "terminated", appearance: { schemaVersion: 1, characterVersion: "cap-v1", paletteId: "orchid-peach" } }); - await render("slack", true); - expect(container.querySelector('a[download]')?.getAttribute("href")).toBe("/api/agent-avatars/cap-v1/orchid-peach/rest.png?size=512&scale=1&background=paperclip-dark"); - expect(mocks.getAgent).toHaveBeenCalledWith("agent-a", "company-a"); - }); - - it("keeps avatar download available in connector settings", async () => { + it("keeps settings focused on channel access and the test-message action", async () => { mocks.tab = "settings"; await render("slack", true); - const section = container.querySelector('section[aria-label="Slack avatar"]')!; - expect(section.textContent).toContain("Download avatar"); - expect(section.querySelector('a[download]')?.getAttribute("href")).toContain("/cherry-pop/rest.png?size=512&scale=1"); - expect(section.querySelector("details")?.open).toBe(false); - expect(mocks.getAgent).toHaveBeenCalledWith("agent-a", "company-a"); + expect(container.textContent).toContain("Send me a test message"); + expect(container.textContent).toContain("Open Slack configuration"); + expect(container.textContent).not.toContain("Message in a channel"); + expect(container.textContent).not.toContain("Agent avatar"); + expect(container.textContent).not.toContain("Manage action permissions"); + expect(container.querySelector('a[download]')).toBeNull(); }); it("discovers a newly invited, enabled Slack channel while settings stays open", async () => { diff --git a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx index d26e6b75416..dd075cf795e 100644 --- a/ui/src/pages/apps/chat/ChatEndpointDetail.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointDetail.tsx @@ -1,14 +1,13 @@ +import { agentAvatarUrl } from "@/lib/agent-avatar-url"; +import { resolveAgentAppearance } from "@paperclipai/shared"; +import { SlackTestMessageButton } from "./SlackTestMessageButton"; +import { AgentAvatar } from "@/components/AgentAvatar"; import { Identity } from "@/components/Identity"; import { SlackSetupAdvanced } from "./SlackAppDetails"; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogTrigger } from "@/components/ui/dialog"; import { SlackToolsSettings, SlackSearchAccess } from "./SlackToolSettings"; -import { defaultSlackAppName } from "./slack-app-name"; import { ChatCommunicationInstructions } from "./ChatCommunicationInstructions"; -import { SlackAvatarSettings } from "./SlackAvatarStep"; import { agentsApi } from "@/api/agents"; -import { agentAvatarUrl } from "@/lib/agent-avatar-url"; -import { resolveAgentAppearance } from "@paperclipai/shared"; -import { AgentAvatar } from "@/components/AgentAvatar"; import { ChatConversationList } from "./ChatConversationList"; import { GitHubBotManagement, GitHubReviews } from "./GitHubBotManagement"; import { GitHubBotMention, GitHubAppIdentityActions } from "./GitHubAppIdentity"; @@ -315,8 +314,9 @@ export function ChatEndpointDetail() { endpoint.setup?.step !== "complete" && !(endpoint.provider === "slack" && endpoint.setup?.step === "test") && ["draft", "verifying", "attention", "revoked"].includes(endpoint.status); - const slackUrl = endpoint.provider === "slack" && endpoint.providerAccountId && endpoint.botExternalId - ? `https://app.slack.com/client/${encodeURIComponent(endpoint.providerAccountId)}/user/${encodeURIComponent(endpoint.botExternalId)}` + const slackAppId = endpoint.setup?.slackRegistration?.appId; + const slackUrl = endpoint.provider === "slack" + ? slackAppId ? `https://api.slack.com/apps/${encodeURIComponent(slackAppId)}/oauth` : "https://api.slack.com/apps" : null; return ( @@ -347,8 +347,8 @@ export function ChatEndpointDetail() { )} -
- {slackUrl && } +
+ {slackUrl && } {setupIncomplete ? (
+ {endpoint.provider === "slack" && } {endpoint.provider === "github" && } @@ -435,13 +436,6 @@ function Settings({ }) { const queryClient = useQueryClient(); const { pushToast } = useToast(); - const [messageCopied, setMessageCopied] = useState(false); - const avatarAgent = useQuery({ - queryKey: queryKeys.agents.detail(endpoint.assignedAgentId), - queryFn: () => agentsApi.get(endpoint.assignedAgentId, endpoint.companyId), - enabled: endpoint.provider === "slack", - }); - const mentionMessage = `@${(endpoint.botUsername ?? endpoint.botLabel ?? endpoint.assignedAgentName).replace(/^@/, "")} you there?`; const resourcesQuery = useQuery({ queryKey: queryKeys.chatEndpoints.resources(endpointId), queryFn: () => chatEndpointsApi.listResources(endpointId), @@ -512,7 +506,7 @@ function Settings({ No destinations yet.

) : ( -
+
{destinationResources.map((resource) => (
@@ -595,27 +589,7 @@ function Settings({ queryClient.setQueryData(queryKeys.chatEndpoints.detail(endpointId), next); }} />} - {endpoint.provider === "slack" && ( -
-

Invite the bot to a channel, then mention it to start a conversation.

-
- {mentionMessage} - -
-
- )} - {endpoint.provider === "slack" && - {avatarAgent.isPending ?

Loading agent avatar…

- : avatarAgent.isError ?

Couldn’t load the agent’s avatar.

- : } -
} - {endpoint.provider === "slack" && } + {endpoint.provider === "slack" && } ); } @@ -634,7 +608,7 @@ function SettingToggle({ onChange: (value: boolean) => void; }) { return ( -
+

{label}

{detail &&

{detail}

} diff --git a/ui/src/pages/apps/chat/ChatEndpointSetup.tsx b/ui/src/pages/apps/chat/ChatEndpointSetup.tsx index fae0c99bb7f..a8d45b008ac 100644 --- a/ui/src/pages/apps/chat/ChatEndpointSetup.tsx +++ b/ui/src/pages/apps/chat/ChatEndpointSetup.tsx @@ -1,6 +1,7 @@ import { buildSlackAppManifest, defaultSlackAppConfiguration } from "@paperclipai/shared"; import { SlackAppDetails, SlackSetupAdvanced } from "./SlackAppDetails"; -import { SlackAutomaticSetup } from "./SlackAutomaticSetup"; +import { SlackSetup } from "./slack/SlackSetup"; +import { isProvisionedSlackSetup, slackSetupState } from "./slack/setup-state"; import { defaultSlackAppName } from "./slack-app-name"; import { GitHubChatSetup } from "./GitHubChatSetup"; import { GitHubAgentTrustWarning } from "@/components/GitHubAgentTrustWarning"; @@ -105,10 +106,11 @@ export function isChatEndpointRepairing( ); } -export function ChatConnectionPurpose({ provider, onChat, onTools }: { +export function ChatConnectionPurpose({ provider, onChat, onTools, slackLabels }: { provider: ChatProvider; onChat: () => void; onTools: () => void; + slackLabels?: string[]; }) { const { setBreadcrumbs } = useBreadcrumbs(); useEffect(() => { @@ -117,7 +119,7 @@ export function ChatConnectionPurpose({ provider, onChat, onTools }: { }, [setBreadcrumbs]); return (
- +

Choose how to connect

@@ -192,6 +194,7 @@ function ChatSdkEndpointSetup() { const [viewedStep, setViewedStep] = useState(null); const [slackIdentityReady, setSlackIdentityReady] = useState(false); const [automaticBusy, setAutomaticBusy] = useState(false); + const [managedAutoStart, setManagedAutoStart] = useState(Boolean(resumeEndpointId)); const [slackDraft, setSlackDraft] = useState<{ agentId: string; app: SlackAppConfiguration } | null>(null); const [endpoint, setEndpoint] = useState(null); const [credentials, setCredentials] = useState>({}); @@ -220,7 +223,7 @@ function ChatSdkEndpointSetup() { useEffect(() => { if (!resumeQuery.data) return; setEndpoint(current => { - if (current?.setup?.slackSetupMethod === "automatic" && !current.providerAccountId && resumeQuery.data.providerAccountId) setViewedStep(null); + if (isProvisionedSlackSetup(current?.setup?.slackSetupMethod) && !current?.providerAccountId && resumeQuery.data.providerAccountId) setViewedStep(null); return resumeQuery.data; }); setAgentId(resumeQuery.data.assignedAgentId); @@ -273,7 +276,8 @@ function ChatSdkEndpointSetup() { next: ChatEndpoint, onlyIfStillVisible = false, ) => { - if (next.setup?.slackSetupMethod === "automatic" && next.setup.slackRegistration?.status === "configured" + if (next.setup?.slackSetupMethod === "managed" && endpoint && endpoint.setup?.slackSetupMethod !== "managed") setManagedAutoStart(true); + if (isProvisionedSlackSetup(next.setup?.slackSetupMethod) && next.setup?.slackRegistration?.status === "configured" && endpoint?.setup?.slackRegistration?.status !== "configured") setViewedStep(null); setEndpoint((visible) => onlyIfStillVisible && visible?.id !== next.id ? visible : next, @@ -287,21 +291,31 @@ function ChatSdkEndpointSetup() { ); } }; + const slackChoices = useQuery({ + queryKey: ["slack-setup-options", selectedCompanyId], + queryFn: () => chatEndpointsApi.slackSetupOptions(selectedCompanyId!), + enabled: provider === "slack" && Boolean(selectedCompanyId), + retry: false, + }); const createEndpoint = useMutation({ - mutationFn: () => - chatEndpointsApi.create(selectedCompanyId!, { + mutationFn: ({ ownApp = false }: { ownApp?: boolean; exit?: boolean }) => { + if (provider === "slack" && !slackChoices.data) throw new Error("Slack setup is still loading. Try again."); + return chatEndpointsApi.create(selectedCompanyId!, { provider: provider!, assignedAgentId: agentId, - ...(provider === "slack" ? { slackApp: slackDetailsValidation.success ? slackDetailsValidation.data : slackDetails } : {}), - }), - onSuccess: (next) => { - setViewedStep(null); + ...(provider === "slack" ? { slackSetupMethod: ownApp ? "automatic" : slackChoices.data!.defaultMethod, slackApp: slackDetailsValidation.success ? slackDetailsValidation.data : slackDetails } : {}), + }); + }, + onSuccess: (next, { exit }) => { + setManagedAutoStart(!exit); + setViewedStep(exit ? 0 : null); syncEndpointSnapshot(next); if (next.provider === "imessage-photon" || next.provider === "slack") { const resumed = new URLSearchParams(params); resumed.set("resume", next.id); setParams(resumed, { replace: true }); } + if (exit) navigate("/apps"); }, onError: (error) => pushToast({ @@ -324,13 +338,22 @@ function ChatSdkEndpointSetup() { if (exit && provider !== "slack") { navigate("/apps"); return; } if (!endpoint) { if (!agentId) { if (exit) navigate("/apps"); return; } - createEndpoint.mutate(undefined, { onSuccess: () => { if (exit) navigate("/apps"); } }); + createEndpoint.mutate({ exit }); return; } try { if (provider === "slack") await persistSlackDetails(); } catch { return; } - if (exit) navigate("/apps"); else setViewedStep(1); + if (exit) navigate("/apps"); else { setManagedAutoStart(true); setViewedStep(null); } }; + const useOwnSlackApp = useMutation({ + mutationFn: async () => { + if (!endpoint) return createEndpoint.mutateAsync({ ownApp: true }); + await persistSlackDetails(); + return chatEndpointsApi.update(endpoint.id, { slackSetupMethod: "automatic" }); + }, + onSuccess: next => { setManagedAutoStart(false); syncEndpointSnapshot(next); setViewedStep(1); }, + onError: () => pushToast({ title: "Couldn't switch Slack setup", body: "Try again.", tone: "error" }), + }); const setupAction = useMutation({ mutationFn: async ({ action, @@ -454,21 +477,14 @@ function ChatSdkEndpointSetup() { reconnectRequested, ); const isSlack = provider === "slack"; - const automaticSlack = isSlack && (!endpoint || endpoint.setup?.slackSetupMethod === "automatic"); - const automaticSlackComplete = automaticSlack && (endpoint?.setup?.step === "test" || endpoint?.setup?.step === "complete"); - const tryStep = isSlack ? automaticSlack ? 3 : 5 : 2; - const availableStep = endpoint - ? automaticSlack && endpoint.setup?.slackRegistration?.status === "configured" && endpoint.setup.slackAccount?.status !== "linked" && endpoint.setup.step !== "complete" ? 2 - : !repairing && - (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") - ? isSlack && endpoint.setup?.step !== "complete" - ? !automaticSlack && !slackIdentityReady ? 4 : tryStep - : tryStep - : automaticSlack && endpoint.setup?.slackRegistration?.status === "credentials_saved" ? 2 - : isSlack && endpoint.providerAccountId && !repairing ? 3 - : isSlack && (slackCredentialsReady || repairing || endpoint.setup?.slackSetupMethod === "existing" || automaticSlack && endpoint.setup?.slackRegistration?.appId) ? 2 : 1 - : 0; + const slackNavigation = slackSetupState({ endpoint, repairing, credentialsReady: slackCredentialsReady, identityReady: slackIdentityReady, defaultMethod: slackChoices.data?.defaultMethod }); + const automaticSlack = isSlack && slackNavigation.provisioned; + const automaticSlackComplete = automaticSlack && slackNavigation.complete; + const tryStep = isSlack ? slackNavigation.finalStage : 2; + const availableStep = isSlack ? slackNavigation.availableStage : !endpoint ? 0 + : !repairing && (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") ? tryStep : 1; const step = Math.min(viewedStep ?? availableStep, availableStep); + const agentStepBusy = automaticBusy || createEndpoint.isPending || saveSlackDetails.isPending || useOwnSlackApp.isPending; const slackVerificationQuery = useQuery({ queryKey: ["chat-endpoint-slack-webhook-verification", endpoint?.id], queryFn: () => chatEndpointsApi.get(endpoint!.id), @@ -507,17 +523,17 @@ function ChatSdkEndpointSetup() { ); if (purpose === "choice") { - return setPurpose("chat")} onTools={() => navigate(toolHref)} />; + return setPurpose("chat")} onTools={() => navigate(toolHref)} />; } return (

setViewedStep(isSlack ? slackNavigation.toStage(value) : value)} />
{step === 0 ? ( @@ -541,15 +557,23 @@ function ChatSdkEndpointSetup() { emptyMessage="No active agents are available." />} {isSlack && (selectedAgent || endpoint) && - setSlackDraft({ agentId, app })} onBlur={() => void persistSlackDetails().catch(() => {})} /> {saveSlackDetails.isError &&

Couldn't save the Slack app details. Try again before continuing.

} + {slackChoices.data?.defaultMethod === "managed" && (!endpoint || endpoint.setup?.slackSetupMethod === "managed") && !endpoint?.setup?.slackRegistration && }
} + {isSlack && !endpoint && !slackChoices.data && (slackChoices.isError + ?
Couldn't load Slack setup.
+ :

Loading Slack…

)} {provider === "github" && } - void continueFromAgent(true)}> + void continueFromAgent(true)}>
)}
-

{slackStage === "app" ? automaticSlack ? "App configuration access token" : "Create a Slack app" : automaticSlack ? "Install Slack app" : "Add Slack credentials"}

+

{endpoint.setup?.slackSetupMethod === "managed" ? "Connect to Slack" : slackStage === "app" ? automaticSlack ? "App configuration access token" : "Create a Slack app" : automaticSlack ? "Install Slack app" : "Add Slack credentials"}

{repairing && !automaticSlack && (

Reconnect verifies or replaces credentials for this same Slack app. It does not reinstall the app or change its workspace or channel membership. Leave credentials blank to reuse the saved values. @@ -1628,8 +1655,10 @@ function ProviderConnectStep({

}
- {automaticSlack && {}} onBusy={onAutomaticBusy} onSaved={onEndpointSaved} onContinue={onSlackAppCreated} diff --git a/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx b/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx index 3fe861aaedb..ff342c014a4 100644 --- a/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx +++ b/ui/src/pages/apps/chat/SlackAutomaticSetup.tsx @@ -1,183 +1 @@ -import { useEffect, useRef, useState } from "react"; -import { ExternalLink, Loader2 } from "lucide-react"; -import { slackRegistrationErrorMessage } from "@paperclipai/shared"; -import { chatEndpointsApi, type ChatEndpoint } from "@/api/chatEndpoints"; -import { Button } from "@/components/ui/button"; -import { Input } from "@/components/ui/input"; -import { Checkbox } from "@/components/ui/checkbox"; -import { SetupWizardFooter } from "@/components/SetupWizard"; -import { sanitizedSetupErrorMessage } from "./chat-setup-error"; -import { SlackSetupAdvanced } from "./SlackAppDetails"; - -/** Uses ordinary request-local state: configuration tokens never enter a query/mutation cache. */ -export function SlackAutomaticSetup({ endpoint, stage, disabled, saveDetails, onSaved, onBusy, - onManual, onContinue, onSaveExit }: { - endpoint: ChatEndpoint; - stage: "app" | "credentials"; - disabled: boolean; - saveDetails: () => Promise; - onSaved: (endpoint: ChatEndpoint) => void; - onBusy: (busy: boolean) => void; - onManual: (existing: boolean) => Promise; - onContinue: () => void; - onSaveExit: () => void; -}) { - const [configurationToken, setConfigurationToken] = useState(""); - const [busy, setBusy] = useState(false); - const [error, setError] = useState(null); - const [checkedNoApp, setCheckedNoApp] = useState(false); - const requestId = useRef(crypto.randomUUID()); - const inFlight = useRef(false); - const pendingWindow = useRef(null); - const mounted = useRef(true); - useEffect(() => { - mounted.current = true; - return () => { - mounted.current = false; - pendingWindow.current?.close(); - pendingWindow.current = null; - }; - }, []); - const registration = endpoint.setup?.slackRegistration; - const uncertain = registration?.status === "uncertain"; - const creating = registration?.status === "creating"; - const created = Boolean(registration?.appId); - const configurationPending = registration?.errorCode === "slack_manifest_update_pending"; - - async function run(action: () => Promise) { - if (inFlight.current) return; - inFlight.current = true; - setBusy(true); - onBusy(true); - setError(null); - try { await action(); } - catch (failure) { setError(sanitizedSetupErrorMessage(failure, { configurationToken })); } - finally { - setConfigurationToken(""); - setBusy(false); - onBusy(false); - inFlight.current = false; - } - } - - async function create() { - // Reserve the window during the click so async app creation cannot trigger a popup blocker. - const installWindow = window.open("about:blank", "_blank"); - if (installWindow) { - installWindow.opener = null; - installWindow.document.title = "Preparing Slack installation"; - installWindow.document.body.textContent = "Preparing your Slack installation…"; - pendingWindow.current = installWindow; - } - let token = configurationToken.trim(); - setConfigurationToken(""); - try { - let next: ChatEndpoint; - try { - if (!created) await saveDetails(); - if (registration?.status === "failed" || checkedNoApp) requestId.current = crypto.randomUUID(); - next = await chatEndpointsApi.createSlackApp(endpoint.id, { - requestId: requestId.current, - credentials: { configurationToken: token }, - ...(checkedNoApp ? { confirmedNoAppCreated: true } : {}), - }); - } catch (failure) { - // A response can be lost after Slack creates the app. Only saved state can advance. - const current = await chatEndpointsApi.get(endpoint.id).catch(() => null); - if (current && mounted.current) onSaved(current); - if (!current?.setup?.slackRegistration?.appId) { - throw new Error(sanitizedSetupErrorMessage(failure, { configurationToken: token })); - } - next = current; - } - token = ""; - if (!mounted.current) return; - setCheckedNoApp(false); - onSaved(next); - const saved = next.setup?.slackRegistration; - if (!saved?.appId) return; - onContinue(); - // Pending/uncertain configuration and existing installations must not start OAuth. - if (saved.status !== "install" || saved.errorCode) return; - if (!installWindow || installWindow.closed) { - setError("Select Install in Slack to open the installation page."); - return; - } - const result = await chatEndpointsApi.installSlackApp(endpoint.id); - if (!mounted.current) return; - if (installWindow.closed) { - setError("Select Install in Slack to reopen the installation page."); - return; - } - installWindow.location.replace(result.authorizationUrl); - pendingWindow.current = null; - } finally { - token = ""; - pendingWindow.current?.close(); - pendingWindow.current = null; - } - } - - async function authorize() { - const result = await chatEndpointsApi.installSlackApp(endpoint.id); - // Current-tab navigation avoids popup blockers. The callback resumes this saved draft. - window.location.assign(result.authorizationUrl); - } - - return
- {error || registration?.errorCode ?

- {error ?? slackRegistrationErrorMessage(registration!.errorCode!)} -

: null} - {(stage === "app" && !created && !creating || configurationPending) && <> - {uncertain &&
- Open Slack app settings - -
} - {(!uncertain || checkedNoApp) &&
- -
-
    -
  1. In Slack app settings, find Your App Configuration Tokens and choose Generate Token.
  2. -
  3. Select the workspace where you want to install your bot.
  4. -
  5. Copy the Access Token and paste it below.
  6. -
-

Paperclip uses this token to create your app

-
- - setConfigurationToken(event.target.value)} - aria-describedby="slack-configuration-token-help" /> -
} - } - {creating &&

Creating your Slack app. You can return to this saved setup.

} - {created &&

- {endpoint.setup?.slackApp?.appName ?? "Your Slack app"} is created. - {stage === "credentials" && " Approve installation with your Slack account. Paperclip will link it to your signed-in Paperclip account and send you a welcome DM."} -

} - {stage === "credentials" && registration?.status === "credentials_saved" &&
-

Your installation credentials are saved. Retry connecting to finish setup. If Slack access has changed, authorize the same app again.

- -
} - {stage === "app" &&
- - -
} - - {configurationPending ? : stage === "app" ? : registration?.status === "credentials_saved" ? : } - -
; -} +export { OwnSlackAppSetup as SlackAutomaticSetup } from "./slack/OwnSlackAppSetup"; diff --git a/ui/src/pages/apps/chat/SlackSetupPrompt.tsx b/ui/src/pages/apps/chat/SlackSetupPrompt.tsx index b9256c27d59..2cc0568ea77 100644 --- a/ui/src/pages/apps/chat/SlackSetupPrompt.tsx +++ b/ui/src/pages/apps/chat/SlackSetupPrompt.tsx @@ -12,19 +12,21 @@ Ask me to take over for login, two-factor authentication, CAPTCHA, workspace-adm 2. Check public HTTPS before creating the app. Slack must be able to reach Paperclip's generated webhook URL over publicly reachable HTTPS with a valid certificate. Paperclip Cloud includes HTTPS. A private Tailscale Serve address alone is not reachable by Slack; self-hosting needs an authorized public ingress, such as Tailscale Funnel or a reverse proxy with a trusted certificate. Follow the warning's Learn more link if needed. Use the callback URL generated by this connection, including its full path. Check that it uses the intended public instance hostname or an explicitly configured webhook ingress, not localhost, an internal service address, or an old pool hostname. If it is wrong or unreachable, resolve the instance configuration before registration; do not invent a callback path, disable signature verification, or bypass Cloud authentication protections. -3. Create Slack app automatically. Review Slack app name, Bot display name, and Slash command under Advanced on Choose agent in Paperclip. Keep the generated defaults unless I requested other names. Use the automatic setup path for a new bot. Ask me to open Slack's Your App Configuration Tokens > Generate Token, select the agreed workspace, and enter the temporary configuration token directly into Paperclip's App configuration access token password field. Pause while I do that. Never read, copy, reveal, screenshot, or include that token in your prompt or tool output. Do not request its refresh token. Click Create Slack app after I enter it. Paperclip creates the app, saves its signing secret, and uploads the assigned agent's avatar. Avatar upload has no wizard step; if it fails, finish setup and optionally use Settings > Agent avatar for manual upload later. Do not recreate the app or request another configuration token for an optional avatar. Its saved state is the evidence of creation; a click or navigation is not proof. Resume the matching draft after refresh or Save & exit. If creation is uncertain, open Slack app settings and check the agreed workspace for the exact app before continuing. Never retry creation automatically. Only confirm that no app was created after I explicitly confirm that check, or recover the existing app on this same draft. No Slack CLI is required. +3. Use the saved Slack setup method. For a new connection, keep managed setup when it is the instance default. Continue on Choose agent opens Slack authorization or starts installation automatically; there is no separate Add to Slack screen. To explicitly use your own app, open Advanced on Choose agent and choose Use your own app below Slash command. Select only a workspace authorized by my own Paperclip account, or let me complete the manager authorization in Slack; pause for login and administrator consent. Reuse my saved workspace authorization for additional agents. Paperclip creates and installs the child app, uploads its avatar, and links my verified Slack identity. Continue within the same two-stop wizard: Choose agent, Connect. If Slack requires child-app consent or approval, complete that for the saved app and resume. Never switch methods after creation dispatch, migrate an existing bot, read manager credentials, or reuse a teammate's grant. If managed setup becomes unavailable, preserve the draft and report its recovery action. Use your own app only when selected or when managed setup is unavailable before creation. + +For the own-app path, create Slack app automatically. Review Slack app name, Bot display name, and Slash command under Advanced on Choose agent in Paperclip. Keep the generated defaults unless I requested other names. Use the automatic setup path for a new bot. Ask me to open Slack's Your App Configuration Tokens > Generate Token, select the agreed workspace, and enter the temporary configuration token directly into Paperclip's App configuration access token password field. Pause while I do that. Never read, copy, reveal, screenshot, or include that token in your prompt or tool output. Do not request its refresh token. Click Create Slack app after I enter it. Paperclip creates the app, saves its signing secret, and uploads the assigned agent's avatar. Avatar upload has no wizard step; if it fails, finish setup and optionally use Slack app settings > Basic Information to set its app icon later. Do not recreate the app or request another configuration token for an optional avatar. Its saved state is the evidence of creation; a click or navigation is not proof. Resume the matching draft after refresh or Save & exit. If creation is uncertain, open Slack app settings and check the agreed workspace for the exact app before continuing. Never retry creation automatically. Only confirm that no app was created after I explicitly confirm that check, or recover the existing app on this same draft. No Slack CLI is required. 4. Install Slack app. Successful creation and configuration advance Paperclip to Install Slack app and open Slack consent in a new window automatically. Use Install in Slack if that window was blocked or closed. Check the workspace and approve installation within the agreed scope. Pause for required login, ownership decisions, or administrator approval. Return to the saved wizard through Slack's OAuth redirect. Paperclip obtains and saves the bot token itself; do not locate, copy, or request the bot token or signing secret on the automatic path. Wait for Paperclip to report saved credentials. The fourth screen, Send a message to your agent, becomes Success automatically after Paperclip receives qualifying connection evidence. There is no separate Try it screen. If approval is declined or pending, preserve the app and resume its installation later without another configuration token. If authorization expires, start a fresh installation of this same app. If credentials are saved but connecting fails, use Retry connecting. If Slack access changed or the token was revoked, use Authorize in Slack again for this same app. Do not recreate the app. Installation automatically links the Slack account returned by OAuth to my signed-in Paperclip account and sends a welcome DM. Have me approve with my own Slack account; do not authorize with your account or a teammate’s account. There is no separate personal-linking step on the automatic path. Existing/conflicting/revoked links are managed in the connection’s Access settings later. Never overwrite them as a shortcut. Manual fallback only when selected or needed for recovery: choose Create manually or Use an existing app on the creation step. For manual creation, use Paperclip's generated manifest, select the agreed workspace in Slack, and install that exact app. Preserve all scopes, bot events, slash command, interactivity, and signed HTTP callbacks. For an existing app, reconcile its configuration with this connection's manifest and generated URLs. Have me enter the Bot User OAuth Token (OAuth & Permissions, xoxb- prefix) and Signing Secret (Basic Information > App Credentials) directly into the matching Paperclip fields, or use safe clipboard transfer only as described above. The Signing Secret is not the Client Secret, Verification Token, or app-level token. Click Connect Slack app. On a resumed manual setup, leave credential fields blank to keep saved values. Never use Socket Mode as a shortcut. -5. On the automatic path, use Send a message to your agent and its Open Slack link. If authorized, send a short message. Paperclip accepts an authenticated message event from the saved app/workspace as connection evidence and replaces this screen with Success. The welcome event may complete the check first. If Success appears, skip the Slack settings visit even if Slack's Request URL still says unverified. If Paperclip keeps waiting, expand Didn’t work? for Request URL instructions. On the manual path, open the same Slack app's Event Subscriptions. Make sure events are enabled. Beside the manifest's prefilled Request URL, click Retry if needed, wait for Slack to display Verified, and save changes when offered. Verification can fail before Paperclip has the credentials, so retry now. Return to Paperclip and wait for its verified state and automatic advancement, or Continue when available. If verification fails, inspect the specific error and the wizard's Troubleshooting URL: check public reachability, the correct app/workspace and signing secret, and the full generated callback URL. Also confirm the generated Slash Commands request URL and Interactivity & Shortcuts request URL were retained if those features fail. Use the manifest's values for each surface; do not assume verifying events proves every callback works. Do not recreate the app or change arbitrary URLs to hide a failed check. +5. On either provisioned path, use Send a message to your agent and its Open Slack link. If authorized, send a short message. Paperclip accepts an authenticated message event from the saved app/workspace as connection evidence and replaces this screen with Success. The welcome event may complete the check first. If Success appears, skip the Slack settings visit even if Slack's Request URL still says unverified. If Paperclip keeps waiting, expand Didn’t work? for Request URL instructions. On the manual path, open the same Slack app's Event Subscriptions. Make sure events are enabled. Beside the manifest's prefilled Request URL, click Retry if needed, wait for Slack to display Verified, and save changes when offered. Verification can fail before Paperclip has the credentials, so retry now. Return to Paperclip and wait for its verified state and automatic advancement, or Continue when available. If verification fails, inspect the specific error and the wizard's Troubleshooting URL: check public reachability, the correct app/workspace and signing secret, and the full generated callback URL. Also confirm the generated Slash Commands request URL and Interactivity & Shortcuts request URL were retained if those features fail. Use the manifest's values for each surface; do not assume verifying events proves every callback works. Do not recreate the app or change arbitrary URLs to hide a failed check. 6. Manual/existing-app paths only: connect your Slack account. Copy the exact command shown by Paperclip, consisting of this app's saved slash command followed by connect. In the correct Slack workspace, send it as my signed-in Slack user. This discovers identity without starting agent work. Return to Paperclip and wait for that identity to appear. Compare the Slack account/workspace and the target Paperclip user. Only select the ownership confirmation for that identity when they are verified as my accounts; its visible label is This is my Slack account and browser tools may identify it as Link to my Paperclip account. Ask me if ownership is ambiguous. Never link a teammate's account to mine. Confirm Linked to you and use Continue to message test. Keep unlinked-person access off unless I explicitly requested a different policy. On these manual paths installing the bot and linking a person are separate steps; future work uses that linked person's Paperclip permissions. 7. Prepare the agreed destination. If using a channel, invite/add this bot to that channel through Slack's UI if authorized. In the connection's Settings, inspect Where this agent can work. Use its Refresh action if available, or reload the page after the bot invitation if the channel is missing. Inspect Allowed Channels and make sure the intended test channel is enabled; newly invited channels start enabled, while an explicitly disabled channel stays off until a person re-enables it. Do not invite the bot to unrelated channels, enable every channel, or broaden anyone's access just to get a test to pass. For a DM, check the connection permits direct messages and use my conversation with this bot. Return to the saved wizard if you visited Settings. Reading access and allowed response destinations are distinct; seeing a channel in Slack does not prove the bot is allowed to reply there. -8. Optional live qualification when explicitly requested: on the automatic path, the wizard ends at Success and Done, with no additional test screen. Open the bot's DM directly if a conversation test was requested. The welcome DM and connection check alone do not prove an agent response. For a channel test or manual setup, send the wizard's suggested short message, for example @ you there? Find the bot belonging to this Slack app in Slack's mention suggestions and select it. Slack's actual display name can differ from Paperclip's suggested username: for example, Paperclip may suggest @cedarqa while Slack shows cedar-qa. Preserve Slack's displayed punctuation and select the real bot; pasting plain text that resembles a mention may not notify it. Use the configured bot, not a literal example. For a channel test, use a mention rather than the slash connect command. Wait for a real response in the originating Slack thread. Open the connection's Conversations page, follow the matching Open task link, and verify that the assigned agent's run completed successfully; the agent's Runs page provides the execution result if the Slack reply has no task link. Send one short follow-up in the same thread without another mention and confirm continuity and a single delivered answer. Do not claim success from a typing indicator, a verified webhook, the wizard's checkmark, or a manually posted substitute reply. If a run stops or no answer arrives, inspect the task/run and connection Activity, distinguish credential/callback/destination/identity failures from an agent-runtime failure, and resolve the specific issue or report the remaining blocker without exposing secrets. +8. Optional live qualification when explicitly requested: on either provisioned path, the wizard ends at Success and Done, with no additional test screen. Open the bot's DM directly if a conversation test was requested. The welcome DM and connection check alone do not prove an agent response. For a channel test or manual setup, send the wizard's suggested short message, for example @ you there? Find the bot belonging to this Slack app in Slack's mention suggestions and select it. Slack's actual display name can differ from Paperclip's suggested username: for example, Paperclip may suggest @cedarqa while Slack shows cedar-qa. Preserve Slack's displayed punctuation and select the real bot; pasting plain text that resembles a mention may not notify it. Use the configured bot, not a literal example. For a channel test, use a mention rather than the slash connect command. Wait for a real response in the originating Slack thread. Open the connection's Conversations page, follow the matching Open task link, and verify that the assigned agent's run completed successfully; the agent's Runs page provides the execution result if the Slack reply has no task link. Send one short follow-up in the same thread without another mention and confirm continuity and a single delivered answer. Do not claim success from a typing indicator, a verified webhook, the wizard's checkmark, or a manually posted substitute reply. If a run stops or no answer arrives, inspect the task/run and connection Activity, distinguish credential/callback/destination/identity failures from an agent-runtime failure, and resolve the specific issue or report the remaining blocker without exposing secrets. 9. Finish and review. Use Done to finish setup. The message test is optional and does not waive required credentials, callback verification, or identity linking. If I declined testing, finish without claiming it passed. Confirm the connection is saved for the correct agent/workspace, inspect its permitted destinations and linked identity, and leave existing access restrictions and per-action approvals intact. Slack tools for this agent's eligible tasks and routines use this bot connection and the responsible user's linked Slack account; do not create a separate personal Slack MCP connection or promise all methods work regardless of scopes or plan. Additional communication instructions are optional and apply to new tasks; add them only if I supplied preferences. Other people link their own accounts using the Access page's connect instructions; they do not share the bot secrets or my identity. diff --git a/ui/src/pages/apps/chat/SlackTestMessageButton.tsx b/ui/src/pages/apps/chat/SlackTestMessageButton.tsx new file mode 100644 index 00000000000..10cea6448d2 --- /dev/null +++ b/ui/src/pages/apps/chat/SlackTestMessageButton.tsx @@ -0,0 +1,88 @@ +import { useEffect, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; +import { Check, Loader2, Send } from "lucide-react"; +import type { SlackTestMessageResult } from "@paperclipai/shared"; +import { chatEndpointsApi } from "@/api/chatEndpoints"; +import { Button } from "@/components/ui/button"; +import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from "@/components/ui/dialog"; +import { Input } from "@/components/ui/input"; +import { queryKeys } from "@/lib/queryKeys"; + +export function SlackTestMessageButton({ endpointId }: { endpointId: string }) { + const queryClient = useQueryClient(); + const [requestId, setRequestId] = useState(() => crypto.randomUUID()); + const [accounts, setAccounts] = useState["accounts"] | null>(null); + const [nextCursor, setNextCursor] = useState(null); + const [selected, setSelected] = useState(""); + const [invitationExpired, setInvitationExpired] = useState(false); + const [search, setSearch] = useState(""); + const send = useMutation({ + mutationFn: (input: { slackUserId?: string; accountCursor?: string; requestId?: string }) => chatEndpointsApi.sendSlackTestMessage(endpointId, { requestId, ...input }), + onSuccess: (result, input) => { + if (result.status === "connection_required") { + setAccounts(previous => input.accountCursor ? [...new Map([...(previous ?? []), ...result.accounts].map(account => [account.id, account])).values()] : result.accounts); + setNextCursor(result.nextCursor); + return; + } + setAccounts(null); + void queryClient.invalidateQueries({ queryKey: queryKeys.chatEndpoints.principals(endpointId) }); + }, + }); + const sent = send.data?.status === "sent"; + const pendingConnection = send.data?.status === "connection_pending" ? send.data : null; + useEffect(() => { + setInvitationExpired(false); + if (!pendingConnection) return; + const timer = setTimeout(() => setInvitationExpired(true), Math.max(0, new Date(pendingConnection.expiresAt).getTime() - Date.now())); + return () => clearTimeout(timer); + }, [pendingConnection?.expiresAt]); + const principals = useQuery({ + queryKey: queryKeys.chatEndpoints.principals(endpointId), + queryFn: () => chatEndpointsApi.listPrincipals(endpointId), + enabled: Boolean(pendingConnection) && !invitationExpired, refetchInterval: pendingConnection && !invitationExpired ? 3000 : false, + }); + const confirmed = Boolean(pendingConnection && principals.data?.some(link => link.principalId === pendingConnection.principalId && link.status === "linked")); + useEffect(() => { + if (confirmed && !send.isPending && !send.isError) send.mutate({ slackUserId: selected }); + }, [confirmed, send.isPending, send.isError]); + const reset = () => { setRequestId(crypto.randomUUID()); send.reset(); }; + const startTest = () => { + if (invitationExpired) { + const next = crypto.randomUUID(); setRequestId(next); + send.mutate({ requestId: next, slackUserId: selected }); + } else send.mutate({}); + }; + return
+ + {pendingConnection &&

{invitationExpired ? "The Slack invitation expired." : "Open the invitation in Slack to connect your account."}

} + {send.isError &&
+ {send.error.message} +
} + { if (!open && !send.isPending) setAccounts(null); }}> + + + Connect your Slack account + Choose your account. Messages to this agent will use your Paperclip permissions. + + setSearch(event.target.value)} /> +
+ {accounts?.filter(account => `${account.name} ${account.id}`.toLowerCase().includes(search.toLowerCase())).map(account => + )} + {accounts?.length === 0 &&

No available accounts found in this Slack workspace.

} +
+ {nextCursor && } + {send.isError &&

{send.error.message}

} +
+ + +
+
+
+
; +} diff --git a/ui/src/pages/apps/chat/SlackToolSettings.tsx b/ui/src/pages/apps/chat/SlackToolSettings.tsx index 8e2fb86df9b..772eb0af66c 100644 --- a/ui/src/pages/apps/chat/SlackToolSettings.tsx +++ b/ui/src/pages/apps/chat/SlackToolSettings.tsx @@ -1,4 +1,3 @@ -import { Link } from "react-router-dom"; import { useId, useState } from "react"; import { useQuery } from "@tanstack/react-query"; import type { @@ -200,11 +199,9 @@ export function SlackSearchView({ export function SlackToolsSettings({ companyId, endpointId, - connectionId, }: { companyId: string; endpointId: string; - connectionId?: string | null; }) { const query = useQuery({ queryKey: ["slack-capabilities", companyId, endpointId], @@ -217,14 +214,7 @@ export function SlackToolsSettings({ capabilities={query.data} error={query.error?.message} /> - {connectionId && ( - - Manage action permissions - - )} +
); } diff --git a/ui/src/pages/apps/chat/chat-ui-contract.test.ts b/ui/src/pages/apps/chat/chat-ui-contract.test.ts index f67ccd5be70..34b79e374d4 100644 --- a/ui/src/pages/apps/chat/chat-ui-contract.test.ts +++ b/ui/src/pages/apps/chat/chat-ui-contract.test.ts @@ -242,7 +242,8 @@ describe("chat connector UI contract", () => { expect(setup).toContain("defaultSlackAppConfiguration"); expect(setup).not.toContain("No credentials"); expect(setup).not.toContain("managed Microsoft app"); - expect(setup).toContain("endpoint.providerAccountId && !repairing"); + expect(setup).toContain("slackSetupState({ endpoint, repairing"); + expect(source("./slack/setup-state.ts")).toContain("endpoint.providerAccountId && !repairing"); expect(setup).not.toContain('field("webhookSecret"'); expect(setup).not.toContain("@paperclipai/teams-connect"); expect(setup).not.toContain("Copy setup command"); diff --git a/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx new file mode 100644 index 00000000000..245773a6a70 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.test.tsx @@ -0,0 +1,88 @@ +// @vitest-environment jsdom +import { StrictMode, act } from "react"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { beforeEach, afterEach, expect, it, vi } from "vitest"; +import type { ChatEndpoint } from "@/api/chatEndpoints"; +import { ManagedSlackSetup } from "./ManagedSlackSetup"; + +const api = vi.hoisted(() => ({ slackSetupOptions: vi.fn(), provisionManagedSlack: vi.fn(), authorizeSlackManager: vi.fn(), get: vi.fn() })); +vi.mock("@/api/chatEndpoints", () => ({ chatEndpointsApi: api })); +// Stop at the navigation boundary without leaving jsdom. +const navigation = vi.hoisted(() => vi.fn()); +vi.mock("@/lib/oauthHandoff", () => ({ prepareOAuthNavigation: navigation, savePendingCloudHandoff: vi.fn() })); +let root: Root; +let container: HTMLDivElement; +let client: QueryClient; +let endpoint: ChatEndpoint; +const props = () => ({ endpoint, stage: "app" as const, disabled: false, autoStart: true, saveDetails: vi.fn().mockResolvedValue(undefined), onSaved: vi.fn(), onBusy: vi.fn(), onSaveExit: vi.fn(), onManual: vi.fn(), onContinue: vi.fn() }); +beforeEach(() => { + vi.resetAllMocks(); + container = document.createElement("div"); document.body.append(container); root = createRoot(container); + client = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + endpoint = { id: "endpoint", companyId: "company", assignedAgentName: "Maya", setup: { slackSetupMethod: "managed" } } as ChatEndpoint; + api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [{ grantId: "grant", workspaceName: "Paperclip" }] }); + api.provisionManagedSlack.mockResolvedValue({}); api.get.mockResolvedValue(endpoint); + api.authorizeSlackManager.mockResolvedValue({ authorizationUrl: "https://slack.com/oauth" }); + navigation.mockImplementation(() => new Promise(() => {})); +}); +afterEach(async () => { await act(async () => root.unmount()); client.clear(); container.remove(); }); +async function render(overrides = {}) { + await act(async () => { root.render(); }); + await act(async () => { await new Promise(resolve => setTimeout(resolve, 30)); }); +} +it("starts a single managed install after Continue even across rerenders", async () => { + await render(); await render(); + expect(api.provisionManagedSlack).toHaveBeenCalledTimes(1); + expect(api.provisionManagedSlack).toHaveBeenCalledWith("endpoint", expect.objectContaining({ grantId: "grant" })); + expect(api.authorizeSlackManager).not.toHaveBeenCalled(); +}); +it("requests explicit reinstall when reconnecting a configured app", async () => { + endpoint.setup!.slackRegistration = { status: "configured", appId: "app", managerGrantId: "grant" } as NonNullable["slackRegistration"]; + await render({ autoStart: false, reconnect: true }); + const button = Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Continue in Slack"); + expect(button).toBeDefined(); + await act(async () => button!.click()); + expect(api.provisionManagedSlack).toHaveBeenCalledWith("endpoint", expect.objectContaining({ grantId: "grant", reconnect: true, requestId: expect.any(String) })); +}); +it("reuses the reconnect receipt after a lost response and failed recovery read", async () => { + endpoint.setup!.slackRegistration = { status: "configured", appId: "app", managerGrantId: "grant" } as NonNullable["slackRegistration"]; + api.provisionManagedSlack.mockRejectedValue(new Error("Response lost")); + api.get.mockRejectedValue(new Error("Recovery read unavailable")); + await render({ autoStart: false, reconnect: true }); + const button = () => Array.from(container.querySelectorAll("button")).find(item => item.textContent === "Continue in Slack")!; + await act(async () => button().click()); + await act(async () => button().click()); + expect(api.provisionManagedSlack).toHaveBeenCalledTimes(2); + expect(api.provisionManagedSlack.mock.calls[1]![1]).toEqual(api.provisionManagedSlack.mock.calls[0]![1]); +}); +it("opens manager authorization directly when there is no saved workspace", async () => { + api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [] }); + await render(); + expect(api.authorizeSlackManager).toHaveBeenCalledTimes(1); + expect(navigation).toHaveBeenCalledWith({ authorizationUrl: "https://slack.com/oauth" }); + expect(api.provisionManagedSlack).not.toHaveBeenCalled(); +}); +it("does not start while Choose agent is visible or after Save & exit", async () => { + await render({ autoStart: false }); + expect(api.provisionManagedSlack).not.toHaveBeenCalled(); + expect(api.authorizeSlackManager).not.toHaveBeenCalled(); +}); +it("waits for an explicit workspace choice when there are several", async () => { + api.slackSetupOptions.mockResolvedValue({ managedAvailable: true, workspaces: [{ grantId: "one", workspaceName: "One" }, { grantId: "two", workspaceName: "Two" }] }); + await render(); expect(api.provisionManagedSlack).not.toHaveBeenCalled(); + expect(container.textContent).toContain("Choose a Slack workspace"); +}); +it.each(["uncertain", "failed", "creating", "install", "credentials_saved"])("does not automatically retry a %s registration", async status => { + endpoint.setup!.slackRegistration = { status } as NonNullable["slackRegistration"]; + await render(); expect(api.provisionManagedSlack).not.toHaveBeenCalled(); expect(api.authorizeSlackManager).not.toHaveBeenCalled(); +}); +it("does not loop authorization after denial", async () => { + endpoint.setup!.slackManagerError = "slack_install_declined"; + await render(); expect(api.authorizeSlackManager).not.toHaveBeenCalled(); expect(api.provisionManagedSlack).not.toHaveBeenCalled(); +}); +it("does not silently retry an ambiguous request after refresh of setup options", async () => { + api.provisionManagedSlack.mockRejectedValue(new Error("Request failed")); + await render(); await render(); + expect(api.provisionManagedSlack).toHaveBeenCalledTimes(1); +}); diff --git a/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx new file mode 100644 index 00000000000..d9023162f02 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/ManagedSlackSetup.tsx @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState, type ComponentProps } from "react"; +import { useQuery } from "@tanstack/react-query"; +import { Loader2 } from "lucide-react"; +import { slackRegistrationErrorMessage } from "@paperclipai/shared"; +import { chatEndpointsApi } from "@/api/chatEndpoints"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; +import { SetupWizardFooter } from "@/components/SetupWizard"; +import type { OwnSlackAppSetup } from "./OwnSlackAppSetup"; +import { prepareOAuthNavigation, savePendingCloudHandoff } from "@/lib/oauthHandoff"; +import { sanitizedSetupErrorMessage } from "../chat-setup-error"; + +export function ManagedSlackSetup({ endpoint, disabled, saveDetails, onSaved, onBusy, onSaveExit, autoStart = false, reconnect = false }: ComponentProps & { autoStart?: boolean; reconnect?: boolean }) { + const choices = useQuery({ queryKey: ["slack-managed-workspaces", endpoint.companyId], queryFn: () => chatEndpointsApi.slackSetupOptions(endpoint.companyId), retry: false }); + const [selectedGrant, setSelectedGrant] = useState(null); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [confirmed, setConfirmed] = useState(false); + const inFlight = useRef(false); + const requestId = useRef(crypto.randomUUID()); + const registration = endpoint.setup?.slackRegistration; + const started = Boolean(registration); + const workspaces = choices.data?.workspaces ?? []; + const grantId = registration?.managerGrantId ?? selectedGrant ?? (workspaces.length === 1 ? workspaces[0]!.grantId : null); + const needsAuthorization = !grantId || registration?.errorCode === "slack_manager_reauthorize" || Boolean(grantId && !workspaces.some(workspace => workspace.grantId === grantId)); + const uncertain = registration?.status === "uncertain"; + const creating = registration?.status === "creating"; + async function run(action: () => Promise) { + if (inFlight.current) return; + inFlight.current = true; setBusy(true); onBusy(true); setError(null); + try { await action(); } + catch (failure) { setError(sanitizedSetupErrorMessage(failure, {})); await Promise.allSettled([choices.refetch(), chatEndpointsApi.get(endpoint.id).then(onSaved)]); } + finally { inFlight.current = false; setBusy(false); onBusy(false); } + } + async function authorize() { + const authorization = await chatEndpointsApi.authorizeSlackManager(endpoint.id); + const target = await prepareOAuthNavigation(authorization); + if (target.kind === "reauthentication" && authorization.handoff) savePendingCloudHandoff(authorization.handoff.session); + window.location.assign(target.url); + } + async function connect() { + if (needsAuthorization || !grantId) { + await authorize(); return; + } + if (!started) await saveDetails(); + if (confirmed || registration?.status === "failed") requestId.current = crypto.randomUUID(); + const input = { requestId: requestId.current, grantId, ...(reconnect ? { reconnect: true } : {}), ...(confirmed ? { confirmedNoAppCreated: true } : {}) }; + setConfirmed(false); + const result = await chatEndpointsApi.provisionManagedSlack(endpoint.id, input); + await choices.refetch(); + onSaved(await chatEndpointsApi.get(endpoint.id)); + if (result.authorization) window.location.assign(result.authorization.authorizationUrl); + } + const autoStarted = useRef(false); + useEffect(() => { + // Only fresh setup can start itself. Declines, uncertain creation, and saved + // registration recovery require an explicit action and never recreate an app. + if (!autoStart || autoStarted.current || disabled || !choices.data?.managedAvailable + || choices.isFetching || choices.isError || started || endpoint.setup?.slackManagerError + || workspaces.length > 1) return; + autoStarted.current = true; + void run(connect); + }); + const setupError = (code: string) => code === "slack_manifest_update_pending" + ? "Your app was created. Retry to finish its configuration." + : code === "slack_creation_uncertain" ? "Slack may have created your app. Check Slack app settings before trying again." + : slackRegistrationErrorMessage(code); + const message = error ?? (endpoint.setup?.slackManagerError ? setupError(endpoint.setup.slackManagerError) : null) ?? (choices.isError ? "Couldn't load your Slack workspaces. Try again." : registration?.errorCode ? setupError(registration.errorCode) : null); + return
+ {message &&

{message}

} + {busy || choices.isPending || creating ?

{busy ? needsAuthorization ? "Opening Slack…" : "Connecting your agent…" : creating ? "Setting up your Slack app…" : "Loading Slack…"}

+ : !choices.data?.managedAvailable ?

Managed Slack setup is unavailable. Try again later.

+ : <> + {workspaces.length === 1 &&

Add {endpoint.assignedAgentName} to {workspaces[0]!.workspaceName}.

} + {workspaces.length > 1 && } + {(!started || registration?.errorCode === "slack_manager_reauthorize") && workspaces.length > 0 &&
} + } + {uncertain &&
+ Open Slack app settings + +
} + + {choices.isError || !choices.isPending && !choices.data?.managedAvailable ? : } + +
; +} diff --git a/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx b/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx new file mode 100644 index 00000000000..a08c09376b7 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/OwnSlackAppSetup.tsx @@ -0,0 +1,185 @@ +import { useEffect, useRef, useState } from "react"; +import { ExternalLink, Loader2 } from "lucide-react"; +import { slackRegistrationErrorMessage } from "@paperclipai/shared"; +import { chatEndpointsApi, type ChatEndpoint } from "@/api/chatEndpoints"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Checkbox } from "@/components/ui/checkbox"; +import { SetupWizardFooter } from "@/components/SetupWizard"; +import { sanitizedSetupErrorMessage } from "../chat-setup-error"; +import { SlackSetupAdvanced } from "../SlackAppDetails"; + +/** Uses ordinary request-local state: configuration tokens never enter a query/mutation cache. */ +export function OwnSlackAppSetup({ endpoint, stage, disabled, saveDetails, onSaved, onBusy, + onManual, onManaged, onContinue, onSaveExit }: { + endpoint: ChatEndpoint; + stage: "app" | "credentials"; + disabled: boolean; + saveDetails: () => Promise; + onSaved: (endpoint: ChatEndpoint) => void; + onBusy: (busy: boolean) => void; + onManual: (existing: boolean) => Promise; + onManaged?: () => Promise; + onContinue: () => void; + onSaveExit: () => void; +}) { + const [configurationToken, setConfigurationToken] = useState(""); + const [busy, setBusy] = useState(false); + const [error, setError] = useState(null); + const [checkedNoApp, setCheckedNoApp] = useState(false); + const requestId = useRef(crypto.randomUUID()); + const inFlight = useRef(false); + const pendingWindow = useRef(null); + const mounted = useRef(true); + useEffect(() => { + mounted.current = true; + return () => { + mounted.current = false; + pendingWindow.current?.close(); + pendingWindow.current = null; + }; + }, []); + const registration = endpoint.setup?.slackRegistration; + const uncertain = registration?.status === "uncertain"; + const creating = registration?.status === "creating"; + const created = Boolean(registration?.appId); + const configurationPending = registration?.errorCode === "slack_manifest_update_pending"; + + async function run(action: () => Promise) { + if (inFlight.current) return; + inFlight.current = true; + setBusy(true); + onBusy(true); + setError(null); + try { await action(); } + catch (failure) { setError(sanitizedSetupErrorMessage(failure, { configurationToken })); } + finally { + setConfigurationToken(""); + setBusy(false); + onBusy(false); + inFlight.current = false; + } + } + + async function create() { + // Reserve the window during the click so async app creation cannot trigger a popup blocker. + const installWindow = window.open("about:blank", "_blank"); + if (installWindow) { + installWindow.opener = null; + installWindow.document.title = "Preparing Slack installation"; + installWindow.document.body.textContent = "Preparing your Slack installation…"; + pendingWindow.current = installWindow; + } + let token = configurationToken.trim(); + setConfigurationToken(""); + try { + let next: ChatEndpoint; + try { + if (!created) await saveDetails(); + if (registration?.status === "failed" || checkedNoApp) requestId.current = crypto.randomUUID(); + setCheckedNoApp(false); + next = await chatEndpointsApi.createSlackApp(endpoint.id, { + requestId: requestId.current, + credentials: { configurationToken: token }, + ...(checkedNoApp ? { confirmedNoAppCreated: true } : {}), + }); + } catch (failure) { + // A response can be lost after Slack creates the app. Only saved state can advance. + const current = await chatEndpointsApi.get(endpoint.id).catch(() => null); + if (current && mounted.current) onSaved(current); + if (!current?.setup?.slackRegistration?.appId) { + throw new Error(sanitizedSetupErrorMessage(failure, { configurationToken: token })); + } + next = current; + } + token = ""; + if (!mounted.current) return; + onSaved(next); + const saved = next.setup?.slackRegistration; + if (!saved?.appId) return; + onContinue(); + // Pending/uncertain configuration and existing installations must not start OAuth. + if (saved.status !== "install" || saved.errorCode) return; + if (!installWindow || installWindow.closed) { + setError("Select Install in Slack to open the installation page."); + return; + } + const result = await chatEndpointsApi.installSlackApp(endpoint.id); + if (!mounted.current) return; + if (installWindow.closed) { + setError("Select Install in Slack to reopen the installation page."); + return; + } + installWindow.location.replace(result.authorizationUrl); + pendingWindow.current = null; + } finally { + token = ""; + pendingWindow.current?.close(); + pendingWindow.current = null; + } + } + + async function authorize() { + const result = await chatEndpointsApi.installSlackApp(endpoint.id); + // Current-tab navigation avoids popup blockers. The callback resumes this saved draft. + window.location.assign(result.authorizationUrl); + } + + return
+ {error || registration?.errorCode ?

+ {error ?? slackRegistrationErrorMessage(registration!.errorCode!)} +

: null} + {(stage === "app" && !created && !creating || configurationPending) && <> + {uncertain &&
+ Open Slack app settings + +
} + {(!uncertain || checkedNoApp) &&
+ +
+
    +
  1. In Slack app settings, find Your App Configuration Tokens and choose Generate Token.
  2. +
  3. Select the workspace where you want to install your bot.
  4. +
  5. Copy the Access Token and paste it below.
  6. +
+

Paperclip uses this token to create your app

+
+ + setConfigurationToken(event.target.value)} + aria-describedby="slack-configuration-token-help" /> +
} + } + {creating &&

Creating your Slack app. You can return to this saved setup.

} + {created &&

+ {endpoint.setup?.slackApp?.appName ?? "Your Slack app"} is created. + {stage === "credentials" && " Approve installation with your Slack account. Paperclip will link it to your signed-in Paperclip account and send you a welcome DM."} +

} + {stage === "credentials" && registration?.status === "credentials_saved" &&
+

Your installation credentials are saved. Retry connecting to finish setup. If Slack access has changed, authorize the same app again.

+ +
} + {stage === "app" &&
+ {onManaged && !registration && } + + +
} + + {configurationPending ? : stage === "app" ? : registration?.status === "credentials_saved" ? : } + +
; +} diff --git a/ui/src/pages/apps/chat/slack/SlackSetup.tsx b/ui/src/pages/apps/chat/slack/SlackSetup.tsx new file mode 100644 index 00000000000..e962d6d7cde --- /dev/null +++ b/ui/src/pages/apps/chat/slack/SlackSetup.tsx @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; +import type { ComponentProps } from "react"; +import { chatEndpointsApi } from "@/api/chatEndpoints"; +import { OwnSlackAppSetup } from "./OwnSlackAppSetup"; +import { ManagedSlackSetup } from "./ManagedSlackSetup"; +export function SlackSetup(props: ComponentProps & { autoStart?: boolean; reconnect?: boolean }) { + const choices = useQuery({ queryKey: ["slack-managed-workspaces", props.endpoint.companyId], queryFn: () => chatEndpointsApi.slackSetupOptions(props.endpoint.companyId), retry: false }); + return props.endpoint.setup?.slackSetupMethod === "managed" + ? + : props.onSaved(await chatEndpointsApi.update(props.endpoint.id, { slackSetupMethod: "managed" })) : undefined} />; +} diff --git a/ui/src/pages/apps/chat/slack/setup-state.test.ts b/ui/src/pages/apps/chat/slack/setup-state.test.ts new file mode 100644 index 00000000000..b21830596b1 --- /dev/null +++ b/ui/src/pages/apps/chat/slack/setup-state.test.ts @@ -0,0 +1,30 @@ +import { describe, expect, it } from "vitest"; +import type { ChatEndpoint } from "@/api/chatEndpoints"; +import { slackSetupState } from "./setup-state"; +const draft = (method?: "managed" | "automatic" | "manual" | "existing") => ({ id: "endpoint", setup: { step: "provider_setup", slackSetupMethod: method } }) as ChatEndpoint; +const state = (endpoint: ChatEndpoint | null, extra = {}) => slackSetupState({ endpoint, repairing: false, credentialsReady: false, identityReady: false, ...extra }); +describe("Slack setup capabilities and progress", () => { + it("does not flash token instructions while discovery is pending", () => { + expect(state(null).labels).toEqual(["Choose agent", "Connect"]); + }); + it("defaults only new drafts and preserves legacy manual drafts", () => { + expect(state(null, { defaultMethod: "managed" }).labels).toEqual(["Choose agent", "Connect"]); + expect(state(draft(), { defaultMethod: "managed" }).provisioned).toBe(false); + expect(state(draft("automatic"), { defaultMethod: "managed" }).labels).toHaveLength(4); + }); + it("keeps managed provider operations within the two visible stops", () => { + const endpoint = draft("managed"); + endpoint.setup!.slackRegistration = { status: "install", appId: "AAPP", managementUrl: "https://api.slack.com/apps/AAPP" }; + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + endpoint.setup!.slackRegistration.status = "credentials_saved"; + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + endpoint.providerAccountId = "TWORKSPACE"; + endpoint.setup!.slackRegistration.status = "configured"; + endpoint.setup!.slackAccount = { status: "linked", externalUserId: "UPERSON", paperclipUserId: "person", welcomeStatus: "sent" }; + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + endpoint.setup!.step = "test"; + expect(state(endpoint).complete).toBe(true); + expect(state(endpoint).toStage(1)).toBe(3); + expect(state(endpoint).toVisible(state(endpoint).availableStage)).toBe(1); + }); +}); diff --git a/ui/src/pages/apps/chat/slack/setup-state.ts b/ui/src/pages/apps/chat/slack/setup-state.ts new file mode 100644 index 00000000000..9e25bc230db --- /dev/null +++ b/ui/src/pages/apps/chat/slack/setup-state.ts @@ -0,0 +1,30 @@ +import type { ChatEndpoint } from "@/api/chatEndpoints"; +import { isProvisionedSlackSetup } from "@paperclipai/shared"; +export { isProvisionedSlackSetup }; +export function slackSetupNavigation(method: string | undefined, availableStage = 1) { + const managed = method === "managed"; + return { + labels: managed ? ["Choose agent", "Connect"] : isProvisionedSlackSetup(method) + ? ["Choose agent", "App configuration access token", "Install Slack app", "Send a message"] + : ["Choose agent", "Create Slack app", "Add credentials", "Verify Slack connection", "Connect your Slack account", "Try it"], + toVisible: (step: number) => managed ? Math.min(step, 1) : step, + toStage: (step: number) => managed && step === 1 ? availableStage : step, + }; +} + +/** Logical stages stay compatible with existing draft URLs; navigation is derived here. */ +export function slackSetupState(input: { endpoint: ChatEndpoint | null; repairing: boolean; credentialsReady: boolean; identityReady: boolean; defaultMethod?: string }) { + const { endpoint, repairing, credentialsReady, identityReady } = input; + const method = endpoint ? endpoint.setup?.slackSetupMethod : input.defaultMethod ?? "managed"; + const provisioned = isProvisionedSlackSetup(method); + const complete = provisioned && (endpoint?.setup?.step === "test" || endpoint?.setup?.step === "complete"); + const finalStage = provisioned ? 3 : 5; + const availableStage = !endpoint ? 0 + : provisioned && endpoint.setup?.slackRegistration?.status === "configured" && endpoint.setup.slackAccount?.status !== "linked" && endpoint.setup.step !== "complete" ? 2 + : !repairing && (endpoint.setup?.step === "test" || endpoint.setup?.step === "complete") + ? endpoint.setup.step !== "complete" && !provisioned && !identityReady ? 4 : finalStage + : provisioned && endpoint.setup?.slackRegistration?.status === "credentials_saved" ? 2 + : endpoint.providerAccountId && !repairing ? 3 + : credentialsReady || repairing || method === "existing" || provisioned && endpoint.setup?.slackRegistration?.appId ? 2 : 1; + return { ...slackSetupNavigation(method, availableStage), provisioned, complete, finalStage, availableStage }; +} diff --git a/ui/storybook/fixtures/SlackSetupFixture.tsx b/ui/storybook/fixtures/SlackSetupFixture.tsx index be7b266de05..dc1d26a3991 100644 --- a/ui/storybook/fixtures/SlackSetupFixture.tsx +++ b/ui/storybook/fixtures/SlackSetupFixture.tsx @@ -7,10 +7,10 @@ import { type ChatEndpoint } from "@/api/chatEndpoints"; import { storybookAgents, storybookAuthSession } from "./paperclipData"; import { defaultSlackAppConfiguration } from "@paperclipai/shared"; -export type SlackSetupScenario = "choose" | "create" | "manual" | "install" | "declined" | "uncertain" | "manifest_pending" | "recovery" | "verify" | "avatar_failed" | "welcome_failed" | "success"; +export type SlackSetupScenario = "choose" | "create" | "manual" | "install" | "declined" | "approval_denied" | "uncertain" | "manifest_pending" | "recovery" | "verify" | "avatar_failed" | "welcome_failed" | "success"; /** Production wizard over a local provider fixture. No requests go to Slack. */ -export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSetupScenario }) { +export function SlackSetupFixture({ scenario = "create", managed = false, managedAvailable = true, workspaceCount = 1, optionsDelayMs = 0, optionsError = false }: { scenario?: SlackSetupScenario; managed?: boolean; managedAvailable?: boolean; workspaceCount?: number; optionsDelayMs?: number; optionsError?: boolean }) { const [ready, setReady] = useState(false); const [signalVerification, setSignalVerification] = useState<() => void>(() => () => {}); useLayoutEffect(() => { @@ -20,14 +20,16 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet const endpoint: ChatEndpoint = { id: "slack-story", companyId: "company-storybook", provider: "slack", status: "draft", assignedAgentId: agent.id, assignedAgentName: agent.name, allowUnlinkedPeople: false, - setup: { step: "provider_setup", slackSetupMethod: scenario === "manual" ? "manual" : "automatic", + setup: { step: "provider_setup", slackSetupMethod: scenario === "manual" ? "manual" : managed ? "managed" : "automatic", slackApp: defaultSlackAppConfiguration(agent.name), webhookUrl: "https://ingress.example/api/chat-webhooks/slack-story/slack", slackOAuthCallbackUri: "https://board.example/api/chat-slack/oauth/callback", }, }; + const grantId = "11111111-1111-4111-8111-111111111111"; + let authorized = workspaceCount > 0 || new URLSearchParams(window.location.search).get("slackFixtureAuthorized") === "1"; const created = () => { - endpoint.setup!.slackRegistration = { status: "install", appId: "ASTORY", managementUrl: "https://api.slack.com/apps/ASTORY" }; + endpoint.setup!.slackRegistration = { status: "install", ...(managed ? { managerGrantId: grantId } : {}), appId: "ASTORY", managementUrl: "https://api.slack.com/apps/ASTORY" }; endpoint.setup!.slackAvatar = scenario === "avatar_failed" ? { status: "failed", errorCode: "slack_avatar_upload_failed" } : { status: "uploaded", uploadedAt: new Date().toISOString() }; }; let linked = false; @@ -37,11 +39,12 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet endpoint.setup!.slackAccount = { externalUserId: "UPERSON", paperclipUserId: storybookAuthSession.user.id, status: "linked", welcomeStatus: scenario === "welcome_failed" ? "failed" : "sent", ...(scenario === "welcome_failed" ? {} : { dmChannelId: "DSTORY" }) }; endpoint.status = "verifying"; endpoint.providerAccountId = "TSTORY"; endpoint.botExternalId = "USTORY"; endpoint.botUsername = "maya"; }; - if (["install", "declined", "recovery", "manifest_pending"].includes(scenario)) created(); + if (["install", "declined", "approval_denied", "recovery", "manifest_pending"].includes(scenario)) created(); if (scenario === "manifest_pending") endpoint.setup!.slackRegistration!.errorCode = "slack_manifest_update_pending"; - if (scenario === "declined") endpoint.setup!.slackRegistration!.errorCode = "slack_install_declined"; + if (scenario === "declined") endpoint.setup!.slackRegistration!.errorCode = managed ? "slack_approval_pending" : "slack_install_declined"; + if (scenario === "approval_denied") endpoint.setup!.slackRegistration!.errorCode = "slack_approval_denied"; if (scenario === "recovery") endpoint.setup!.slackRegistration = { ...endpoint.setup!.slackRegistration!, status: "credentials_saved", errorCode: "slack_configuration_incomplete" }; - if (scenario === "uncertain") endpoint.setup!.slackRegistration = { status: "uncertain", errorCode: "slack_creation_uncertain", managementUrl: "https://api.slack.com/apps" }; + if (scenario === "uncertain") endpoint.setup!.slackRegistration = { status: "uncertain", ...(managed ? { managerGrantId: grantId } : {}), errorCode: "slack_creation_uncertain", managementUrl: "https://api.slack.com/apps" }; if (["verify", "avatar_failed", "welcome_failed", "success"].includes(scenario)) installed(); if (["avatar_failed", "welcome_failed", "success"].includes(scenario)) { endpoint.setup!.webhookVerifiedAt = new Date().toISOString(); @@ -52,12 +55,26 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet window.fetch = async (input, init) => { const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, window.location.origin); const path = url.pathname; + if (path === "/api/companies/company-storybook/chat-slack/setup-options") { + if (optionsDelayMs) await new Promise(resolve => setTimeout(resolve, optionsDelayMs)); + if (optionsError) return Response.json({ error: "Slack setup unavailable" }, { status: 503 }); + return Response.json({ managedAvailable: managed && managedAvailable, defaultMethod: managed ? "managed" : "automatic", workspaces: managed && authorized ? Array.from({ length: Math.max(1, workspaceCount) }, (_, i) => ({ grantId: i ? "22222222-2222-4222-8222-222222222222" : grantId, workspaceId: `TSTORY${i}`, workspaceName: i ? "Research workspace" : "Paperclip", userId: "UPERSON" })) : [] }); + } + if (path === "/api/chat-endpoints/slack-story/slack/managed/authorize") { + authorized = true; + const returnUrl = new URL(window.location.href); + returnUrl.hash = ""; + returnUrl.searchParams.set("slackFixtureAuthorized", "1"); + return Response.json({ authorizationUrl: returnUrl.toString(), expiresAt: new Date(Date.now() + 600_000).toISOString() }); + } + if (path === "/api/chat-endpoints/slack-story/slack/managed/provision") { installed(); return Response.json({}); } if (path === "/api/companies/company-storybook/agents") return Response.json(agents); if (path === `/api/agents/${agent.id}`) return Response.json(agent); if (path === "/api/companies/company-storybook/chat-endpoints") { const input = JSON.parse(String(init?.body ?? "{}")); agent = agents.find(value => value.id === input.assignedAgentId) ?? agent; endpoint.assignedAgentId = agent.id; endpoint.assignedAgentName = agent.name; + endpoint.setup!.slackSetupMethod = input.slackSetupMethod ?? (managed ? "managed" : "automatic"); endpoint.setup!.slackApp = input.slackApp ?? defaultSlackAppConfiguration(agent.name); return Response.json(endpoint); } @@ -94,7 +111,7 @@ export function SlackSetupFixture({ scenario = "create" }: { scenario?: SlackSet }; setReady(true); return () => { window.fetch = original; }; - }, [scenario]); + }, [scenario, managed, managedAvailable, workspaceCount, optionsDelayMs, optionsError]); return