mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Add managed Slack setup with an independent own-app path (#15679)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Slack connects a person to an agent through an installed app and a verified identity. > - Independent installations must remain usable without Paperclip Cloud or manager credentials. > - Approved Cloud deployments can use Slack manager grants to create and install each agent's app. > - Both paths should share credential storage, identity checks, recovery, and the existing runtime. > - This pull request adds gated managed setup and keeps independent app setup under Advanced. > - It also makes ongoing connection settings and personal test messages easier to use. ## Linked Issues or Issue Description **Subsystem affected** Slack setup, connection settings, tenant credential storage, and the Cloud connector handoff. **Problem or motivation** Approved deployments need an installation flow without pasted configuration tokens. Open-source users must retain their independent Slack app flow. A connection manager also needs a simple way to check a bot from Settings. **Proposed solution** Use the managed path when the instance is configured to support it. Choose agent and Connect are the two visible stops. Continue starts authorization or installation. Use your own app remains under Advanced before creation. Save personal manager grants by company, Paperclip user, Slack user, workspace, and manager app. Reuse only verified identities for test messages. Unknown identities must complete a private Slack connection challenge before linking and receiving the test DM. **Alternatives considered** A shared bot would change each agent's Slack identity. Requiring Slack CLI or a hosted service for self-hosted users would remove independent installation. **Roadmap alignment** This maintainer-requested change improves Connected Apps and experimental Agent Chat. It adds provisioning and management controls to the existing Slack runtime. **Additional context** The companion Cloud broker profile is a separate deployment prerequisite. Managed setup remains gated. Existing-app adoption is deferred. ## What Changed - Extract Slack registration, provider requests, completion, customer-owned setup, managed setup, and personal manager grants into focused services. Preserve the compatibility facade and independent removal cleanup. - Add company-scoped manager grants and a registration reference through an additive, idempotent migration after current master. Keep runtime credentials separate from app-registration secrets. - Add authenticated manager authorization, sealed handoff, serialized refresh and revocation, app creation, managed installation, and installer-bound OAuth fallback. - Preserve creation intent, block automatic uncertain recreation, save credentials before downstream checks, and validate app, workspace, bot, scopes, and installer identity. - Add the two-stop production wizard and ordered stories. Keep the own-app, manual, existing-app, and legacy-draft paths. - Simplify Settings. Open Slack configuration targets the saved app. The test-message action has its own row. Remove the old manual-avatar section, channel-message section, and broken permissions link. - Add paginated account discovery and a private connection challenge for unknown accounts. Expired invitations stop waiting and offer a new private invitation. Save send receipts before provider dispatch and preserve confirmed delivery when activity logging fails. - Reinstall the saved managed app on explicit reconnect, replace revoked credentials, and resume completion. Persist reconnect intent and duplicate-request receipts without creating another app. - Update provider browser tests, recovery tests, secret redaction, setup documentation, and the agent prompt. Preserve current master's durable verification scheduling. ## Verification - Full workspace typecheck and repository build passed on `56d389d47a`. The subsequent reconnect retry fix in `288364eada` also passed UI typecheck and UI build. - 114 focused Slack registration cases and 13 production managed-wizard cases passed, including fresh-token reconnect, retry after installation failure, and duplicate reconnect requests. - All nine affected Slack browser cases passed against the production UI and a disposable fixture server. They cover managed setup, automatic setup, own-app selection, refresh, recovery, mobile behavior, and connection settings. - Eight focused real-database test-message cases passed. They cover a private ownership challenge, saved grant reuse, account pagination, revoked and conflicting links, workspace mismatch, disabled DMs, revoked membership, audit failure, ambiguous dispatch, and secret canaries. - UI token gates and the production Storybook build passed. - Greptile reviewed exact head `288364eada5d9c114fad991ff634612639d06672` at 5/5 with no actionable findings or unresolved review threads. - The full repository test suite and current-head CI are running. Their final results will replace this line before merge. - Earlier live feature previews created two bots in the authorized Paperclip Slack workspace and reused one personal manager grant. Local managed Continue created and installed a bot without a configuration token or a separate Add to Slack action. Avatars, welcome DMs, signed callbacks, and direct replies were observed. A Settings test DM also arrived in the signed-in user's Slack account. - Earlier staging thread qualification required manual publication recovery for one bot. Current-head live qualification remains required before enabling the production default. Fixture results do not establish Slack approval behavior in other workspaces. ## Risks - Personal manager tokens can create apps. They stay in scoped vault entries. The manager client secret stays at the broker. Company, actor/session, origin, and app identity checks apply throughout setup. - The additive migration must also work on qualification instances that applied earlier branch migrations. Table, column, index, and constraint creation is idempotent. - Creation and message timeouts can leave uncertain outcomes. Saved request IDs prevent automatic duplicate dispatch. - Unknown Slack identities must receive and complete their private challenge while signed in. Account selection alone does not grant Paperclip permissions. Existing revoked or conflicting links are not overwritten. - A development-only manager transport uses an explicitly configured private credential file for local qualification. Production ignores this transport; no manager credentials are bundled with open source. - Managed rollout requires Slack approval, the companion broker deployment, configured secrets, and final live qualification. Removing a connection does not delete its Slack app. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, code execution, and browser tools. The session does not expose the exact serving model ID or context-window size. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
7d9b93aee2
commit
f0e12c8519
54 files changed
+3966
-914
No files matched your search
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
@@ -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;
|
||||
+817
-4
@@ -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",
|
||||
+7
@@ -2332,6 +2332,13 @@
|
||||
"when": 1791807836288,
|
||||
"tag": "0334_rare_slipstream",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 335,
|
||||
"version": "7",
|
||||
"when": 1791832982875,
|
||||
"tag": "0335_absurd_bulldozer",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -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<Record<string, string>>().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')`),
|
||||
]);
|
||||
@@ -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<SlackRegistrationStatus>().notNull(),
|
||||
manifest: jsonb("manifest").$type<Record<string, unknown>>().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')`),
|
||||
]);
|
||||
@@ -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";
|
||||
@@ -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<typeof slackRegistrationStateSchema>;
|
||||
@@ -79,11 +80,33 @@ export const slackAccountStateSchema = z.object({
|
||||
}).strict();
|
||||
export type SlackAccountState = z.infer<typeof slackAccountStateSchema>;
|
||||
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<typeof slackManagerAuthorizationSchema>;
|
||||
export type SlackInstallAuthorization = z.infer<typeof slackInstallAuthorizationSchema>;
|
||||
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<typeof slackTestMessageSchema>;
|
||||
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<typeof slackTestMessageResultSchema>;
|
||||
|
||||
export function slackRegistrationErrorMessage(code: string): string {
|
||||
const messages: Record<string, string> = {
|
||||
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<typeof slackSetupOptionsSchema>;
|
||||
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<typeof slackManagedProvisionSchema>;
|
||||
export const slackManagedResultSchema = z.object({ authorization: slackInstallAuthorizationSchema.optional() });
|
||||
export type SlackManagedResult = z.infer<typeof slackManagedResultSchema>;
|
||||
@@ -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;
|
||||
|
||||
@@ -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(),
|
||||
|
||||
@@ -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<void>),
|
||||
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<void>),
|
||||
beforeAuth: null as null | (() => Promise<void>),
|
||||
beforeExchange: null as null | (() => Promise<void>),
|
||||
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<ReturnType<typeof managedFixture>>) => 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<boolean>();
|
||||
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<ReturnType<typeof fixture>>, patch: Record<string, unknown> = {}, 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: {
|
||||
|
||||
@@ -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", () => {
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -16,6 +16,13 @@ const SENSITIVE_KEYS = new Set<string>([
|
||||
// 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",
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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"],
|
||||
|
||||
@@ -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<void>;
|
||||
renderSlackAvatar?: (request: AgentAvatarRequest) => Promise<Buffer>;
|
||||
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<ChatEndpointSetupState["github"]>["stage"]) => {
|
||||
const record = await endpointRecord(endpointId);
|
||||
if (!record || record.endpoint.provider !== "github") throw notFound("GitHub bot not found");
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
const providerFailure = (code: string) => unprocessable(slackRegistrationErrorMessage(code), { code });
|
||||
const knownProviderErrors: Record<string, string> = {
|
||||
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<Buffer>;
|
||||
withLock: <T>(endpointId: string, work: (lease: CredentialMutationLeaseGuard) => Promise<T>) => Promise<T>;
|
||||
runtimeSigningSecret: (endpointId: string) => Promise<string>;
|
||||
runtimeBotToken: (endpointId: string) => Promise<string>;
|
||||
canWelcome: (endpointId: string, account: SlackAccountState) => Promise<boolean>;
|
||||
linkInstaller: (endpointId: string, user: Record<string, unknown>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise<void>;
|
||||
configure: (endpointId: string, credentials: Record<string, string>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise<unknown>;
|
||||
}) {
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const vault = secretService(db);
|
||||
let avatarPool: ReturnType<typeof createAgentAvatarPool> | 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<string, string> | 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<string, string>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard,
|
||||
patch: Partial<Registration> = {}, 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<number | null> {
|
||||
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";
|
||||
@@ -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<number | null> {
|
||||
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 };
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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<PaperclipCloudConnector,
|
||||
"getSlackManagerAppId" | "startAuthorization" | "claim" | "refresh" | "revoke"
|
||||
> & { 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<string, unknown>;
|
||||
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<string, string>, subject: string, companyId: string, initial: boolean): Promise<SealedConnectorCredentials> {
|
||||
let result: Record<string, unknown>;
|
||||
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"); }
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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<typeof createSlackManagerGrants>,
|
||||
provisioner: ReturnType<typeof createCustomerOwnedSetup>, completion: ReturnType<typeof createSlackCompletion>,
|
||||
installFallback: (endpointId: string, actor: SlackSetupActor) => Promise<{ authorizationUrl: string; expiresAt: string }>) {
|
||||
async function provision(endpointId: string, actor: SlackSetupActor, input: SlackManagedProvisionInput): Promise<SlackManagedResult> {
|
||||
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 };
|
||||
}
|
||||
@@ -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<typeof secretService>[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<SetupChoices> {
|
||||
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<Parameters<typeof db.transaction>[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<string, Promise<{ grant: Grant; token: string }>>();
|
||||
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<string, string> | 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 };
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
import { slackRegistrationErrorMessage } from "@paperclipai/shared";
|
||||
import { unprocessable } from "../../../../errors.js";
|
||||
export const object = (value: unknown): Record<string, unknown> => value !== null && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
export const providerFailure = (code: string) => unprocessable(slackRegistrationErrorMessage(code), { code });
|
||||
export const knownProviderErrors: Record<string, string> = {
|
||||
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<string, string> | 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<string, unknown>;
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<Buffer>;
|
||||
withLock: <T>(endpointId: string, work: (lease: CredentialMutationLeaseGuard) => Promise<T>) => Promise<T>;
|
||||
runtimeSigningSecret: (endpointId: string) => Promise<string>;
|
||||
runtimeBotToken: (endpointId: string) => Promise<string>;
|
||||
canWelcome: (endpointId: string, account: SlackAccountState) => Promise<boolean>;
|
||||
linkInstaller: (endpointId: string, user: Record<string, unknown>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise<void>;
|
||||
configure: (endpointId: string, credentials: Record<string, string>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard) => Promise<unknown>;
|
||||
};
|
||||
export function createSlackRegistrationStore(db: Db, options: SlackSetupOptions) {
|
||||
const fetchImpl = options.fetch ?? fetch;
|
||||
const vault = secretService(db);
|
||||
let avatarPool: ReturnType<typeof createAgentAvatarPool> | 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<string, string>, actor: SlackSetupActor, lease: CredentialMutationLeaseGuard,
|
||||
patch: Partial<Registration> = {}, 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<typeof createSlackRegistrationStore>;
|
||||
@@ -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);
|
||||
}) };
|
||||
}
|
||||
@@ -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<SlackSetupOptions, "fetch" | "withLock" | "runtimeBotToken" | "linkInstaller"> & {
|
||||
ensureAccount: (endpointId: string, user: Record<string, unknown>) => Promise<string>;
|
||||
createLinkIntent: (endpointId: string, principalId: string, expiresInSeconds: number) => Promise<{ confirmationUrl: string; expiresAt: string }>;
|
||||
};
|
||||
const human = (user: Record<string, unknown>, 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<SlackTestMessageResult> => {
|
||||
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<string, string>) {
|
||||
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 };
|
||||
});
|
||||
};
|
||||
}
|
||||
@@ -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 };
|
||||
|
||||
@@ -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<string | null> {
|
||||
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<string> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<ChatMock> {
|
||||
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" });
|
||||
|
||||
@@ -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<ChatEndpoint>(`/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<ChatEndpoint>(`/companies/${companyId}/chat-endpoints`, input),
|
||||
update: (
|
||||
endpointId: string,
|
||||
@@ -216,6 +218,11 @@ export const chatEndpointsApi = {
|
||||
photon?: PhotonChannelConfiguration;
|
||||
},
|
||||
) => api.post<ChatEndpoint>(`/chat-endpoints/${endpointId}/setup`, input),
|
||||
sendSlackTestMessage: (endpointId: string, input: import("@paperclipai/shared").SlackTestMessageInput) =>
|
||||
api.post<import("@paperclipai/shared").SlackTestMessageResult>(`/chat-endpoints/${endpointId}/slack/test-message`, input),
|
||||
slackSetupOptions: (companyId: string) => api.get<SlackSetupOptions>(`/companies/${companyId}/chat-slack/setup-options`),
|
||||
authorizeSlackManager: (endpointId: string) => api.post<import("@paperclipai/shared").SlackManagerAuthorization>(`/chat-endpoints/${endpointId}/slack/managed/authorize`, {}),
|
||||
provisionManagedSlack: (endpointId: string, input: SlackManagedProvisionInput) => api.post<SlackManagedResult>(`/chat-endpoints/${endpointId}/slack/managed/provision`, input),
|
||||
createSlackApp: (endpointId: string, input: SlackRegistrationInput) =>
|
||||
api.post<ChatEndpoint>(`/chat-endpoints/${endpointId}/slack/registration`, input),
|
||||
installSlackApp: (endpointId: string) =>
|
||||
|
||||
@@ -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 () => {
|
||||
|
||||
@@ -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() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{slackUrl && <Button asChild variant="outline"><a href={slackUrl} target="_blank" rel="noopener noreferrer">Open Slack <ExternalLink className="size-4" /></a></Button>}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{slackUrl && <Button asChild variant="outline"><a href={slackUrl} target="_blank" rel="noopener noreferrer">Open Slack configuration <ExternalLink className="size-4" /></a></Button>}
|
||||
{setupIncomplete ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -364,6 +364,7 @@ export function ChatEndpointDetail() {
|
||||
{["paused", "attention", "revoked"].includes(endpoint.status) && <StatusBadge status={endpoint.status} />}
|
||||
</div>
|
||||
</header>
|
||||
{endpoint.provider === "slack" && <SlackTestMessageButton key={endpoint.id} endpointId={endpoint.id} />}
|
||||
{endpoint.provider === "github" && <div hidden={activeTab !== "settings" && activeTab !== "access"}>
|
||||
<GitHubBotManagement key={endpoint.id} endpoint={endpoint} view={activeTab === "access" ? "access" : "settings"} />
|
||||
</div>}
|
||||
@@ -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.
|
||||
</p>
|
||||
) : (
|
||||
<div className="divide-y divide-border border-y border-border">
|
||||
<div className={endpoint.provider === "slack" ? "space-y-1" : "divide-y divide-border border-y border-border"}>
|
||||
{destinationResources.map((resource) => (
|
||||
<div key={resource.id} className="flex items-center gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
@@ -595,27 +589,7 @@ function Settings({
|
||||
queryClient.setQueryData(queryKeys.chatEndpoints.detail(endpointId), next);
|
||||
}}
|
||||
/>}
|
||||
{endpoint.provider === "slack" && (
|
||||
<SlackSetupAdvanced label="Message in a channel"><div className="space-y-2 text-sm">
|
||||
<p>Invite the bot to a channel, then mention it to start a conversation.</p>
|
||||
<div className="flex items-center justify-between gap-3 rounded-lg border border-border p-3">
|
||||
<code>{mentionMessage}</code>
|
||||
<Button size="icon" variant="ghost" aria-label={messageCopied ? "Message copied" : "Copy message"} onClick={() => {
|
||||
void copyTextToClipboard(mentionMessage).then(() => setMessageCopied(true), () => pushToast({ title: "Couldn’t copy the message", body: "Select and copy it manually.", tone: "error" }));
|
||||
}}>{messageCopied ? <Check className="size-4" /> : <Copy className="size-4" />}</Button>
|
||||
</div>
|
||||
</div></SlackSetupAdvanced>
|
||||
)}
|
||||
{endpoint.provider === "slack" && <SlackSetupAdvanced label="Agent avatar">
|
||||
{avatarAgent.isPending ? <p role="status" className="text-sm text-muted-foreground">Loading agent avatar…</p>
|
||||
: avatarAgent.isError ? <p role="alert" className="text-sm text-destructive">Couldn’t load the agent’s avatar. <button className="underline" onClick={() => void avatarAgent.refetch()}>Try again</button></p>
|
||||
: <SlackAvatarSettings
|
||||
agentName={avatarAgent.data?.name ?? endpoint.assignedAgentName}
|
||||
appName={endpoint.setup?.slackApp?.appName ?? defaultSlackAppName(avatarAgent.data?.name ?? endpoint.assignedAgentName)}
|
||||
avatarUrl={agentAvatarUrl(resolveAgentAppearance(avatarAgent.data?.appearance, endpoint.assignedAgentId), 512, 1, "rest", false, "paperclip-dark")}
|
||||
/>}
|
||||
</SlackSetupAdvanced>}
|
||||
{endpoint.provider === "slack" && <SlackSetupAdvanced label="Slack tools"><SlackToolsSettings companyId={endpoint.companyId} endpointId={endpointId} connectionId={endpoint.connectionId} /></SlackSetupAdvanced>}
|
||||
{endpoint.provider === "slack" && <SlackSetupAdvanced label="Slack tools"><SlackToolsSettings companyId={endpoint.companyId} endpointId={endpointId} /></SlackSetupAdvanced>}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -634,7 +608,7 @@ function SettingToggle({
|
||||
onChange: (value: boolean) => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3 border-y border-border py-3">
|
||||
<div className="flex items-center gap-3 py-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium">{label}</p>
|
||||
{detail && <p className="text-xs text-muted-foreground">{detail}</p>}
|
||||
|
||||
@@ -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 (
|
||||
<div className="max-w-2xl space-y-6">
|
||||
<ChatSetupNavigation labels={provider === "slack" ? ["Choose agent", "App configuration access token", "Install Slack app", "Send a message"] : undefined} step={0} availableStep={0} onSelect={onChat} />
|
||||
<ChatSetupNavigation labels={provider === "slack" ? slackLabels ?? ["Choose agent", "Connect"] : undefined} step={0} availableStep={0} onSelect={onChat} />
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">Choose how to connect</h1>
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
@@ -192,6 +194,7 @@ function ChatSdkEndpointSetup() {
|
||||
const [viewedStep, setViewedStep] = useState<number | null>(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<ChatEndpoint | null>(null);
|
||||
const [credentials, setCredentials] = useState<Record<string, string>>({});
|
||||
@@ -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 <ChatConnectionPurpose provider={provider} onChat={() => setPurpose("chat")} onTools={() => navigate(toolHref)} />;
|
||||
return <ChatConnectionPurpose provider={provider} slackLabels={slackNavigation.labels} onChat={() => setPurpose("chat")} onTools={() => navigate(toolHref)} />;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={automaticSlackComplete && step === tryStep ? "mx-auto w-full max-w-2xl space-y-6" : "max-w-2xl space-y-6"}>
|
||||
<ChatSetupNavigation
|
||||
labels={isSlack ? automaticSlack ? ["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"] : undefined}
|
||||
step={step}
|
||||
availableStep={availableStep}
|
||||
disabled={automaticBusy || createEndpoint.isPending || saveSlackDetails.isPending || setupAction.isPending || generateSetupSecret.isPending || testConnection.isPending}
|
||||
onSelect={setViewedStep}
|
||||
labels={isSlack ? slackNavigation.labels : undefined}
|
||||
step={isSlack ? slackNavigation.toVisible(step) : step}
|
||||
availableStep={isSlack ? slackNavigation.toVisible(availableStep) : availableStep}
|
||||
disabled={agentStepBusy || setupAction.isPending || generateSetupSecret.isPending || testConnection.isPending}
|
||||
onSelect={value => setViewedStep(isSlack ? slackNavigation.toStage(value) : value)}
|
||||
/>
|
||||
<div className="min-w-0 space-y-6">
|
||||
{step === 0 ? (
|
||||
@@ -541,15 +557,23 @@ function ChatSdkEndpointSetup() {
|
||||
emptyMessage="No active agents are available."
|
||||
/>}
|
||||
{isSlack && (selectedAgent || endpoint) && <SlackSetupAdvanced>
|
||||
<SlackAppDetails value={slackDetails} readOnly={!slackDetailsEditable || createEndpoint.isPending}
|
||||
<SlackAppDetails value={slackDetails} readOnly={!slackDetailsEditable || agentStepBusy}
|
||||
onChange={app => setSlackDraft({ agentId, app })}
|
||||
onBlur={() => void persistSlackDetails().catch(() => {})} />
|
||||
{saveSlackDetails.isError && <p role="alert" className="text-sm text-destructive">Couldn't save the Slack app details. Try again before continuing.</p>}
|
||||
{slackChoices.data?.defaultMethod === "managed" && (!endpoint || endpoint.setup?.slackSetupMethod === "managed") && !endpoint?.setup?.slackRegistration && <Button
|
||||
variant="link" className="h-auto p-0 text-muted-foreground"
|
||||
disabled={agentStepBusy || !slackDetailsValidation.success}
|
||||
onClick={() => useOwnSlackApp.mutate()}
|
||||
>Use your own app</Button>}
|
||||
</SlackSetupAdvanced>}
|
||||
{isSlack && !endpoint && !slackChoices.data && (slackChoices.isError
|
||||
? <div role="alert" className="text-sm text-destructive">Couldn't load Slack setup. <Button variant="link" disabled={slackChoices.isFetching} onClick={() => void slackChoices.refetch()}>Retry</Button></div>
|
||||
: <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />Loading Slack…</p>)}
|
||||
{provider === "github" && <GitHubAgentTrustWarning agent={selectedAgent} />}
|
||||
<SetupWizardFooter disabled={createEndpoint.isPending || saveSlackDetails.isPending} onSaveExit={() => void continueFromAgent(true)}>
|
||||
<SetupWizardFooter disabled={agentStepBusy || isSlack && !endpoint && Boolean(agentId) && !slackChoices.data} onSaveExit={() => void continueFromAgent(true)}>
|
||||
<Button
|
||||
disabled={!agentId || createEndpoint.isPending || saveSlackDetails.isPending || isSlack && !slackDetailsValidation.success}
|
||||
disabled={!agentId || agentStepBusy || isSlack && (!slackDetailsValidation.success || !endpoint && !slackChoices.data)}
|
||||
onClick={() => void continueFromAgent(false)}
|
||||
>
|
||||
{createEndpoint.isPending && (
|
||||
@@ -574,6 +598,7 @@ function ChatSdkEndpointSetup() {
|
||||
<ProviderConnectStep
|
||||
key={`${provider}:${endpoint.id}`}
|
||||
provider={provider}
|
||||
managedAutoStart={managedAutoStart && step !== 0}
|
||||
slackStage={step === 1 ? "app" : step === 3 ? "finish" : "credentials"}
|
||||
onSlackCredentialsContinue={() => setViewedStep(3)}
|
||||
onSlackVerificationContinue={() => setViewedStep(4)}
|
||||
@@ -684,6 +709,7 @@ function ProviderConnectStep({
|
||||
repairing,
|
||||
pending,
|
||||
onAutomaticBusy,
|
||||
managedAutoStart = false,
|
||||
onEndpointSaved,
|
||||
generatedWebhookSecret,
|
||||
generatingSetupSecret,
|
||||
@@ -702,6 +728,7 @@ function ProviderConnectStep({
|
||||
setCredentials: Dispatch<SetStateAction<Record<string, string>>>;
|
||||
repairing: boolean;
|
||||
pending: boolean;
|
||||
managedAutoStart?: boolean;
|
||||
onAutomaticBusy: (busy: boolean) => void;
|
||||
onEndpointSaved: (endpoint: ChatEndpoint) => void;
|
||||
generatedWebhookSecret: string;
|
||||
@@ -826,7 +853,7 @@ function ProviderConnectStep({
|
||||
...defaultSlackAppConfiguration(agentName),
|
||||
...(endpoint.setup?.command ? { command: endpoint.setup.command } : {}),
|
||||
};
|
||||
const automaticSlack = endpoint.setup?.slackSetupMethod === "automatic";
|
||||
const automaticSlack = isProvisionedSlackSetup(endpoint.setup?.slackSetupMethod);
|
||||
const slackValidation = slackAppConfigurationSchema.safeParse(slackApp);
|
||||
const saveSlackAndExit = () => navigate("/apps");
|
||||
const slackAppName = slackApp.appName.trim();
|
||||
@@ -1553,7 +1580,7 @@ function ProviderConnectStep({
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<h1 className="text-xl font-bold">{slackStage === "app" ? automaticSlack ? "App configuration access token" : "Create a Slack app" : automaticSlack ? "Install Slack app" : "Add Slack credentials"}</h1>
|
||||
<h1 className="text-xl font-bold">{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"}</h1>
|
||||
{repairing && !automaticSlack && (
|
||||
<p className="mt-1 text-sm text-muted-foreground">
|
||||
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({
|
||||
</div>
|
||||
</div>}
|
||||
</div>
|
||||
{automaticSlack && <SlackAutomaticSetup
|
||||
{automaticSlack && <SlackSetup
|
||||
key={endpoint.id}
|
||||
autoStart={managedAutoStart}
|
||||
reconnect={repairing}
|
||||
endpoint={endpoint} stage={slackStage} disabled={!endpoint.setup?.webhookUrl || !endpoint.setup?.slackOAuthCallbackUri?.startsWith("https://") || !slackValidation.success}
|
||||
saveDetails={async () => {}}
|
||||
onBusy={onAutomaticBusy} onSaved={onEndpointSaved} onContinue={onSlackAppCreated}
|
||||
|
||||
@@ -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<void>;
|
||||
onSaved: (endpoint: ChatEndpoint) => void;
|
||||
onBusy: (busy: boolean) => void;
|
||||
onManual: (existing: boolean) => Promise<void>;
|
||||
onContinue: () => void;
|
||||
onSaveExit: () => void;
|
||||
}) {
|
||||
const [configurationToken, setConfigurationToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [checkedNoApp, setCheckedNoApp] = useState(false);
|
||||
const requestId = useRef(crypto.randomUUID());
|
||||
const inFlight = useRef(false);
|
||||
const pendingWindow = useRef<Window | null>(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<void>) {
|
||||
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 <div className="space-y-5">
|
||||
{error || registration?.errorCode ? <p role="alert" className="text-sm text-destructive">
|
||||
{error ?? slackRegistrationErrorMessage(registration!.errorCode!)}
|
||||
</p> : null}
|
||||
{(stage === "app" && !created && !creating || configurationPending) && <>
|
||||
{uncertain && <div className="space-y-3 text-sm">
|
||||
<a href={registration?.managementUrl ?? "https://api.slack.com/apps"} target="_blank" rel="noopener noreferrer" className="underline underline-offset-4">Open Slack app settings <ExternalLink className="inline size-3" /></a>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={checkedNoApp} disabled={busy} onCheckedChange={value => setCheckedNoApp(value === true)} />
|
||||
I checked Slack and no app was created. Create a new app.
|
||||
</label>
|
||||
</div>}
|
||||
{(!uncertain || checkedNoApp) && <div className="space-y-2">
|
||||
<Button asChild size="lg" className="mb-4 h-auto w-full whitespace-normal py-4">
|
||||
<a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer">Get your App configuration access token <ExternalLink className="size-4" /></a>
|
||||
</Button>
|
||||
<div id="slack-configuration-token-help" className="space-y-3 pb-3 text-sm">
|
||||
<ol className="list-decimal space-y-2 pl-5">
|
||||
<li>In Slack app settings, find <strong>Your App Configuration Tokens</strong> and choose <strong>Generate Token</strong>.</li>
|
||||
<li>Select the workspace where you want to install your bot.</li>
|
||||
<li>Copy the <strong>Access Token</strong> and paste it below.</li>
|
||||
</ol>
|
||||
<p className="text-muted-foreground">Paperclip uses this token to create your app</p>
|
||||
</div>
|
||||
<label htmlFor="slack-configuration-token" className="text-sm font-medium">App configuration access token</label>
|
||||
<Input id="slack-configuration-token" type="password" autoComplete="off" spellCheck={false}
|
||||
value={configurationToken} disabled={busy} onChange={event => setConfigurationToken(event.target.value)}
|
||||
aria-describedby="slack-configuration-token-help" />
|
||||
</div>}
|
||||
</>}
|
||||
{creating && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" /> Creating your Slack app. You can return to this saved setup.</p>}
|
||||
{created && <p className="text-sm">
|
||||
<strong>{endpoint.setup?.slackApp?.appName ?? "Your Slack app"}</strong> 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."}
|
||||
</p>}
|
||||
{stage === "credentials" && registration?.status === "credentials_saved" && <div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Your installation credentials are saved. Retry connecting to finish setup. If Slack access has changed, authorize the same app again.</p>
|
||||
<Button variant="link" className="h-auto p-0" disabled={busy || disabled} onClick={() => void run(authorize)}>Authorize in Slack again</Button>
|
||||
</div>}
|
||||
{stage === "app" && <SlackSetupAdvanced><div className="flex flex-wrap gap-4 text-sm">
|
||||
<Button variant="link" className="h-auto p-0" disabled={busy || creating} onClick={() => void run(() => onManual(false))}>Create manually</Button>
|
||||
<Button variant="link" className="h-auto p-0" disabled={busy || creating} onClick={() => void run(() => onManual(true))}>Use an existing app</Button>
|
||||
</div></SlackSetupAdvanced>}
|
||||
<SetupWizardFooter onSaveExit={onSaveExit} disabled={busy}>
|
||||
{configurationPending ? <Button disabled={busy || disabled || !configurationToken.trim()} onClick={() => void run(create)}>
|
||||
{busy && <Loader2 className="size-4 animate-spin" />}Retry app configuration
|
||||
</Button> : stage === "app" ? <Button disabled={busy || disabled || creating || (!created && (!configurationToken.trim() || uncertain && !checkedNoApp))}
|
||||
onClick={() => created ? onContinue() : void run(create)}>
|
||||
{busy && <Loader2 className="size-4 animate-spin" />}{created ? "Continue to installation" : "Create Slack app"}
|
||||
</Button> : registration?.status === "credentials_saved" ? <Button disabled={busy} onClick={() => void run(async () => onSaved(await chatEndpointsApi.resumeSlackInstallation(endpoint.id)))}>
|
||||
{busy && <Loader2 className="size-4 animate-spin" />}Retry connecting
|
||||
</Button> : <Button disabled={busy || !created || disabled} onClick={() => void run(authorize)}>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <ExternalLink className="size-4" />}Install in Slack
|
||||
</Button>}
|
||||
</SetupWizardFooter>
|
||||
</div>;
|
||||
}
|
||||
export { OwnSlackAppSetup as SlackAutomaticSetup } from "./slack/OwnSlackAppSetup";
|
||||
@@ -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 <Slack account> 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 @<actual bot display name> 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 @<actual bot display name> 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.
|
||||
|
||||
|
||||
@@ -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<Extract<SlackTestMessageResult, { status: "connection_required" }>["accounts"] | null>(null);
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(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 <div className="space-y-2">
|
||||
<Button variant="outline" disabled={send.isPending || sent || Boolean(pendingConnection && !invitationExpired)} onClick={startTest}>
|
||||
{send.isPending ? <Loader2 className="size-4 animate-spin" /> : sent ? <Check className="size-4" /> : <Send className="size-4" />}
|
||||
{send.isPending ? "Sending…" : sent ? "Test message sent" : invitationExpired ? "Request a new invitation" : pendingConnection ? "Waiting for Slack connection…" : "Send me a test message"}
|
||||
</Button>
|
||||
{pendingConnection && <p role="status" className="text-sm text-muted-foreground">{invitationExpired ? "The Slack invitation expired." : "Open the invitation in Slack to connect your account."}</p>}
|
||||
{send.isError && <div role="alert" className="max-w-sm text-sm text-destructive">
|
||||
{send.error.message} <button className="underline" onClick={reset}>Dismiss</button>
|
||||
</div>}
|
||||
<Dialog open={accounts !== null} onOpenChange={(open) => { if (!open && !send.isPending) setAccounts(null); }}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Connect your Slack account</DialogTitle>
|
||||
<DialogDescription>Choose your account. Messages to this agent will use your Paperclip permissions.</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Input aria-label="Find your Slack account" placeholder="Find your name" value={search} onChange={event => setSearch(event.target.value)} />
|
||||
<div className="max-h-64 space-y-1 overflow-y-auto" role="radiogroup" aria-label="Your Slack account">
|
||||
{accounts?.filter(account => `${account.name} ${account.id}`.toLowerCase().includes(search.toLowerCase())).map(account =>
|
||||
<label key={account.id} className="flex cursor-pointer items-center gap-3 rounded-md border border-border p-3 text-sm">
|
||||
<input type="radio" name="slack-account" value={account.id} checked={selected === account.id} onChange={() => setSelected(account.id)} />
|
||||
<span>{account.name} <span className="text-muted-foreground">({account.id})</span></span>
|
||||
</label>)}
|
||||
{accounts?.length === 0 && <p className="text-sm text-muted-foreground">No available accounts found in this Slack workspace.</p>}
|
||||
</div>
|
||||
{nextCursor && <Button variant="ghost" disabled={send.isPending} onClick={() => send.mutate({ accountCursor: nextCursor })}>Load more Slack accounts</Button>}
|
||||
{send.isError && <p role="alert" className="text-sm text-destructive">{send.error.message}</p>}
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<Button variant="ghost" disabled={send.isPending} onClick={() => setAccounts(null)}>Cancel</Button>
|
||||
<Button disabled={!selected || send.isPending} onClick={() => send.mutate({ slackUserId: selected })}>{send.isPending ? "Sending invitation…" : "Send connection invitation"}</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>;
|
||||
}
|
||||
@@ -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 && (
|
||||
<Link
|
||||
className="text-sm underline underline-offset-4"
|
||||
to={`/apps/${connectionId}/permissions`}
|
||||
>
|
||||
Manage action permissions
|
||||
</Link>
|
||||
)}
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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(<StrictMode><QueryClientProvider client={client}><ManagedSlackSetup {...props()} {...overrides} /></QueryClientProvider></StrictMode>); });
|
||||
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<ChatEndpoint["setup"]>["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<ChatEndpoint["setup"]>["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<ChatEndpoint["setup"]>["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);
|
||||
});
|
||||
@@ -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<typeof OwnSlackAppSetup> & { autoStart?: boolean; reconnect?: boolean }) {
|
||||
const choices = useQuery({ queryKey: ["slack-managed-workspaces", endpoint.companyId], queryFn: () => chatEndpointsApi.slackSetupOptions(endpoint.companyId), retry: false });
|
||||
const [selectedGrant, setSelectedGrant] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(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<void>) {
|
||||
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 <div className="space-y-5">
|
||||
{message && <p role="alert" className="text-sm text-destructive">{message}</p>}
|
||||
{busy || choices.isPending || creating ? <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" />{busy ? needsAuthorization ? "Opening Slack…" : "Connecting your agent…" : creating ? "Setting up your Slack app…" : "Loading Slack…"}</p>
|
||||
: !choices.data?.managedAvailable ? <p className="text-sm">Managed Slack setup is unavailable. Try again later.</p>
|
||||
: <>
|
||||
{workspaces.length === 1 && <p className="text-sm">Add <strong>{endpoint.assignedAgentName}</strong> to <strong>{workspaces[0]!.workspaceName}</strong>.</p>}
|
||||
{workspaces.length > 1 && <Select value={grantId ?? ""} onValueChange={setSelectedGrant} disabled={busy || started}>
|
||||
<SelectTrigger aria-label="Slack workspace"><SelectValue placeholder="Choose a Slack workspace" /></SelectTrigger>
|
||||
<SelectContent>{workspaces.map(workspace => <SelectItem key={workspace.grantId} value={workspace.grantId}>{workspace.workspaceName}</SelectItem>)}</SelectContent>
|
||||
</Select>}
|
||||
{(!started || registration?.errorCode === "slack_manager_reauthorize") && workspaces.length > 0 && <div><Button variant="link" className="h-auto p-0" disabled={busy} onClick={() => void run(authorize)}>{started ? "Reconnect Slack workspace" : "Connect another Slack workspace"}</Button></div>}
|
||||
</>}
|
||||
{uncertain && <div className="space-y-3 text-sm">
|
||||
<a className="underline underline-offset-4" href={registration?.managementUrl ?? "https://api.slack.com/apps"} target="_blank" rel="noopener noreferrer">Open Slack app settings</a>
|
||||
<label className="flex items-center gap-2"><Checkbox checked={confirmed} onCheckedChange={value => setConfirmed(value === true)} disabled={busy} />I checked Slack and no app was created. Create a new app.</label>
|
||||
</div>}
|
||||
<SetupWizardFooter onSaveExit={onSaveExit} disabled={busy}>
|
||||
{choices.isError || !choices.isPending && !choices.data?.managedAvailable ? <Button disabled={busy || choices.isFetching} onClick={() => void choices.refetch()}>Retry</Button> : <Button disabled={busy || disabled || creating || choices.isPending || !choices.data?.managedAvailable || (workspaces.length > 1 && !grantId) || (uncertain && !confirmed)} onClick={() => void run(connect)}>
|
||||
{busy && <Loader2 className="size-4 animate-spin" />}{needsAuthorization && started ? "Reconnect Slack" : registration?.appId ? "Continue in Slack" : "Continue"}
|
||||
</Button>}
|
||||
</SetupWizardFooter>
|
||||
</div>;
|
||||
}
|
||||
@@ -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<void>;
|
||||
onSaved: (endpoint: ChatEndpoint) => void;
|
||||
onBusy: (busy: boolean) => void;
|
||||
onManual: (existing: boolean) => Promise<void>;
|
||||
onManaged?: () => Promise<void>;
|
||||
onContinue: () => void;
|
||||
onSaveExit: () => void;
|
||||
}) {
|
||||
const [configurationToken, setConfigurationToken] = useState("");
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [checkedNoApp, setCheckedNoApp] = useState(false);
|
||||
const requestId = useRef(crypto.randomUUID());
|
||||
const inFlight = useRef(false);
|
||||
const pendingWindow = useRef<Window | null>(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<void>) {
|
||||
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 <div className="space-y-5">
|
||||
{error || registration?.errorCode ? <p role="alert" className="text-sm text-destructive">
|
||||
{error ?? slackRegistrationErrorMessage(registration!.errorCode!)}
|
||||
</p> : null}
|
||||
{(stage === "app" && !created && !creating || configurationPending) && <>
|
||||
{uncertain && <div className="space-y-3 text-sm">
|
||||
<a href={registration?.managementUrl ?? "https://api.slack.com/apps"} target="_blank" rel="noopener noreferrer" className="underline underline-offset-4">Open Slack app settings <ExternalLink className="inline size-3" /></a>
|
||||
<label className="flex items-center gap-2">
|
||||
<Checkbox checked={checkedNoApp} disabled={busy} onCheckedChange={value => setCheckedNoApp(value === true)} />
|
||||
I checked Slack and no app was created. Create a new app.
|
||||
</label>
|
||||
</div>}
|
||||
{(!uncertain || checkedNoApp) && <div className="space-y-2">
|
||||
<Button asChild size="lg" className="mb-4 h-auto w-full whitespace-normal py-4">
|
||||
<a href="https://api.slack.com/apps" target="_blank" rel="noopener noreferrer">Get your App configuration access token <ExternalLink className="size-4" /></a>
|
||||
</Button>
|
||||
<div id="slack-configuration-token-help" className="space-y-3 pb-3 text-sm">
|
||||
<ol className="list-decimal space-y-2 pl-5">
|
||||
<li>In Slack app settings, find <strong>Your App Configuration Tokens</strong> and choose <strong>Generate Token</strong>.</li>
|
||||
<li>Select the workspace where you want to install your bot.</li>
|
||||
<li>Copy the <strong>Access Token</strong> and paste it below.</li>
|
||||
</ol>
|
||||
<p className="text-muted-foreground">Paperclip uses this token to create your app</p>
|
||||
</div>
|
||||
<label htmlFor="slack-configuration-token" className="text-sm font-medium">App configuration access token</label>
|
||||
<Input id="slack-configuration-token" type="password" autoComplete="off" spellCheck={false}
|
||||
value={configurationToken} disabled={busy} onChange={event => setConfigurationToken(event.target.value)}
|
||||
aria-describedby="slack-configuration-token-help" />
|
||||
</div>}
|
||||
</>}
|
||||
{creating && <p role="status" className="flex items-center gap-2 text-sm text-muted-foreground"><Loader2 className="size-4 animate-spin" /> Creating your Slack app. You can return to this saved setup.</p>}
|
||||
{created && <p className="text-sm">
|
||||
<strong>{endpoint.setup?.slackApp?.appName ?? "Your Slack app"}</strong> 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."}
|
||||
</p>}
|
||||
{stage === "credentials" && registration?.status === "credentials_saved" && <div className="space-y-2">
|
||||
<p className="text-sm text-muted-foreground">Your installation credentials are saved. Retry connecting to finish setup. If Slack access has changed, authorize the same app again.</p>
|
||||
<Button variant="link" className="h-auto p-0" disabled={busy || disabled} onClick={() => void run(authorize)}>Authorize in Slack again</Button>
|
||||
</div>}
|
||||
{stage === "app" && <SlackSetupAdvanced><div className="flex flex-wrap gap-4 text-sm">
|
||||
{onManaged && !registration && <Button variant="link" className="h-auto p-0" disabled={busy} onClick={() => void run(onManaged)}>Use managed setup</Button>}
|
||||
<Button variant="link" className="h-auto p-0" disabled={busy || creating} onClick={() => void run(() => onManual(false))}>Create manually</Button>
|
||||
<Button variant="link" className="h-auto p-0" disabled={busy || creating} onClick={() => void run(() => onManual(true))}>Use an existing app</Button>
|
||||
</div></SlackSetupAdvanced>}
|
||||
<SetupWizardFooter onSaveExit={onSaveExit} disabled={busy}>
|
||||
{configurationPending ? <Button disabled={busy || disabled || !configurationToken.trim()} onClick={() => void run(create)}>
|
||||
{busy && <Loader2 className="size-4 animate-spin" />}Retry app configuration
|
||||
</Button> : stage === "app" ? <Button disabled={busy || disabled || creating || (!created && (!configurationToken.trim() || uncertain && !checkedNoApp))}
|
||||
onClick={() => created ? onContinue() : void run(create)}>
|
||||
{busy && <Loader2 className="size-4 animate-spin" />}{created ? "Continue to installation" : "Create Slack app"}
|
||||
</Button> : registration?.status === "credentials_saved" ? <Button disabled={busy} onClick={() => void run(async () => onSaved(await chatEndpointsApi.resumeSlackInstallation(endpoint.id)))}>
|
||||
{busy && <Loader2 className="size-4 animate-spin" />}Retry connecting
|
||||
</Button> : <Button disabled={busy || !created || disabled} onClick={() => void run(authorize)}>
|
||||
{busy ? <Loader2 className="size-4 animate-spin" /> : <ExternalLink className="size-4" />}Install in Slack
|
||||
</Button>}
|
||||
</SetupWizardFooter>
|
||||
</div>;
|
||||
}
|
||||
@@ -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<typeof OwnSlackAppSetup> & { 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"
|
||||
? <ManagedSlackSetup {...props} />
|
||||
: <OwnSlackAppSetup {...props} onManaged={choices.data?.defaultMethod === "managed" && !props.endpoint.setup?.slackRegistration ? async () => props.onSaved(await chatEndpointsApi.update(props.endpoint.id, { slackSetupMethod: "managed" })) : undefined} />;
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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 <div className="space-y-6 p-6">
|
||||
<aside className="flex flex-wrap items-center gap-3 rounded-lg border border-border p-3 text-sm text-muted-foreground">
|
||||
Preview fixture: Slack consent, callbacks, and identity discovery are simulated.
|
||||
|
||||
@@ -11,7 +11,7 @@ import { useCompany } from "@/context/CompanyContext";
|
||||
import type { ChatEndpoint, ChatEndpointResource, ChatIdentityLink } from "@/api/chatEndpoints";
|
||||
import { storybookAgents } from "../../fixtures/paperclipData";
|
||||
|
||||
type Scenario = "populated" | "empty" | "error" | "channel-added";
|
||||
type Scenario = "populated" | "empty" | "error" | "channel-added" | "unlinked" | "invitation-expired";
|
||||
function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab?: string; scenario?: Scenario }) {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
@@ -26,7 +26,7 @@ function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab?
|
||||
assignedAgentId: agent.id, assignedAgentName: agent.name, providerAccountId: "TSTORY",
|
||||
providerAccountLabel: "Paperclip", botExternalId: "UARLO", botLabel: "Arlo", botUsername: "arlo-paperclip",
|
||||
allowUnlinkedPeople: false, allowDirectMessages: true,
|
||||
setup: { step: "complete", slackApp: { appName: "Arlo", botName: "Arlo", command: "/arlo" } },
|
||||
setup: { step: "complete", slackRegistration: { status: "configured", appId: "ASTORY" }, slackApp: { appName: "Arlo", botName: "Arlo", command: "/arlo" } },
|
||||
};
|
||||
let resources: ChatEndpointResource[] = scenario === "empty" ? [] : [
|
||||
{ id: "engineering", type: "channel", providerResourceId: "CENGINEERING", label: "#engineering", availability: "available", enabled: true },
|
||||
@@ -37,6 +37,8 @@ function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab?
|
||||
{ id: "grace", principalId: "grace", externalLabel: "Grace Hopper", status: "pending" },
|
||||
];
|
||||
let resourceReads = 0;
|
||||
let invitationSent = false;
|
||||
let invitationConfirmed = false;
|
||||
window.fetch = async (input, init) => {
|
||||
const url = new URL(typeof input === "string" ? input : input instanceof URL ? input.href : input.url, location.origin);
|
||||
const path = url.pathname;
|
||||
@@ -62,7 +64,18 @@ function ChatManagementPage({ tab = "settings", scenario = "populated" }: { tab?
|
||||
}
|
||||
return json(resources);
|
||||
}
|
||||
if (route === "principals") return json(principals);
|
||||
if (route === "slack/test-message") {
|
||||
if ((scenario === "unlinked" || scenario === "invitation-expired") && !invitationConfirmed) {
|
||||
if (!JSON.parse(String(init?.body)).slackUserId) return json({ status: "connection_required", accounts: [{ id: "UADA", name: "Ada Lovelace" }], nextCursor: null });
|
||||
invitationSent = true;
|
||||
return json({ status: "connection_pending", principalId: "ada", expiresAt: new Date(Date.now() + (scenario === "invitation-expired" ? 50 : 600_000)).toISOString() });
|
||||
}
|
||||
return json({ status: "sent", slackUrl: "https://app.slack.com/client/TSTORY/DADA" });
|
||||
}
|
||||
if (route === "principals") {
|
||||
if (invitationSent && scenario !== "invitation-expired") invitationConfirmed = true;
|
||||
return json(scenario === "invitation-expired" ? [] : principals);
|
||||
}
|
||||
if (route.endsWith("/link-intent")) return json({ confirmationUrl: "https://example.test/chat-identity/confirm?token=storybook-only" });
|
||||
if (route.endsWith("/link") && init?.method === "DELETE") {
|
||||
principals = principals.map(person => route.includes(person.id) ? { ...person, status: "revoked", paperclipUserId: null } : person);
|
||||
@@ -110,11 +123,38 @@ export const InvitePeople: Story = {
|
||||
},
|
||||
};
|
||||
export const Conversations: Story = { name: "4 · Conversations", args: { tab: "conversations" } };
|
||||
export const SlackAvatar: Story = {
|
||||
name: "5 · Slack avatar", args: { tab: "settings" },
|
||||
export const TestMessage: Story = {
|
||||
name: "5 · Test message", args: { tab: "settings" },
|
||||
play: async ({ canvasElement }) => {
|
||||
await userEvent.click(await within(canvasElement).findByText("Agent avatar", { selector: "summary" }));
|
||||
await expect(within(canvasElement).getByRole("img", { name: "Arlo’s Cliptoon avatar" })).toBeVisible();
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Send me a test message" }));
|
||||
await expect(await canvas.findByRole("button", { name: "Test message sent" })).toBeVisible();
|
||||
await expect(canvas.queryByRole("link", { name: "View message" })).not.toBeInTheDocument();
|
||||
await expect(canvas.getByRole("link", { name: "Open Slack configuration" })).toHaveAttribute("href", "https://api.slack.com/apps/ASTORY/oauth");
|
||||
await expect(canvas.queryByText("Agent avatar")).not.toBeInTheDocument();
|
||||
await expect(canvas.queryByText("Message in a channel")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const ConnectAndTest: Story = {
|
||||
name: "Connect and send test message", args: { tab: "settings", scenario: "unlinked" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Send me a test message" }));
|
||||
const page = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(await page.findByRole("radio", { name: "Ada Lovelace (UADA)" }));
|
||||
await userEvent.click(page.getByRole("button", { name: "Send connection invitation" }));
|
||||
await expect(await canvas.findByRole("button", { name: "Test message sent" })).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const InvitationExpired: Story = {
|
||||
name: "Expired Slack invitation", args: { tab: "settings", scenario: "invitation-expired" },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Send me a test message" }));
|
||||
const page = within(canvasElement.ownerDocument.body);
|
||||
await userEvent.click(await page.findByRole("radio", { name: "Ada Lovelace (UADA)" }));
|
||||
await userEvent.click(page.getByRole("button", { name: "Send connection invitation" }));
|
||||
await expect(await canvas.findByRole("button", { name: "Request a new invitation" })).toBeEnabled();
|
||||
},
|
||||
};
|
||||
export const ChannelAdded: Story = {
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { expect, userEvent, within, waitFor } from "storybook/test";
|
||||
import { SlackSetupFixture } from "../fixtures/SlackSetupFixture";
|
||||
|
||||
const start = "/PAP/apps/chat/connect?provider=slack&purpose=chat";
|
||||
const meta = {
|
||||
title: "Connections/Slack/Managed setup", component: SlackSetupFixture,
|
||||
args: { managed: true },
|
||||
parameters: { layout: "fullscreen", initialEntries: [`${start}&resume=slack-story`] },
|
||||
render: args => <SlackSetupFixture key={`${args.scenario}-${args.workspaceCount}-${args.managedAvailable}-${args.optionsDelayMs}-${args.optionsError}`} {...args} />,
|
||||
} satisfies Meta<typeof SlackSetupFixture>;
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof meta>;
|
||||
/** Walk the production wizard from the same entry point as the connector catalog. */
|
||||
export const DefaultJourney: Story = {
|
||||
name: "00 · Start here · Managed by default",
|
||||
args: { scenario: "choose" },
|
||||
parameters: { initialEntries: ["/PAP/apps/chat/connect?provider=slack"] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(canvas.queryByText("App configuration access token")).not.toBeInTheDocument();
|
||||
await userEvent.click(await canvas.findByRole("button", { name: /^Chat with an agent/ }));
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Choose an active agent" }));
|
||||
await userEvent.click(await within(document.body).findByRole("button", { name: "Select Maya" }));
|
||||
await waitFor(() => expect(canvas.getByRole("button", { name: "Continue" })).toBeEnabled());
|
||||
await userEvent.click(canvas.getByRole("button", { name: "Continue" }));
|
||||
await expect(await canvas.findByRole("heading", { name: "Send a message to your agent" })).toBeVisible();
|
||||
await expect(canvas.queryByLabelText("App configuration access token")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const ChooseAgent: Story = { name: "01 · Choose agent", args: { scenario: "choose" }, parameters: { initialEntries: [start] } };
|
||||
export const ChooseAgentAdvanced: Story = {
|
||||
...ChooseAgent, name: "01 · Choose agent · Advanced",
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Choose an active agent" }));
|
||||
await userEvent.click(await within(document.body).findByRole("button", { name: "Select Maya" }));
|
||||
await userEvent.click(await canvas.findByText("Advanced", { exact: true }));
|
||||
await expect(await canvas.findByRole("button", { name: "Use your own app" })).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const AuthorizeWorkspace: Story = { name: "02 · Connect · First authorization", args: { scenario: "create", workspaceCount: 0 } };
|
||||
export const AddToSlack: Story = { name: "02 · Connect · Returning workspace", args: { scenario: "create" }, play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByRole("heading", { name: "Send a message to your agent" })).toBeVisible();
|
||||
await expect(canvas.queryByText("Use your own app")).not.toBeInTheDocument();
|
||||
} };
|
||||
export const MultipleWorkspaces: Story = { name: "02 · Connect · Choose workspace", args: { scenario: "create", workspaceCount: 2 } };
|
||||
export const Connect: Story = { name: "02 · Connect · Waiting for a message", args: { scenario: "verify" } };
|
||||
export const Success: Story = { name: "02 · Connect · Success", args: { scenario: "success" } };
|
||||
export const ApprovalPending: Story = { name: "Recovery · Slack approval pending", args: { scenario: "declined" } };
|
||||
export const ApprovalDeclined: Story = { name: "Recovery · Slack approval declined", args: { scenario: "approval_denied" } };
|
||||
export const EventConfigurationPending: Story = { name: "Recovery · App configuration pending", args: { scenario: "manifest_pending" } };
|
||||
export const UncertainCreation: Story = { name: "Recovery · Uncertain creation", args: { scenario: "uncertain" } };
|
||||
export const SavedCredentials: Story = { name: "Recovery · Saved credentials", args: { scenario: "recovery" } };
|
||||
export const Unavailable: Story = { name: "Recovery · Managed setup unavailable", args: { scenario: "install", managedAvailable: false } };
|
||||
export const OwnApp: Story = { name: "Advanced · Use your own app", args: { scenario: "choose" }, parameters: { initialEntries: [start] }, play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Choose an active agent" }));
|
||||
await userEvent.click(await within(document.body).findByRole("button", { name: "Select Maya" }));
|
||||
await userEvent.click(await canvas.findByText("Advanced", { exact: true }));
|
||||
await userEvent.click(await canvas.findByRole("button", { name: "Use your own app" }));
|
||||
await expect(await canvas.findByRole("heading", { name: "App configuration access token" })).toBeVisible();
|
||||
} };
|
||||
export const SelfHosted: Story = { name: "Self-hosted · Own app directly", args: { scenario: "create", managed: false, managedAvailable: false } };
|
||||
export const Mobile: Story = { ...AddToSlack, name: "Mobile · Connect", globals: { viewport: { value: "mobile1", isRotated: false } } };
|
||||
export const MobileSuccess: Story = { ...Success, name: "Mobile · Success", globals: { viewport: { value: "mobile1", isRotated: false } } };
|
||||
|
||||
export const BrokerUnavailable: Story = {
|
||||
...DefaultJourney,
|
||||
name: "Recovery · Broker unavailable · No token fallback",
|
||||
args: { scenario: "choose", managedAvailable: false },
|
||||
play: async context => {
|
||||
const entry = within(context.canvasElement);
|
||||
await userEvent.click(await entry.findByRole("button", { name: /^Chat with an agent/ }));
|
||||
await userEvent.click(await entry.findByRole("button", { name: "Choose an active agent" }));
|
||||
await userEvent.click(await within(document.body).findByRole("button", { name: "Select Maya" }));
|
||||
await userEvent.click(await entry.findByRole("button", { name: "Continue" }));
|
||||
const canvas = within(context.canvasElement);
|
||||
await expect(await canvas.findByText("Managed Slack setup is unavailable. Try again later.")).toBeVisible();
|
||||
await expect(canvas.getByRole("button", { name: "Retry" })).toBeVisible();
|
||||
},
|
||||
};
|
||||
export const LoadingOptions: Story = {
|
||||
name: "Loading · Checking Slack setup",
|
||||
args: { scenario: "choose", optionsDelayMs: 3000 },
|
||||
parameters: { initialEntries: [start] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByRole("status")).toHaveTextContent("Loading Slack");
|
||||
await expect(canvas.getByRole("button", { name: "Continue" })).toBeDisabled();
|
||||
await expect(canvas.queryByText("App configuration access token")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
export const OptionsError: Story = {
|
||||
name: "Recovery · Setup lookup failed · No token fallback",
|
||||
args: { scenario: "choose", optionsError: true },
|
||||
parameters: { initialEntries: [start] },
|
||||
play: async ({ canvasElement }) => {
|
||||
const canvas = within(canvasElement);
|
||||
await expect(await canvas.findByRole("alert")).toHaveTextContent("Couldn't load Slack setup");
|
||||
await expect(canvas.queryByText("App configuration access token")).not.toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
Reference in new issue
Block a user