diff --git a/AGENTS.md b/AGENTS.md index 0e31e80d7c..c957ca41ea 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -198,6 +198,7 @@ When adding endpoints: - Keep routes and nav aligned with available API surface - Use company selection context for company-scoped pages - Surface failures clearly; do not silently ignore API errors +- Form and wizard footers: keep Save & exit (or Cancel/Back) left and the primary action right in the same vertically aligned row. Each step owns the entire footer; never append Save & exit as a separate row. See `DESIGN.md`. ## 10. Pull Request Requirements diff --git a/DESIGN.md b/DESIGN.md index 56c43fb5e6..16f428bffa 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -35,6 +35,14 @@ Existing tiers already in index.css (~80+ tokens) — extraction maps to these o 7. **Words are part of the system.** One name per concept across the entire UI — the canonical term is *task* (never *issue* or *ticket* in copy, labels, or empty states). Buttons name the action ("Approve hire," not "Submit"). Errors say what happened and what to do. Empty states say what to do first. **Note:** enforcing the task rename is a visible change and is explicitly OUT of the zero-visual-change extraction run; it happens in its own follow-up run. 8. **Agent-modifiable by design.** The system must be changeable via instructions: single token source, lint rules that enforce it, and this document kept current. A correct change should be expressible as "edit tokens + run checks," not "visit 40 files." +## Form and wizard footers + +Keep **Save & exit** (or Cancel/Back) and the primary Continue/Connect/Finish +action in one shared footer row, vertically centered. Put the subdued secondary +action on the left and the primary action on the right. A step owns its whole +footer: do not render Save & exit in a separate parent block below it. Check this +alignment in every step and conditional state, not just the first screen. + ## Contextual feedback Do not show a toast for task or run state already visible on the current screen. diff --git a/doc/DEVELOPING.md b/doc/DEVELOPING.md index 157589b00c..65c8c956c5 100644 --- a/doc/DEVELOPING.md +++ b/doc/DEVELOPING.md @@ -479,6 +479,68 @@ preserves all companies, agents, and secrets and ignores the bootstrap flags. The worktree execution setting is the only value it may reconcile in that case. +### Slack chat setup in a test drive + +Enable **Chat connectors** in Instance Settings, then open **Connectors → Slack → +Chat with an agent**. Before connecting, configure a public HTTPS URL that Slack +can reach. The setup page shows this requirement above the app details. +Slack app name, bot display name, and slash command are editable while the +connection is a draft; valid edits save when a field loses focus. **Create Slack +app** opens Slack with the generated manifest prefilled. **View Slack App Manifest** +opens the read-only manifest in a modal to inspect or copy it. Once connected, +the app details are locked so reconnecting cannot silently change the registered +command. Slack still requires workspace selection, installation approval, and +copying the bot token and signing secret back into Paperclip. + +After Slack verifies its Events Request URL, the wizard asks you to send +`/ connect`. This command works before a sender or channel is +allowed to start work. It records the Slack identity and sends a private, +one-time confirmation link that expires after 15 minutes; it creates no task +and grants no access. You can confirm **This is my Slack account** in the wizard, +or follow the private link and sign into Paperclip. Both paths check company +membership before linking, and future messages use the linked user's current +permissions. The wizard only lists identities that sent the connect command to +this endpoint during the current test. + +New Slack connections disable **Allow unlinked people** by default. The Access +page includes the shareable connect command and instructions for other users. +Other Slack users join through the same connect command after setup. A signed-in +nonmember can **Request access** from the confirmation page. This creates a +pending human join request in the company's existing admin approval queue; it +does not grant membership or link the identity. After approval, confirm the +identity, or send the connect command again if the link has expired. Successful +confirmation also queues a private Slack acknowledgement. + +Slack identity invitation pages retain the cloud authentication and bootstrap +checks. Signed-in nonmembers may open a valid private invitation to request +membership, but confirmation still requires membership in the invitation's +company. Preview, access-request, and confirmation APIs also enforce the chat +connector rollout flag on the server; invitees cannot read board experimental +settings before they join. Expired or consumed tokens grant no access. + +The final wizard step suggests `@ you there?`, then continuing in +the agent's thread. Select the bot from Slack's @mention suggestions so the +message includes a real mention. It detects a message or task command from the current user's +linked Slack identity during this setup session and shows a checkmark. This +conversation test is optional: **I've sent the test message** and **Skip test and +finish** both finish setup once webhook verification and account linking are +complete. The separate strict connection-test API retains its conversation and +delivery checks. + +### Chat activity pagination and callback diagnostics + +The connection Activity tab loads 25 records per page. `GET /api/chat-endpoints/:id/activity?limit=25` +returns `{ items, nextCursor }`; pass `cursor` to read older records. The limit must be 1–100. +A timestamp and ID cursor preserves records with equal timestamps and avoids shifts from new arrivals. +The first page refreshes automatically; older pages do not poll. Mutable action status can move an +entry forward in time, so this is a live ledger, not a historical snapshot. Requests without pagination +parameters retain the recent-100 array response for existing clients. + +Slack callback diagnostics tolerate HTTP between a TLS proxy and Paperclip when the public host, +port, and path still match. A changed authority or path remains stale. This comparison only affects +health display; it does not trust forwarded headers or alter Slack signature verification. + + ## Docker Quickstart (No local Node install) Build and run Paperclip in Docker: diff --git a/packages/shared/src/types/chat-channels.ts b/packages/shared/src/types/chat-channels.ts index 7c09cd06e9..e17e5e4bef 100644 --- a/packages/shared/src/types/chat-channels.ts +++ b/packages/shared/src/types/chat-channels.ts @@ -181,15 +181,24 @@ export interface ChatEndpointCallbackSurfaces { slashCommands: ChatEndpointCallbackSurfaceState; } +export interface SlackAppConfiguration { + appName: string; + botName: string; + command: string; +} + export interface ChatEndpointSetupState { step: "choose_agent" | "provider_setup" | "test" | "complete"; /** Server-generated boundary; only provider events at or after this time can complete setup. */ testStartedAt?: string | null; + /** Onboarding was finished without requiring a full conversation test. */ + testSkipped?: boolean; /** Set only after the provider has delivered a signed callback challenge. */ webhookVerifiedAt?: string | null; authorizationUrl?: string | null; providerUrl?: string | null; command?: string | null; + slackApp?: SlackAppConfiguration; webhookUrl?: string | null; messagingEndpoint?: string | null; /** Safe presence signal only; the secret value is returned once by its generation endpoint. */ @@ -279,6 +288,8 @@ export interface ChatIdentityLink { companyId: string; endpointId: string; principalId: string; + /** Latest discovery-only connect command received by this endpoint. */ + lastConnectAt?: string | null; externalLabel: string; externalDetail?: string | null; paperclipUserId?: string | null; @@ -462,6 +473,7 @@ export interface CreateChatEndpointInput { } export interface UpdateChatEndpointInput { + slackApp?: SlackAppConfiguration; allowDirectMessages?: boolean; allowGroupChats?: boolean; allowUnlinkedPeople?: boolean; diff --git a/packages/shared/src/validators/chat-channels.ts b/packages/shared/src/validators/chat-channels.ts index 4be41d5d61..75f805738b 100644 --- a/packages/shared/src/validators/chat-channels.ts +++ b/packages/shared/src/validators/chat-channels.ts @@ -76,8 +76,15 @@ export const createChatEndpointSchema = z }) .strict(); +export const slackAppConfigurationSchema = z.object({ + appName: z.string().trim().min(1, "Enter a Slack app name.").max(35), + botName: z.string().trim().min(1).max(80).regex(/^[a-z0-9._-]+$/, "Use lowercase letters, numbers, dots, hyphens, or underscores for the bot name."), + command: z.string().trim().min(2).max(32).regex(/^\/[a-z0-9_-]+$/, "Start the command with / and use lowercase letters, numbers, hyphens, or underscores."), +}).strict(); + export const updateChatEndpointSchema = z .object({ + slackApp: slackAppConfigurationSchema.optional(), allowDirectMessages: z.boolean().optional(), allowGroupChats: z.boolean().optional(), allowUnlinkedPeople: z.boolean().optional(), diff --git a/server/src/__tests__/chat-channels.integration.test.ts b/server/src/__tests__/chat-channels.integration.test.ts index 27009fa58d..ec4a96a053 100644 --- a/server/src/__tests__/chat-channels.integration.test.ts +++ b/server/src/__tests__/chat-channels.integration.test.ts @@ -48,6 +48,7 @@ import { chatEndpoints, chatExternalPrincipals, chatIdentityLinks, + joinRequests, chatMessageLinks, chatPublications, chatSdkState, @@ -119,6 +120,7 @@ import { } from "../services/chat-teams-personal-recipient.js"; import * as discordQuestionForms from "../services/chat-discord-question-forms.js"; import { issueService } from "../services/issues.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; import { getExternalChannelBindingSummary } from "../services/chat-channel-binding.js"; import { PaperclipRunnerToolAuthority } from "../services/native-runtime/paperclip-runner-tool-authority.js"; import { NativeChatAttachmentReadScope } from "../services/native-runtime/chat-attachment-read.js"; @@ -1575,15 +1577,11 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { }, "owner-user", ); - if (overrides?.allowUnlinkedPeople !== undefined) { - await context.service.update( - endpoint.id, - { - allowUnlinkedPeople: overrides.allowUnlinkedPeople, - }, - "owner-user", - ); - } + // Most transport fixtures exercise restricted guests explicitly. Production + // Slack creation defaults to linked accounts only (covered separately). + await context.service.update(endpoint.id, { + allowUnlinkedPeople: overrides.allowUnlinkedPeople ?? true, + }, "owner-user"); await context.service.configure( endpoint.id, { @@ -2912,6 +2910,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { assignedAgentId: fixture.assignedAgentId, assignedAgentName: "Maya", status: "draft", + allowUnlinkedPeople: false, setup: { command: expect.stringMatching(/^\/maya-[a-z0-9]{6}$/) }, }); const createdCommand = createResponse.body.setup.command as string; @@ -2947,6 +2946,26 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { ); }); + it("persists Slack manifest draft details and the registered command, then locks them once connected", async () => { + const fixture = await seedCompany(); + const { service } = createService(); + const app = routesApp(db, fixture.companyId, service); + const endpoint = await service.create(fixture.companyId, { provider: "slack", assignedAgentId: fixture.assignedAgentId }); + const slackApp = { appName: "Research Ops", botName: "research-ops", command: "/research" }; + await request(app).patch(`/api/chat-endpoints/${endpoint.id}`).send({ slackApp }).expect(200); + expect((await service.get(endpoint.id)).setup).toMatchObject({ slackApp, command: "/research" }); + const [stored] = await db.select().from(chatEndpoints).where(eq(chatEndpoints.id, endpoint.id)); + expect(stored.setup.command).toBe("/research"); + await request(app).patch(`/api/chat-endpoints/${endpoint.id}`) + .send({ slackApp: { ...slackApp, command: "not-a-command" } }).expect(400); + await db.update(chatEndpoints).set({ status: "verifying" }).where(eq(chatEndpoints.id, endpoint.id)); + await request(app).patch(`/api/chat-endpoints/${endpoint.id}`) + .send({ slackApp: { ...slackApp, command: "/different" } }).expect(409); + expect((await service.get(endpoint.id)).setup.command).toBe("/research"); + const github = await service.create(fixture.companyId, { provider: "github", assignedAgentId: fixture.assignedAgentId }); + await request(app).patch(`/api/chat-endpoints/${github.id}`).send({ slackApp }).expect(422); + }); + it("rejects chat endpoints for non-invokable agents", async () => { const fixture = await seedCompany(); const { service } = createService(); @@ -12546,6 +12565,8 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { }, "owner-user", ); + expect(endpoint.allowUnlinkedPeople).toBe(false); + await service.update(endpoint.id, { allowUnlinkedPeople: true }, "owner-user"); const configured = await service.configure( endpoint.id, @@ -12673,6 +12694,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { { provider: "slack", assignedAgentId: fixture.assignedAgentId }, "owner-user", ); + await service.update(endpoint.id, { allowUnlinkedPeople: true }, "owner-user"); await service.configure( endpoint.id, { @@ -12795,6 +12817,63 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { } }); + it("paginates mixed activity beyond 100 rows without losing timestamp ties", async () => { + const fixture = await seedCompany(); + const { service } = createService(new FakeChatSdkRuntime(), fakeSlackFetch()); + const endpoint = await service.create(fixture.companyId, { provider: "slack", assignedAgentId: fixture.assignedAgentId }, "owner-user"); + const other = await service.create(fixture.companyId, { provider: "slack", assignedAgentId: fixture.assignedAgentId }, "owner-user"); + const createdAt = new Date("2026-01-01T00:00:00.123Z"); + const deliveries = await db.insert(chatDeliveries).values(Array.from({ length: 110 }, (_, index) => ({ + companyId: fixture.companyId, endpointId: endpoint.id, + providerEventId: `page-${index}`, deduplicationKey: `page-${index}`, + eventKind: "message" as const, normalizedEvent: {}, state: "processed" as const, createdAt, + }))).returning(); + const actions = await db.insert(chatActions).values(Array.from({ length: 15 }, (_, index) => ({ + companyId: fixture.companyId, endpointId: endpoint.id, + kind: "slack_session_sync", providerActionId: `page-${index}`, + // Activity for session actions follows updatedAt, not createdAt. + createdAt: new Date("2025-01-01T00:00:00Z"), updatedAt: createdAt, + }))).returning(); + await db.insert(chatActions).values({ companyId: fixture.companyId, endpointId: other.id, kind: "slash_task_start", providerActionId: "other-endpoint", createdAt }); + const expected = [...deliveries, ...actions].map((row) => row.id).sort((a, b) => b.localeCompare(a)); + const found: string[] = []; + let cursor: string | undefined; + for (let page = 0; page < 5; page++) { + const result = await service.listActivityPage(endpoint.id, 25, cursor); + expect(result.items).toHaveLength(25); + found.push(...result.items.map((row) => row.id)); + expect(Boolean(result.nextCursor)).toBe(page < 4); + cursor = result.nextCursor ?? undefined; + if (page === 0) { + // New arrivals must not shift the older pages. + await db.insert(chatActions).values({ companyId: fixture.companyId, endpointId: endpoint.id, kind: "slash_task_start", providerActionId: "new-arrival", createdAt: new Date("2026-01-02T00:00:00Z") }); + } + } + expect(found).toEqual(expected); + expect(await service.listActivity(endpoint.id)).toHaveLength(100); + await expect(service.listActivityPage(endpoint.id, 0)).rejects.toMatchObject({ status: 400 }); + await expect(service.listActivityPage(endpoint.id, 101)).rejects.toMatchObject({ status: 400 }); + await expect(service.listActivityPage(endpoint.id, 25, "invalid")).rejects.toMatchObject({ status: 400 }); + }); + + it("recognizes Slack callbacks behind TLS termination without trusting forwarded headers", async () => { + const fixture = await seedCompany(); + const { endpoint, service } = await configuredSlackEndpoint(fixture); + const path = `/api/chat-webhooks/${endpoint.publicId}/slack`; + const body = JSON.stringify({ type: "url_verification", challenge: "proxy-check" }); + const request = signedSlackWebhookRequest({ url: `http://paperclip.example${path}`, contentType: "application/json", body }); + request.headers.set("x-forwarded-host", "untrusted.example"); + request.headers.set("x-forwarded-proto", "https"); + await service.handleWebhook(endpoint.publicId, "slack", request); + await expect(service.get(endpoint.id)).resolves.toMatchObject({ setup: { callbacksNeedUpdate: false, callbackSurfaces: { events: { status: "current" } } } }); + // A different public host or port is still drift, even with TLS termination. + for (const publicBaseUrl of ["https://moved.example", "https://paperclip.example:8443"]) { + const moved = createService(new FakeChatSdkRuntime(), fakeSlackFetch(), { publicBaseUrl }); + await expect(moved.service.get(endpoint.id)).resolves.toMatchObject({ setup: { callbacksNeedUpdate: true, callbackSurfaces: { events: { status: "stale" } } } }); + await moved.service.shutdown(); + } + }); + it("tracks Slack callback surfaces independently and reports public URL drift", async () => { const fixture = await seedCompany(); const { endpoint, runtime, service } = @@ -13898,6 +13977,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { { provider: "slack", assignedAgentId: fixture.assignedAgentId }, "owner-user", ); + await service.update(endpoint.id, { allowUnlinkedPeople: true }, "owner-user"); await service.configure( endpoint.id, { @@ -19349,6 +19429,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { { provider: "slack", assignedAgentId: fixture.assignedAgentId }, "owner-user", ); + await first.service.update(endpoint.id, { allowUnlinkedPeople: true }, "owner-user"); await first.service.configure( endpoint.id, { @@ -19487,6 +19568,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { { provider: "slack", assignedAgentId: fixture.assignedAgentId }, "owner-user", ); + await service.update(endpoint.id, { allowUnlinkedPeople: true }, "owner-user"); await service.configure( endpoint.id, { @@ -20058,6 +20140,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { { provider: "slack", assignedAgentId: fixture.assignedAgentId }, "owner-user", ); + await service.update(endpoint.id, { allowUnlinkedPeople: true }, "owner-user"); const signingSecret = "durable-ingress-signing-secret"; await service.configure( endpoint.id, @@ -20711,6 +20794,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { { provider: "slack", assignedAgentId: fixture.assignedAgentId }, "owner-user", ); + await service.update(endpoint.id, { allowUnlinkedPeople: true }, "owner-user"); await service.configure( endpoint.id, { @@ -22527,6 +22611,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { { provider: "slack", assignedAgentId: fixture.assignedAgentId }, "owner-user", ); + await service.update(endpoint.id, { allowUnlinkedPeople: true }, "owner-user"); await service.configure( endpoint.id, { @@ -27593,6 +27678,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { }, "owner-user", ); + await service.update(endpoint.id, { allowUnlinkedPeople: true }, "owner-user"); await service.configure( endpoint.id, { @@ -28460,6 +28546,7 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { [blockedEndpoint, "xoxb-blocked-outbox"], [readyEndpoint, "xoxb-ready-outbox"], ] as const) { + await service.update(endpoint.id, { allowUnlinkedPeople: true }, "owner-user"); await service.configure( endpoint.id, { @@ -46228,6 +46315,94 @@ describeEmbeddedPostgres("chat channel control-plane integration", () => { }); }); + it("discovers a Slack connect identity without starting work or granting access", async () => { + await instanceSettingsService(db).updateExperimental({ enableChatConnectors: true }); + const fixture = await seedCompany(); + const { callbacks, endpoint, runtime, service, wakeup } = await configuredSlackEndpoint(fixture, { allowUnlinkedPeople: false }); + const post = vi.fn(); + const postEphemeral = vi.fn(async () => ({ id: "connect-notice", usedFallback: false })); + const event = { + endpointId: endpoint.id, provider: "slack" as const, + event: { + channel: { id: "C-CONNECT", isDM: false, post, postEphemeral } as never, + command: endpoint.setup.command!, text: "connect", triggerId: "connect-discovery-1", + user: { userId: "U-CONNECT", userName: "connector", fullName: "Connect Person", isBot: false, isMe: false, isSystem: false }, + raw: { trigger_id: "connect-discovery-1" }, adapter: {} as never, openModal: async () => undefined, + }, + }; + await callbacks.onSlashCommand!(event); + await callbacks.onSlashCommand!(event); + await vi.waitFor(() => expect(postEphemeral).toHaveBeenCalledTimes(1)); + const [identity] = await service.listPrincipals(endpoint.id); + expect(identity).toMatchObject({ externalLabel: "Connect Person", status: "pending", paperclipUserId: null, lastConnectAt: expect.any(String) }); + expect(await service.listConversations(endpoint.id)).toHaveLength(0); + expect(wakeup).not.toHaveBeenCalled(); + expect(post).not.toHaveBeenCalled(); + expect(await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpoint.id), eq(chatActions.kind, "slack_connect")))).toHaveLength(1); + expect(await db.select().from(chatActions).where(and(eq(chatActions.endpointId, endpoint.id), eq(chatActions.kind, "slash_task_start")))).toHaveLength(0); + const other = await service.create(fixture.companyId, { provider: "slack", assignedAgentId: fixture.assignedAgentId }, "owner-user"); + await db.update(chatEndpoints).set({ status: "verifying", providerAccountId: (await service.get(endpoint.id)).providerAccountId }).where(eq(chatEndpoints.id, other.id)); + expect((await service.listPrincipals(other.id))[0]?.lastConnectAt).toBeNull(); + const notice = String((postEphemeral.mock.calls[0] as unknown[])[1]); + const token = /token=([A-Za-z0-9_-]+)/.exec(notice)![1]; + expect(notice).toContain("Connect your Paperclip account"); + expect((postEphemeral.mock.calls[0] as unknown[])[2]).toEqual({ fallbackToDM: false }); + expect(await service.previewIdentityLink(token, "owner-user")).toMatchObject({ selfService: true, canConfirm: true, externalLabel: "Connect Person" }); + expect(await service.setupTestStatus(endpoint.id, "owner-user")).toEqual({ messageReceivedAt: null }); + await expect(service.finishSlackSetup(endpoint.id, "owner-user")).rejects.toMatchObject({ status: 403 }); + const outsiderId = `outsider-${fixture.companyId}`; + const now = new Date(); + await db.insert(authUsers).values({ id: outsiderId, name: "Outside Person", email: `${outsiderId}@example.test`, emailVerified: true, createdAt: now, updatedAt: now }); + const outsideApp = routesApp(db, randomUUID(), service, outsiderId); + expect((await request(outsideApp).get(`/api/chat-identity-links/preview?token=${token}`)).body).toMatchObject({ selfService: true, canConfirm: false }); + await expect(service.confirmIdentityLink(token, outsiderId)).rejects.toMatchObject({ status: 403 }); + await request(outsideApp).post("/api/chat-identity-links/confirm").send({ token }).expect(403); + expect((await request(outsideApp).post("/api/chat-identity-links/request-access").send({ token })).body).toMatchObject({ status: "pending_approval" }); + await service.requestIdentityAccess(token, outsiderId, "test"); + expect(await db.select().from(joinRequests).where(eq(joinRequests.companyId, fixture.companyId))).toHaveLength(1); + expect(await db.select().from(companyMemberships).where(and(eq(companyMemberships.companyId, fixture.companyId), eq(companyMemberships.principalId, outsiderId)))).toHaveLength(0); + const adminOnly = await service.createLinkIntent(other.id, identity.principalId, 600); + const adminToken = new URL(adminOnly.confirmationUrl).searchParams.get("token")!; + expect((await request(outsideApp).get(`/api/chat-identity-links/preview?token=${adminToken}`)).status).toBe(404); + await expect(service.requestIdentityAccess(adminToken, outsiderId, "test")).rejects.toMatchObject({ status: 403 }); + const providerRuntime = runtime.endpoints.get(endpoint.id)!; + const originalThread = providerRuntime.thread.bind(providerRuntime); + const confirmationNotice = vi.fn(async () => ({ id: "linked-notice", threadId: "C-CONNECT" })); + providerRuntime.thread = (id: string) => ({ ...originalThread(id), postEphemeral: confirmationNotice }); + await service.confirmIdentityLink(token, "owner-user"); + await vi.waitFor(() => expect(confirmationNotice).toHaveBeenCalledWith("U-CONNECT", expect.stringContaining("account is connected"), { fallbackToDM: false })); + expect((await service.listPrincipals(endpoint.id))[0]).toMatchObject({ status: "linked", paperclipUserId: "owner-user" }); + await expect(service.test(endpoint.id)).rejects.toMatchObject({ details: { code: "chat_test_follow_up_missing" } }); + await expect(service.confirmIdentityLink(token, "owner-user")).rejects.toMatchObject({ status: 422 }); + await request(outsideApp).post("/api/chat-identity-links/request-access").send({ token }).expect(422); + expect(await service.setupTestStatus(endpoint.id, "owner-user")).toEqual({ messageReceivedAt: null }); + const finished = await service.finishSlackSetup(endpoint.id, "owner-user"); + expect(finished).toMatchObject({ status: "active", setup: { step: "complete", testSkipped: true } }); + expect(wakeup).not.toHaveBeenCalled(); + }); + + it.each(["message", "mention", "slash"] as const)("scopes Slack setup %s detection to the linked user and current test", async (kind) => { + const fixture = await seedCompany(); + const { endpoint, service } = await configuredSlackEndpoint(fixture); + const now = new Date(); + const [principal] = await db.insert(chatExternalPrincipals).values({ companyId: fixture.companyId, provider: "slack", providerAccountId: (await service.get(endpoint.id)).providerAccountId!, externalId: "U-TEST", kind: "user", displayName: "Test" }).returning(); + const intent = await service.createLinkIntent(endpoint.id, principal.id, 600); + const token = new URL(intent.confirmationUrl).searchParams.get("token")!; + await db.update(chatIdentityLinks).set({ expiresAt: new Date(now.getTime() - 1) }).where(eq(chatIdentityLinks.principalId, principal.id)); + await expect(service.confirmIdentityLink(token, "owner-user")).rejects.toMatchObject({ status: 422 }); + const fresh = await service.createLinkIntent(endpoint.id, principal.id, 600); + await service.confirmIdentityLink(new URL(fresh.confirmationUrl).searchParams.get("token")!, "owner-user"); + await db.insert(chatActions).values({ companyId: fixture.companyId, endpointId: endpoint.id, principalId: principal.id, kind: "slash_task_start", providerActionId: "old-test-message", createdAt: new Date(now.getTime() - 60_000), status: "processed" }); + expect(await service.setupTestStatus(endpoint.id, "owner-user")).toEqual({ messageReceivedAt: null }); + if (kind === "slash") { + await db.insert(chatActions).values({ companyId: fixture.companyId, endpointId: endpoint.id, principalId: principal.id, kind: "slash_task_start", providerActionId: "current-test-message", status: "processed" }); + } else { + await db.insert(chatDeliveries).values({ companyId: fixture.companyId, endpointId: endpoint.id, principalId: principal.id, providerEventId: "current-test-message", deduplicationKey: "current-test-message", eventKind: kind, normalizedEvent: {}, state: "processed" }); + } + expect(await service.setupTestStatus(endpoint.id, "owner-user")).toMatchObject({ messageReceivedAt: expect.any(String) }); + expect(await service.setupTestStatus(endpoint.id, "someone-else")).toEqual({ messageReceivedAt: null }); + }); + it("turns a Slack slash command into a new native thread and one Paperclip task", async () => { const fixture = await seedCompany(); const { callbacks, endpoint, runtime, service } = diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 9e29753867..f7b4698803 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -573,7 +573,7 @@ describe("openapi routes", () => { const activity = spec.paths["/api/chat-endpoints/{endpointId}/activity"].get.responses[ "200" - ].content["application/json"].schema.items; + ].content["application/json"].schema.oneOf[0].items; expect(activity.properties.actionType.enum).toEqual([ "slash_task_start", "provider_effect", diff --git a/server/src/routes/chat-channels.identity.test.ts b/server/src/routes/chat-channels.identity.test.ts index c54c785136..67aa4d9dec 100644 --- a/server/src/routes/chat-channels.identity.test.ts +++ b/server/src/routes/chat-channels.identity.test.ts @@ -1,12 +1,16 @@ import express, { type Request } from "express"; import type { Db } from "@paperclipai/db"; import request from "supertest"; -import { describe, expect, it, vi } from "vitest"; +import { beforeEach, describe, expect, it, vi } from "vitest"; import { HttpError, unprocessable } from "../errors.js"; import { errorHandler } from "../middleware/index.js"; import type { ChatChannelService } from "../services/chat-channels.js"; import { chatChannelRoutes } from "./chat-channels.js"; +const settings = vi.hoisted(() => ({ getExperimental: vi.fn() })); +vi.mock("../services/instance-settings.js", () => ({ instanceSettingsService: () => settings })); +beforeEach(() => settings.getExperimental.mockResolvedValue({ enableChatConnectors: true })); + const token = "synthetic-identity-preview-token-for-route-test"; const preview = { companyId: "company-a", @@ -30,7 +34,10 @@ function fixture( }, ) { const previewIdentityLink = vi.fn().mockResolvedValue(preview); + const confirmIdentityLink = vi.fn(); + const requestIdentityAccess = vi.fn(); const app = express(); + app.use(express.json()); app.use((req, _res, next) => { req.actor = actor; next(); @@ -38,15 +45,26 @@ function fixture( app.use( "/api", chatChannelRoutes({} as Db, { - service: { previewIdentityLink } as unknown as ChatChannelService, + service: { previewIdentityLink, confirmIdentityLink, requestIdentityAccess } as unknown as ChatChannelService, heartbeat: { wakeup: vi.fn() }, }), ); app.use(errorHandler); - return { app, previewIdentityLink }; + return { app, previewIdentityLink, confirmIdentityLink, requestIdentityAccess }; } describe("chat identity-link preview authority", () => { + it.each([false, undefined])("blocks every identity API when chat rollout is disabled (%s)", async (enabled) => { + settings.getExperimental.mockResolvedValue({ enableChatConnectors: enabled }); + const f = fixture(); + await request(f.app).get("/api/chat-identity-links/preview").query({ token }).expect(403); + await request(f.app).post("/api/chat-identity-links/confirm").send({ token }).expect(403); + await request(f.app).post("/api/chat-identity-links/request-access").send({ token }).expect(403); + expect(f.previewIdentityLink).not.toHaveBeenCalled(); + expect(f.confirmIdentityLink).not.toHaveBeenCalled(); + expect(f.requestIdentityAccess).not.toHaveBeenCalled(); + }); + it("returns the preview to a Board member of its exact company", async () => { const { app, previewIdentityLink } = fixture(); const response = await request(app) @@ -54,7 +72,7 @@ describe("chat identity-link preview authority", () => { .query({ token }); expect(response.status).toBe(200); expect(response.body).toEqual(preview); - expect(previewIdentityLink).toHaveBeenCalledExactlyOnceWith(token); + expect(previewIdentityLink).toHaveBeenCalledExactlyOnceWith(token, "viewer"); }); it.each([false, true])( diff --git a/server/src/routes/chat-channels.ts b/server/src/routes/chat-channels.ts index f0eac96246..260caa4c56 100644 --- a/server/src/routes/chat-channels.ts +++ b/server/src/routes/chat-channels.ts @@ -26,6 +26,7 @@ import { type ChatChannelServiceOptions, } from "../services/chat-channels.js"; import { accessService } from "../services/access.js"; +import { instanceSettingsService } from "../services/instance-settings.js"; import { recordChatWebhookStage } from "../services/chat-webhook-diagnostics.js"; import { createInviteRateLimiter, @@ -87,6 +88,18 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { const service = options.service ?? chatChannelService(db, options); const access = accessService(db); + async function assertIdentityLinkAccess(req: ExpressRequest): Promise { + assertBoard(req); + const userId = actorUserId(req); + if (!userId) throw badRequest("A signed-in Paperclip user is required"); + // Enforce rollout here: invited nonmembers cannot read the board's + // experimental-settings API. A private token never bypasses this gate. + if (!(await instanceSettingsService(db).getExperimental()).enableChatConnectors) { + throw forbidden("Chat connectors are not enabled on this instance"); + } + return userId; + } + async function assertConnectionManager( req: ExpressRequest, companyId: string, @@ -186,6 +199,20 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { res.json(await service.test(endpointId(req))); }); + router.post("/chat-endpoints/:endpointId/finish", async (req, res) => { + if (!(await assertEndpointManagementAccess(req, res))) return; + const userId = actorUserId(req); + if (!userId) throw badRequest("A signed-in Paperclip user is required"); + res.json(await service.finishSlackSetup(endpointId(req), userId)); + }); + router.get("/chat-endpoints/:endpointId/test-status", async (req, res) => { + if (!(await assertEndpointAccess(req, res, service))) return; + const userId = actorUserId(req); + if (!userId) throw badRequest("A signed-in Paperclip user is required"); + res.set("Cache-Control", "no-store"); + res.json(await service.setupTestStatus(endpointId(req), userId)); + }); + router.get("/chat-endpoints/:endpointId/resources", async (req, res) => { if (!(await assertEndpointAccess(req, res, service))) return; res.json(await service.listResources(endpointId(req))); @@ -244,28 +271,30 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { "/chat-identity-links/confirm", validate(confirmChatIdentityLinkSchema), async (req, res) => { - assertBoard(req); - const userId = actorUserId(req); - if (!userId) throw badRequest("A signed-in Paperclip user is required"); + const userId = await assertIdentityLinkAccess(req); res.json(await service.confirmIdentityLink(req.body.token, userId)); }, ); + router.post("/chat-identity-links/request-access", validate(confirmChatIdentityLinkSchema), async (req, res) => { + const userId = await assertIdentityLinkAccess(req); + res.json(await service.requestIdentityAccess(req.body.token, userId, req.ip ?? "unknown")); + }); + router.get("/chat-identity-links/preview", async (req, res) => { - assertBoard(req); + const userId = await assertIdentityLinkAccess(req); const token = typeof req.query.token === "string" ? req.query.token : ""; if (token.length < 32 || token.length > 4096) throw badRequest("A valid identity-link token is required"); - const preview = await getAccessibleResource( - req, - res, - service.previewIdentityLink(token).catch((error) => { - // Do not distinguish a valid foreign-company token from an invalid or - // expired token. Confirmation keeps its own validation contract. - if (error instanceof HttpError && error.status === 422) return null; - throw error; - }), - "Identity-link request not found", + res.set("Cache-Control", "no-store"); + const invitation = await service.previewIdentityLink(token, userId).catch((error) => { + if (error instanceof HttpError && error.status === 422) return null; + throw error; + }); + // A link privately issued to a signed Slack sender is an invitation to + // request membership. Other link intents retain company-access checks. + const preview = invitation?.selfService ? invitation : await getAccessibleResource( + req, res, Promise.resolve(invitation), "Identity-link request not found", ); if (!preview) return; res.json(preview); @@ -278,7 +307,13 @@ export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) { router.get("/chat-endpoints/:endpointId/activity", async (req, res) => { if (!(await assertEndpointAccess(req, res, service))) return; - res.json(await service.listActivity(endpointId(req))); + if (req.query.limit !== undefined || req.query.cursor !== undefined) { + if ((req.query.limit !== undefined && (typeof req.query.limit !== "string" || !/^\d+$/.test(req.query.limit))) + || (req.query.cursor !== undefined && typeof req.query.cursor !== "string")) throw badRequest("Invalid activity pagination parameters"); + res.json(await service.listActivityPage(endpointId(req), req.query.limit === undefined ? 25 : Number(req.query.limit), req.query.cursor as string | undefined)); + } else { + res.json(await service.listActivity(endpointId(req))); + } }); router.post( diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index ec4b76f2d1..2b1a21d03a 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -860,6 +860,8 @@ const chatIdentityLinkIntentResponseSchema = z const chatIdentityLinkPreviewResponseSchema = z .object({ + selfService: z.boolean().optional(), + canConfirm: z.boolean().optional(), endpointId: z.string().uuid(), companyId: z.string().uuid(), companyName: z.string(), @@ -1474,6 +1476,8 @@ const BOARD_ONLY_OPERATIONS = new Set([ "POST /api/chat-endpoints/{endpointId}/setup", "POST /api/chat-endpoints/{endpointId}/setup-secret", "POST /api/chat-endpoints/{endpointId}/test", + "POST /api/chat-endpoints/{endpointId}/finish", + "GET /api/chat-endpoints/{endpointId}/test-status", "POST /api/chat-endpoints/{endpointId}/photon/inspect", "GET /api/chat-endpoints/{endpointId}/resources", "PUT /api/chat-endpoints/{endpointId}/resources", @@ -1482,6 +1486,7 @@ const BOARD_ONLY_OPERATIONS = new Set([ "DELETE /api/chat-endpoints/{endpointId}/principals/{principalId}/link", "POST /api/chat-identity-links/confirm", "GET /api/chat-identity-links/preview", + "POST /api/chat-identity-links/request-access", "GET /api/chat-endpoints/{endpointId}/conversations", "GET /api/chat-endpoints/{endpointId}/activity", "POST /api/chat-endpoints/{endpointId}/deliveries/{deliveryId}/replay", @@ -2223,6 +2228,27 @@ registry.registerPath({ }, }); +registry.registerPath({ + method: "post", path: "/api/chat-endpoints/{endpointId}/finish", tags: ["chat-channels"], + summary: "Finish Slack onboarding with an optional conversation test", + description: "Requires a verified Slack webhook and an authorized identity linked to the current user. Records that a full conversation test was not required.", + request: { params: z.object({ endpointId: z.string().uuid() }) }, + responses: { 200: r.ok(chatEndpointResponseSchema), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict }, +}); +registry.registerPath({ + method: "get", path: "/api/chat-endpoints/{endpointId}/test-status", tags: ["chat-channels"], + summary: "Check for the current user's first setup message", + request: { params: z.object({ endpointId: z.string().uuid() }) }, + responses: { 200: r.ok(z.object({ messageReceivedAt: z.string().nullable() })), 401: r.unauthorized, 403: r.forbidden, 404: r.notFound }, +}); +registry.registerPath({ + method: "post", path: "/api/chat-identity-links/request-access", tags: ["chat-channels"], + summary: "Request company membership using a private Slack identity link", + description: "Creates a pending human join request for admin approval. Requires a valid, unexpired self-service token and a signed-in user. Does not grant access or link an identity.", + request: { body: jsonBody(confirmChatIdentityLinkSchema) }, + responses: { 200: r.ok(z.object({ status: z.enum(["member", "pending_approval"]) })), 401: r.unauthorized, 403: r.forbidden, 422: r.unprocessable }, +}); + registry.registerPath({ method: "post", path: "/api/chat-endpoints/{endpointId}/test", @@ -2346,7 +2372,7 @@ registry.registerPath({ tags: ["chat-channels"], summary: "Preview an external identity-link intent", description: - "Returns the company and provider identity that a valid, unexpired confirmation token would link. Company membership is checked before returning the preview.", + "Returns the company and provider identity that a valid, unexpired confirmation token would link. Admin-created links require company access. Private links issued to a signed Slack sender allow a signed-in recipient to preview that identity and request company access.", request: { query: z.object({ token: z.string().min(32).max(4096) }).strict(), }, @@ -2399,10 +2425,11 @@ registry.registerPath({ tags: ["chat-channels"], summary: "List chat endpoint delivery and publication activity", description: - "Returns the endpoint's recent redacted inbound-delivery and outbound-publication ledger, including whether a failed item can be replayed.", - request: { params: z.object({ endpointId: z.string().uuid() }) }, + "Returns the endpoint's recent redacted inbound-delivery and outbound-publication ledger, including whether a failed item can be replayed. Supply limit (1–100) for a page object and follow nextCursor for older activity. Requests without pagination parameters retain the legacy recent-100 array.", + request: { params: z.object({ endpointId: z.string().uuid() }), query: z.object({ limit: z.coerce.number().int().min(1).max(100).optional(), cursor: z.string().max(256).optional() }) }, responses: { - 200: r.ok(z.array(chatActivityResponseSchema)), + 200: r.ok(z.union([z.array(chatActivityResponseSchema), z.object({ items: z.array(chatActivityResponseSchema), nextCursor: z.string().nullable() })])), + 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, diff --git a/server/src/services/chat-channels.ts b/server/src/services/chat-channels.ts index a8531e21df..6edefa604a 100644 --- a/server/src/services/chat-channels.ts +++ b/server/src/services/chat-channels.ts @@ -89,6 +89,8 @@ import { issueThreadInteractions, issueQuestionResponseDeliveries, issues, + invites, + joinRequests, toolApplications, toolConnections, } from "@paperclipai/db"; @@ -520,6 +522,21 @@ function canonicalCallbackUrl(value: string): string | null { } } +// TLS terminates at a reverse proxy in many self-hosted deployments. The +// transport scheme is not evidence of URL drift; authority and path still are. +// This is diagnostic only: it does not trust forwarded headers or change auth. +function slackCallbackMatchesPublicUrl(observed: string, current: string): boolean { + const canonical = canonicalCallbackUrl(observed); + if (canonical === current) return true; + if (!canonical) return false; + const observedUrl = new URL(canonical); + const currentUrl = new URL(current); + return observedUrl.protocol === "http:" + && currentUrl.protocol === "https:" + && observedUrl.host === currentUrl.host + && observedUrl.pathname === currentUrl.pathname; +} + type SlackCallbackInspection = { surface: SlackCallbackSurface; url: string; @@ -2629,7 +2646,7 @@ function providerSetupState( return { status: currentUrl !== null && - canonicalCallbackUrl(observation.url) === currentUrl + slackCallbackMatchesPublicUrl(observation.url, currentUrl) ? "current" : "stale", observedAt: observation.observedAt, @@ -2642,6 +2659,8 @@ function providerSetupState( }; return { step, + testStartedAt: endpoint.setup.testStartedAt ?? null, + testSkipped: endpoint.setup.testSkipped ?? false, authorizationUrl: "https://api.slack.com/apps?new_app=1", providerUrl: "https://app.slack.com/", webhookUrl, @@ -2650,6 +2669,7 @@ function providerSetupState( callbacksNeedUpdate: Object.values(callbackSurfaces).some( (surface) => surface.status === "stale", ), + slackApp: endpoint.setup.slackApp, // Slack registers the slash command in the provider configuration. // Keep that identity immutable when the assigned agent is renamed; // deriving it remains only a compatibility path for older rows. @@ -5978,7 +5998,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { // enables that surface, even when this process is running against a // database created before the column default was hardened. allowGroupChats: input.provider !== "microsoft-teams", - allowUnlinkedPeople: input.provider !== "imessage-photon", + allowUnlinkedPeople: !["slack", "imessage-photon"].includes(input.provider), capabilities: CAPABILITIES[input.provider], setup: { step: "provider_setup", @@ -6023,6 +6043,19 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const values: Partial = { updatedAt: new Date(), }; + if (input.slackApp) { + if (existing.endpoint.provider !== "slack") { + throw unprocessable("Slack app details only apply to Slack connections"); + } + if (existing.endpoint.status !== "draft" || existing.endpoint.botExternalId) { + throw conflict("Slack app details can only be edited before connecting the app"); + } + values.setup = { + ...existing.endpoint.setup, + slackApp: input.slackApp, + command: input.slackApp.command, + }; + } if (input.allowDirectMessages !== undefined) values.allowDirectMessages = input.allowDirectMessages; if (input.allowGroupChats && existing.endpoint.provider === "imessage-photon" && (!existing.endpoint.botExternalId || existing.endpoint.botExternalId.startsWith("photon-project:"))) @@ -9879,7 +9912,44 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { return get(endpoint.id); } - async function test(endpointId: string) { + async function findAuthorizedIdentityLink(endpoint: EndpointRow, userId: string) { + return db.select({ id: chatIdentityLinks.id }).from(chatIdentityLinks) + .innerJoin(companyMemberships, and( + eq(companyMemberships.companyId, chatIdentityLinks.companyId), eq(companyMemberships.principalType, "user"), + eq(companyMemberships.principalId, userId), eq(companyMemberships.status, "active"), ne(companyMemberships.membershipRole, "viewer"), + )) + .where(and(eq(chatIdentityLinks.companyId, endpoint.companyId), eq(chatIdentityLinks.endpointId, endpoint.id), + eq(chatIdentityLinks.paperclipUserId, userId), eq(chatIdentityLinks.status, "linked"))) + .limit(1).then((rows) => rows[0] ?? null); + } + + async function setupTestStatus(endpointId: string, userId: string) { + const record = await endpointRecord(endpointId); + if (!record) throw notFound("Chat endpoint not found"); + const startedAt = record.endpoint.setup.testStartedAt; + if (!startedAt) return { messageReceivedAt: null }; + const links = await db.select({ principalId: chatIdentityLinks.principalId }).from(chatIdentityLinks).where(and( + eq(chatIdentityLinks.companyId, record.endpoint.companyId), eq(chatIdentityLinks.endpointId, endpointId), + eq(chatIdentityLinks.paperclipUserId, userId), eq(chatIdentityLinks.status, "linked"), + )); + if (!links.length) return { messageReceivedAt: null }; + const principalIds = links.map((link) => link.principalId); + const [delivery, command] = await Promise.all([ + db.select({ at: chatDeliveries.createdAt }).from(chatDeliveries).where(and( + eq(chatDeliveries.companyId, record.endpoint.companyId), eq(chatDeliveries.endpointId, endpointId), + inArray(chatDeliveries.principalId, principalIds), gte(chatDeliveries.createdAt, new Date(startedAt)), + inArray(chatDeliveries.eventKind, ["message", "mention"]), + )).orderBy(asc(chatDeliveries.createdAt)).limit(1).then((rows) => rows[0]), + db.select({ at: chatActions.createdAt }).from(chatActions).where(and( + eq(chatActions.companyId, record.endpoint.companyId), eq(chatActions.endpointId, endpointId), + inArray(chatActions.principalId, principalIds), eq(chatActions.kind, "slash_task_start"), gte(chatActions.createdAt, new Date(startedAt)), + )).orderBy(asc(chatActions.createdAt)).limit(1).then((rows) => rows[0]), + ]); + const at = [delivery?.at, command?.at].filter((value): value is Date => Boolean(value)).sort((a, b) => a.getTime() - b.getTime())[0]; + return { messageReceivedAt: at?.toISOString() ?? null }; + } + + async function test(endpointId: string, finishOptions?: { optionalSlackTestForUser: string }) { const initial = await endpointRecord(endpointId); if (!initial) throw notFound("Chat endpoint not found"); return withCredentialMutationLease( @@ -9901,135 +9971,151 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { const testStartedAt = testStartedAtValue ? new Date(testStartedAtValue) : null; - if ( - !endpoint.lastEventAt || - !testStartedAtValue || - !testStartedAt || - Number.isNaN(testStartedAt.getTime()) || - endpoint.lastEventAt < testStartedAt - ) { - throw conflict( - "Send the test message in the provider before completing setup", - { - code: "chat_test_message_missing", - }, - ); + const optionalSlackTest = Boolean(finishOptions?.optionalSlackTestForUser); + if (optionalSlackTest) { + if (endpoint.provider !== "slack" || !endpoint.setup.webhookVerifiedAt || !endpoint.providerAccountId) { + throw conflict("Verify the Slack connection before finishing setup"); + } + const linked = await findAuthorizedIdentityLink(endpoint, finishOptions!.optionalSlackTestForUser); + if (!linked) throw forbidden("Connect your Slack account before finishing setup"); } - const requiredTrigger = - ["telegram", "imessage-photon"].includes(endpoint.provider) - ? "direct_message" - : "subscribed_message"; - const qualifyingDelivery = await db - .select({ - id: chatDeliveries.id, - conversationId: chatDeliveries.conversationId, - processedAt: chatDeliveries.processedAt, - }) - .from(chatDeliveries) - .where( - and( - eq(chatDeliveries.companyId, endpoint.companyId), - eq(chatDeliveries.endpointId, endpoint.id), - eq(chatDeliveries.state, "processed"), - gte(chatDeliveries.processedAt, testStartedAt), - sql`${chatDeliveries.normalizedEvent}->>'trigger' = ${requiredTrigger}`, - ), - ) - .orderBy(desc(chatDeliveries.processedAt)) - .limit(1) - .then((rows) => rows[0] ?? null); - if ( - !qualifyingDelivery?.conversationId || - !qualifyingDelivery.processedAt - ) { - throw conflict( + if (!optionalSlackTest) { + if ( + !endpoint.lastEventAt || + !testStartedAtValue || + !testStartedAt || + Number.isNaN(testStartedAt.getTime()) || + endpoint.lastEventAt < testStartedAt + ) { + throw conflict( + "Send the test message in the provider before completing setup", + { + code: "chat_test_message_missing", + }, + ); + } + const requiredTrigger = ["telegram", "imessage-photon"].includes(endpoint.provider) - ? "Send the test direct message before completing setup" - : "Reply once without mentioning the agent before completing setup", - { code: "chat_test_follow_up_missing" }, - ); - } - const finalPublication = await db - .select({ - commentId: chatPublications.commentId, - payload: chatPublications.payload, - }) - .from(chatPublications) - .innerJoin( - issueComments, - and( - eq(issueComments.id, chatPublications.commentId), - eq(issueComments.companyId, chatPublications.companyId), - eq(issueComments.issueId, chatPublications.issueId), - eq(issueComments.authorType, "agent"), - eq(issueComments.authorAgentId, endpoint.assignedAgentId), - ), - ) - .innerJoin( - chatMessageLinks, - and( - eq(chatMessageLinks.companyId, chatPublications.companyId), - eq(chatMessageLinks.endpointId, chatPublications.endpointId), - eq( - chatMessageLinks.conversationId, - chatPublications.conversationId, + ? "direct_message" + : "subscribed_message"; + const qualifyingDelivery = await db + .select({ + id: chatDeliveries.id, + conversationId: chatDeliveries.conversationId, + processedAt: chatDeliveries.processedAt, + }) + .from(chatDeliveries) + .where( + and( + eq(chatDeliveries.companyId, endpoint.companyId), + eq(chatDeliveries.endpointId, endpoint.id), + eq(chatDeliveries.state, "processed"), + gte(chatDeliveries.processedAt, testStartedAt), + sql`${chatDeliveries.normalizedEvent}->>'trigger' = ${requiredTrigger}`, ), - eq(chatMessageLinks.deliveryId, qualifyingDelivery.id), - eq(chatMessageLinks.direction, "inbound"), - isNotNull(chatMessageLinks.commentId), - ), - ) - .innerJoin( - heartbeatRuns, - and( - eq(heartbeatRuns.id, issueComments.createdByRunId), - eq(heartbeatRuns.companyId, chatPublications.companyId), - eq(heartbeatRuns.agentId, endpoint.assignedAgentId), - eq(heartbeatRuns.status, "succeeded"), - eq( - sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, - sql`${chatPublications.issueId}::text`, + ) + .orderBy(desc(chatDeliveries.processedAt)) + .limit(1) + .then((rows) => rows[0] ?? null); + if ( + !qualifyingDelivery?.conversationId || + !qualifyingDelivery.processedAt + ) { + throw conflict( + ["telegram", "imessage-photon"].includes(endpoint.provider) + ? "Send the test direct message before completing setup" + : "Reply once without mentioning the agent before completing setup", + { code: "chat_test_follow_up_missing" }, + ); + } + const finalPublication = await db + .select({ + commentId: chatPublications.commentId, + payload: chatPublications.payload, + }) + .from(chatPublications) + .innerJoin( + issueComments, + and( + eq(issueComments.id, chatPublications.commentId), + eq(issueComments.companyId, chatPublications.companyId), + eq(issueComments.issueId, chatPublications.issueId), + eq(issueComments.authorType, "agent"), + eq(issueComments.authorAgentId, endpoint.assignedAgentId), ), - or( - sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`, - sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'commentId'`, - sql`coalesce(${heartbeatRuns.contextSnapshot} -> 'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`, + ) + .innerJoin( + chatMessageLinks, + and( + eq(chatMessageLinks.companyId, chatPublications.companyId), + eq(chatMessageLinks.endpointId, chatPublications.endpointId), + eq( + chatMessageLinks.conversationId, + chatPublications.conversationId, + ), + eq(chatMessageLinks.deliveryId, qualifyingDelivery.id), + eq(chatMessageLinks.direction, "inbound"), + isNotNull(chatMessageLinks.commentId), ), - ), - ) - .where( - and( - eq(chatPublications.companyId, endpoint.companyId), - eq(chatPublications.endpointId, endpoint.id), - eq( - chatPublications.conversationId, - qualifyingDelivery.conversationId, + ) + .innerJoin( + heartbeatRuns, + and( + eq(heartbeatRuns.id, issueComments.createdByRunId), + eq(heartbeatRuns.companyId, chatPublications.companyId), + eq(heartbeatRuns.agentId, endpoint.assignedAgentId), + eq(heartbeatRuns.status, "succeeded"), + eq( + sql`${heartbeatRuns.contextSnapshot} ->> 'issueId'`, + sql`${chatPublications.issueId}::text`, + ), + or( + sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'wakeCommentId'`, + sql`${chatMessageLinks.commentId}::text = ${heartbeatRuns.contextSnapshot} ->> 'commentId'`, + sql`coalesce(${heartbeatRuns.contextSnapshot} -> 'wakeCommentIds', '[]'::jsonb) ? ${chatMessageLinks.commentId}::text`, + ), ), - eq(chatPublications.state, "published"), - gte(chatPublications.publishedAt, qualifyingDelivery.processedAt), - ), - ) - .orderBy(desc(chatPublications.publishedAt)) - .then((rows) => - rows.find( - (row) => - row.payload.interactionId === undefined && - row.payload.progressState === undefined && - row.commentId !== null, - ), - ); - if (!finalPublication) { - throw conflict( - "Wait for the Paperclip agent to reply to the setup turn before completing setup", - { - code: "chat_test_round_trip_incomplete", - }, - ); + ) + .where( + and( + eq(chatPublications.companyId, endpoint.companyId), + eq(chatPublications.endpointId, endpoint.id), + eq( + chatPublications.conversationId, + qualifyingDelivery.conversationId, + ), + eq(chatPublications.state, "published"), + gte(chatPublications.publishedAt, qualifyingDelivery.processedAt), + ), + ) + .orderBy(desc(chatPublications.publishedAt)) + .then((rows) => + rows.find( + (row) => + row.payload.interactionId === undefined && + row.payload.progressState === undefined && + row.commentId !== null, + ), + ); + if (!finalPublication) { + throw conflict( + "Wait for the Paperclip agent to reply to the setup turn before completing setup", + { + code: "chat_test_round_trip_incomplete", + }, + ); + } } await options.setupTestActivationBarrier?.(); const expectedGeneration = runtimeGeneration(endpoint.setup); await db.transaction(async (tx) => { await credentialLease.assertOwned(tx); + if (optionalSlackTest) { + const linked = await tx.select({ principalId: chatIdentityLinks.principalId }).from(chatIdentityLinks).where(and( + eq(chatIdentityLinks.endpointId, endpoint.id), eq(chatIdentityLinks.paperclipUserId, finishOptions!.optionalSlackTestForUser), eq(chatIdentityLinks.status, "linked"), + )).limit(1).then((rows) => rows[0]); + if (!linked || !(await lockCurrentPrincipalAuthorization(tx, endpoint, linked.principalId)).allowed) throw forbidden("Your Slack account is no longer authorized"); + } const [activated] = await tx .update(chatEndpoints) .set({ @@ -10038,8 +10124,9 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ...endpoint.setup, step: "complete", testStartedAt: null, + testSkipped: optionalSlackTest, }, - healthMessage: "Connected", + healthMessage: optionalSlackTest ? "Connected; conversation test optional" : "Connected", activatedAt: endpoint.activatedAt ?? new Date(), updatedAt: new Date(), }) @@ -10064,12 +10151,17 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { status: "active", enabled: true, healthStatus: "healthy", - healthMessage: "Connected", + healthMessage: optionalSlackTest ? "Connected; conversation test optional" : "Connected", lastError: null, healthCheckedAt: new Date(), updatedAt: new Date(), }) .where(eq(toolConnections.id, endpoint.connectionId)); + if (optionalSlackTest) await logActivity(tx as unknown as Db, { + companyId: endpoint.companyId, actorType: "user", actorId: finishOptions!.optionalSlackTestForUser, + action: "chat.setup_completed", entityType: "chat_endpoint", entityId: endpointId, + details: { conversationTestOptional: true }, + }); await credentialLease.assertOwned(tx); }); return get(endpointId); @@ -23121,37 +23213,22 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { createHash("sha256") .update(JSON.stringify(event.event.raw)) .digest("hex"); - const queueSlackNotice = async ( - notice: string, - principalId?: string | null, - ) => { - const noticeKey = createHash("sha256") - .update( - JSON.stringify([ - event.event.command, - event.event.user.userId, - providerCommandId, - event.event.text, - ]), - ) - .digest("hex"); - const effect = await db.transaction((tx) => - stageProviderEffect(tx, { - endpoint: record.endpoint, - principalId: principalId ?? null, - providerActionId: `provider_effect:slash_notice:${noticeKey}`, - payload: { - version: 1, - effect: "ephemeral_message", - authorizationMode: "safe_notice", - threadId: event.event.channel.id, - userId: event.event.user.userId, - text: notice, - settleDelivery: false, - }, - runtimeContext, - }), - ); + const stageSlackNotice = (tx: DbTransaction, notice: string, principalId?: string | null) => + stageProviderEffect(tx, { + endpoint: record.endpoint, + principalId: principalId ?? null, + providerActionId: `provider_effect:slash_notice:${createHash("sha256").update(JSON.stringify([ + event.event.command, event.event.user.userId, providerCommandId, event.event.text, + ])).digest("hex")}`, + payload: { + version: 1, effect: "ephemeral_message", authorizationMode: "safe_notice", + threadId: event.event.channel.id, userId: event.event.user.userId, + text: notice, settleDelivery: false, + }, + runtimeContext, + }); + const queueSlackNotice = async (notice: string, principalId?: string | null) => { + const effect = await db.transaction((tx) => stageSlackNotice(tx, notice, principalId)); if (!effect) throw new Error("Slack notice was not persisted"); scheduleProviderEffect(effect.id, event.event.channel); }; @@ -23177,6 +23254,59 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { event.event.user, event.event.raw, ); + if (text.toLowerCase() === "connect") { + // Discovery must work before identity and channel access are granted. + // It records no task and grants no permissions; linking still requires + // explicit confirmation by a company member in the board. + if (principal.principal.kind !== "user" || principal.principal.isBot) return; + const effect = await db.transaction(async (tx) => { + if (!(await runtimeCallbackEndpoint(tx, event.endpointId, runtimeContext, ["verifying", "active"]))) { + throw conflict("This Slack connection changed; send the connect command again"); + } + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${record.endpoint.companyId}:${principal.principal.id}`}, 0))`); + const inserted = await tx.insert(chatActions).values({ + companyId: record.endpoint.companyId, endpointId: event.endpointId, + principalId: principal.principal.id, kind: "slack_connect", + providerActionId: `slack_connect:${providerCommandId}`, + payload: { version: 1, channelId: event.event.channel.id, userId: event.event.user.userId }, + status: "processed", result: { code: "slack_identity_discovered" }, + }).onConflictDoNothing().returning({ id: chatActions.id }); + if (!inserted.length) return null; + const currentLink = await tx.select().from(chatIdentityLinks).where(and( + eq(chatIdentityLinks.endpointId, event.endpointId), eq(chatIdentityLinks.principalId, principal.principal.id), + )).then((rows) => rows[0]); + let notice = "Your Slack account is already connected to Paperclip. You can return to Slack and message the agent."; + if (currentLink?.status !== "linked") { + const token = randomBytes(32).toString("base64url"); + const tokenHash = createHash("sha256").update(token).digest("hex"); + const expiresAt = new Date(Date.now() + 15 * 60 * 1000); + await tx.insert(chatIdentityLinks).values({ + companyId: record.endpoint.companyId, endpointId: event.endpointId, principalId: principal.principal.id, + status: "pending", confirmationTokenHash: tokenHash, expiresAt, + }).onConflictDoUpdate({ + target: [chatIdentityLinks.endpointId, chatIdentityLinks.principalId], + set: { paperclipUserId: null, status: "pending", confirmationTokenHash: tokenHash, expiresAt, confirmedAt: null, revokedAt: null, updatedAt: new Date() }, + }); + await tx.update(chatActions).set({ payload: { + version: 1, channelId: event.event.channel.id, userId: event.event.user.userId, identityLinkHash: tokenHash, + } }).where(eq(chatActions.id, inserted[0].id)); + const url = `${publicBaseUrl}/chat-identity/confirm?token=${encodeURIComponent(token)}`; + notice = publicBaseUrl + ? `[Connect your Paperclip account](${url}) — sign in and confirm this Slack identity. This private link expires in 15 minutes and works once. You can also confirm in the setup wizard. No agent work has started.` + : "Return to the Paperclip setup wizard to confirm your Slack account. No agent work has started."; + } + await logActivity(tx as unknown as Db, { + companyId: record.endpoint.companyId, actorType: "system", actorId: `chat:${principal.principal.id}`, + action: "chat.slack_identity_discovered", entityType: "chat_endpoint", entityId: event.endpointId, + details: { principalId: principal.principal.id }, + }); + const staged = await stageSlackNotice(tx, notice, principal.principal.id); + if (!staged) throw new Error("Slack identity link notice was not persisted"); + return staged; + }); + if (effect) scheduleProviderEffect(effect.id, event.event.channel); + return; + } const resource = await db .select() .from(chatEndpointResources) @@ -27691,6 +27821,17 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .select() .from(chatIdentityLinks) .where(eq(chatIdentityLinks.endpointId, endpointId)); + const connects = record.endpoint.provider === "slack" ? await db + .select({ principalId: chatActions.principalId, lastConnectAt: sql`max(${chatActions.createdAt})::text` }) + .from(chatActions) + .where(and( + eq(chatActions.companyId, record.endpoint.companyId), + eq(chatActions.endpointId, endpointId), + eq(chatActions.kind, "slack_connect"), + eq(chatActions.status, "processed"), + )) + .groupBy(chatActions.principalId) : []; + const connectByPrincipal = new Map(connects.map((row) => [row.principalId, new Date(row.lastConnectAt).toISOString()])); const userIds = links.flatMap((link) => link.paperclipUserId ? [link.paperclipUserId] : [], ); @@ -27724,6 +27865,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { paperclipUserId: link?.paperclipUserId ?? null, paperclipUserLabel: user?.name ?? user?.email ?? null, status: link?.status ?? "pending", + lastConnectAt: connectByPrincipal.get(principal.id) ?? null, }; }); } @@ -27807,7 +27949,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }; } - async function previewIdentityLink(token: string) { + async function previewIdentityLink(token: string, userId?: string | null) { const tokenHash = createHash("sha256").update(token).digest("hex"); const row = await db .select({ @@ -27843,7 +27985,18 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { if (!row || !row.link.expiresAt || row.link.expiresAt <= new Date()) { throw unprocessable("This identity-link request is invalid or expired"); } + if (!["active", "verifying"].includes(row.endpoint.status)) throw unprocessable("This connection is not available"); + const selfService = Boolean(await db.select({ id: chatActions.id }).from(chatActions).where(and( + eq(chatActions.companyId, row.link.companyId), eq(chatActions.endpointId, row.link.endpointId), + eq(chatActions.principalId, row.link.principalId), eq(chatActions.kind, "slack_connect"), + sql`${chatActions.payload}->>'identityLinkHash' = ${tokenHash}`, + )).limit(1).then((rows) => rows[0])); + const membership = userId ? await db.select({ status: companyMemberships.status }).from(companyMemberships).where(and( + eq(companyMemberships.companyId, row.link.companyId), eq(companyMemberships.principalType, "user"), eq(companyMemberships.principalId, userId), + )).then((rows) => rows[0]) : null; return { + selfService, + canConfirm: membership?.status === "active", endpointId: row.endpoint.id, companyId: row.endpoint.companyId, companyName: row.companyName, @@ -27862,6 +28015,43 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }; } + async function requestIdentityAccess(token: string, userId: string, requestIp: string) { + const preview = await previewIdentityLink(token, userId); + if (!preview.selfService) throw forbidden("This identity link cannot request access"); + if (preview.canConfirm) return { status: "member" as const }; + return db.transaction(async (tx) => { + await tx.execute(sql`select pg_advisory_xact_lock(hashtextextended(${`chat-join:${preview.companyId}:${userId}`}, 0))`); + const tokenHash = createHash("sha256").update(token).digest("hex"); + const validLink = await tx.select({ id: chatIdentityLinks.id }).from(chatIdentityLinks).where(and( + eq(chatIdentityLinks.companyId, preview.companyId), eq(chatIdentityLinks.endpointId, preview.endpointId), + eq(chatIdentityLinks.confirmationTokenHash, tokenHash), eq(chatIdentityLinks.status, "pending"), gt(chatIdentityLinks.expiresAt, new Date()), + )).for("update").then((rows) => rows[0]); + if (!validLink) throw unprocessable("This identity-link request is invalid or expired"); + const user = await tx.select({ email: authUsers.email }).from(authUsers).where(eq(authUsers.id, userId)).then((rows) => rows[0]); + if (!user) throw forbidden("Sign in to request company access"); + const existing = await tx.select({ id: joinRequests.id }).from(joinRequests).where(and( + eq(joinRequests.companyId, preview.companyId), eq(joinRequests.requestType, "human"), eq(joinRequests.status, "pending_approval"), + or(eq(joinRequests.requestingUserId, userId), sql`lower(${joinRequests.requestEmailSnapshot}) = ${user.email.toLowerCase()}`), + )).then((rows) => rows[0]); + if (existing) return { status: "pending_approval" as const }; + const now = new Date(); + const [invite] = await tx.insert(invites).values({ + companyId: preview.companyId, tokenHash: createHash("sha256").update(randomBytes(32)).digest("hex"), + inviteType: "company_join", allowedJoinTypes: "human", acceptedAt: now, expiresAt: now, + defaultsPayload: { human: { role: "operator" }, source: "slack_identity_link" }, + }).returning({ id: invites.id }); + const [request] = await tx.insert(joinRequests).values({ + inviteId: invite.id, companyId: preview.companyId, requestType: "human", status: "pending_approval", + requestIp, requestingUserId: userId, requestEmailSnapshot: user.email, + }).returning({ id: joinRequests.id }); + await logActivity(tx as unknown as Db, { + companyId: preview.companyId, actorType: "user", actorId: userId, action: "join.requested", + entityType: "join_request", entityId: request.id, details: { requestType: "human", source: "slack_identity_link", endpointId: preview.endpointId }, + }); + return { status: "pending_approval" as const }; + }); + } + async function confirmIdentityLink(token: string, paperclipUserId: string) { const tokenHash = createHash("sha256").update(token).digest("hex"); const link = await db @@ -27876,6 +28066,13 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .then((rows) => rows[0] ?? null); if (!link || !link.expiresAt || link.expiresAt <= new Date()) throw unprocessable("This identity-link request is invalid or expired"); + const endpointRecordForLink = await endpointRecord(link.endpointId); + if (!endpointRecordForLink || !["active", "verifying"].includes(endpointRecordForLink.endpoint.status)) throw unprocessable("This connection is not available"); + const connectReceipt = endpointRecordForLink.endpoint.provider === "slack" ? await db.select({ payload: chatActions.payload }).from(chatActions).where(and( + eq(chatActions.endpointId, link.endpointId), eq(chatActions.principalId, link.principalId), eq(chatActions.kind, "slack_connect"), + )).orderBy(desc(chatActions.createdAt)).limit(1).then((rows) => rows[0]) : null; + const confirmationRuntime = connectReceipt ? runtimeContexts.get((await runtimeFor(endpointRecordForLink.endpoint)) as object) : null; + let confirmationEffectId: string | null = null; const confirmed = await db.transaction(async (tx) => { await tx.execute( sql`select pg_advisory_xact_lock(hashtextextended(${`chat-identity:${link.companyId}:${link.principalId}`}, 0))`, @@ -27947,7 +28144,7 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }, ); } - return tx + const confirmedLink = await tx .update(chatIdentityLinks) .set({ paperclipUserId, @@ -27966,12 +28163,27 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { ) .returning({ endpointId: chatIdentityLinks.endpointId }) .then((rows) => rows[0] ?? null); + if (confirmedLink && connectReceipt && confirmationRuntime && + typeof connectReceipt.payload.channelId === "string" && typeof connectReceipt.payload.userId === "string") { + const effect = await stageProviderEffect(tx, { + endpoint: endpointRecordForLink.endpoint, principalId: link.principalId, + providerActionId: `provider_effect:identity_linked:${link.id}:${tokenHash}`, + payload: { version: 1, effect: "ephemeral_message", authorizationMode: "safe_notice", + threadId: connectReceipt.payload.channelId, userId: connectReceipt.payload.userId, + text: "Your Slack account is connected to Paperclip. Future messages use your Paperclip permissions.", settleDelivery: false }, + runtimeContext: confirmationRuntime, + }); + if (!effect) throw conflict("The Slack connection changed; try confirming again"); + confirmationEffectId = effect.id; + } + return confirmedLink; }); if (!confirmed) { throw conflict("This identity-link request was already used or expired", { code: "chat_identity_link_consumed", }); } + if (confirmationEffectId) scheduleProviderEffect(confirmationEffectId); return { ok: true, endpointId: confirmed.endpointId }; } @@ -28103,7 +28315,13 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { }); } - async function listActivity(endpointId: string) { + async function listActivity(endpointId: string, page?: { limit: number; before?: { createdAt: string; id: string } }) { + const limit = page ? page.limit + 1 : 100; + // Match the millisecond precision of the public timestamp and use a UUID + // tie-breaker so equal timestamps never skip records between pages. + const before = (date: AnyPgColumn, id: AnyPgColumn) => page?.before + ? sql`(date_trunc('milliseconds', ${date}), ${id}) < (${page.before.createdAt}::timestamptz, ${page.before.id}::uuid)` + : undefined; const recoveryIngress = alias(chatActions, "github_recovery_ingress"); const [ deliveries, @@ -28118,65 +28336,69 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { db .select() .from(chatDeliveries) - .where(eq(chatDeliveries.endpointId, endpointId)) - .orderBy(desc(chatDeliveries.createdAt)) - .limit(100), + .where(and(eq(chatDeliveries.endpointId, endpointId), before(chatDeliveries.createdAt, chatDeliveries.id))) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatDeliveries.createdAt})`), desc(chatDeliveries.id)) + .limit(limit), db .select() .from(chatPublications) - .where(eq(chatPublications.endpointId, endpointId)) - .orderBy(desc(chatPublications.createdAt)) - .limit(100), + .where(and(eq(chatPublications.endpointId, endpointId), before(chatPublications.createdAt, chatPublications.id))) + .orderBy(desc(sql`date_trunc('milliseconds', ${chatPublications.createdAt})`), desc(chatPublications.id)) + .limit(limit), db .select() .from(chatActions) .where( and( eq(chatActions.endpointId, endpointId), + before(chatActions.createdAt, chatActions.id), eq(chatActions.kind, "slash_task_start"), ), ) - .orderBy(desc(chatActions.createdAt)) - .limit(100), + .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id)) + .limit(limit), db .select() .from(chatActions) .where( and( eq(chatActions.endpointId, endpointId), + before(chatActions.createdAt, chatActions.id), eq(chatActions.kind, "provider_effect"), eq(chatActions.status, "delivery_unknown"), ), ) - .orderBy(desc(chatActions.createdAt)) - .limit(100), + .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id)) + .limit(limit), db .select() .from(chatActions) .where( and( eq(chatActions.endpointId, endpointId), + before(chatActions.createdAt, chatActions.id), eq(chatActions.kind, "github_webhook_ingress"), eq(chatActions.status, "failed"), sql`coalesce(${chatActions.result}->>'retryable', 'false') = 'false'`, ), ) - .orderBy(desc(chatActions.createdAt)) - .limit(100), + .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.createdAt})`), desc(chatActions.id)) + .limit(limit), db .select() .from(chatActions) .where( and( eq(chatActions.endpointId, endpointId), + before(chatActions.updatedAt, chatActions.id), inArray(chatActions.kind, [ "slack_session_sync", "slack_session_stop", ]), ), ) - .orderBy(desc(chatActions.updatedAt)) - .limit(100), + .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.updatedAt})`), desc(chatActions.id)) + .limit(limit), db .select({ action: chatActions, @@ -28195,27 +28417,34 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { .where( and( eq(chatActions.endpointId, endpointId), + before(chatActions.updatedAt, chatActions.id), eq(chatActions.kind, "github_webhook_recovery"), ), ) - .orderBy(desc(chatActions.updatedAt)) - .limit(100), + .orderBy(desc(sql`date_trunc('milliseconds', ${chatActions.updatedAt})`), desc(chatActions.id)) + .limit(limit), db .select() .from(chatSdkState) .where( and( eq(chatSdkState.endpointId, endpointId), + before(chatSdkState.updatedAt, chatSdkState.id), eq(chatSdkState.stateKey, GITHUB_RECOVERY_STATE_KEY), ), ) .limit(1), ]); - const ambiguousProviderEffectDeliveryIds = new Set( - providerEffects.flatMap((action) => - action.deliveryId ? [action.deliveryId] : [], - ), - ); + // A provider effect can be on another page. Replay safety must still + // consider every unresolved effect associated with these deliveries. + const ambiguousEffects = deliveries.length ? await db.select({ deliveryId: chatActions.deliveryId }) + .from(chatActions).where(and( + eq(chatActions.endpointId, endpointId), + eq(chatActions.kind, "provider_effect"), + eq(chatActions.status, "delivery_unknown"), + inArray(chatActions.deliveryId, deliveries.map((row) => row.id)), + )) : []; + const ambiguousProviderEffectDeliveryIds = new Set(ambiguousEffects.map((row) => row.deliveryId)); const transferRows = publications.length ? await db .select(fileTransferProjectionColumns) @@ -28509,8 +28738,32 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { : []; }), ] - .sort((a, b) => b.createdAt.localeCompare(a.createdAt)) - .slice(0, 100); + .sort((a, b) => b.createdAt.localeCompare(a.createdAt) || b.id.localeCompare(a.id)) + .slice(0, limit); + } + + async function listActivityPage(endpointId: string, limit = 25, cursor?: string) { + if (!Number.isInteger(limit) || limit < 1 || limit > 100) throw badRequest("Activity limit must be between 1 and 100"); + let before: { createdAt: string; id: string } | undefined; + if (cursor !== undefined) { + try { + if (cursor.length > 256) throw new Error("Invalid cursor"); + const value = JSON.parse(Buffer.from(cursor, "base64url").toString("utf8")); + if (!Array.isArray(value) || value.length !== 2 + || typeof value[0] !== "string" || new Date(value[0]).toISOString() !== value[0] + || typeof value[1] !== "string" || !/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value[1])) throw new Error("Invalid cursor"); + before = { createdAt: value[0], id: value[1] }; + } catch { throw badRequest("Invalid activity cursor"); } + } + const rows = await listActivity(endpointId, { limit, before }); + const items = rows.slice(0, limit); + const last = items.at(-1); + return { + items, + nextCursor: rows.length > limit && last + ? Buffer.from(JSON.stringify([last.createdAt, last.id])).toString("base64url") + : null, + }; } async function replayDelivery(endpointId: string, deliveryId: string) { @@ -37646,16 +37899,20 @@ export function chatChannelService(db: Db, options: ChatChannelServiceOptions) { configure, inspectPhoton, test, + finishSlackSetup: (endpointId: string, userId: string) => test(endpointId, { optionalSlackTestForUser: userId }), + setupTestStatus, handleWebhook, listResources, replaceResources, listPrincipals, createLinkIntent, previewIdentityLink, + requestIdentityAccess, confirmIdentityLink, revokeLink, listConversations, listActivity, + listActivityPage, replayDelivery, replayPublication, resolveAction, diff --git a/tests/e2e/chat-adapters-ui-providers.spec.ts b/tests/e2e/chat-adapters-ui-providers.spec.ts index b6d5d658f6..67bd60ace7 100644 --- a/tests/e2e/chat-adapters-ui-providers.spec.ts +++ b/tests/e2e/chat-adapters-ui-providers.spec.ts @@ -234,45 +234,13 @@ test.describe.serial("native chat adapter UI", () => { } if (provider.provider === "slack") { - await expect( - page.getByRole("heading", { name: "Finish Slack setup" }), - ).toBeVisible(); - await expect( - page.getByText( - `https://paperclip.example.test/api/chat-webhooks/public-slack/slack`, - { exact: true }, - ), - ).toBeVisible(); - await expect( - page.getByText("/maya-public", { exact: true }), - ).toBeVisible(); - await expect( - page.getByText( - /Slack's bare \/status command is not a Paperclip control/, - ), - ).toBeVisible(); - const saveChangesStep = page - .getByRole("listitem") - .filter({ hasText: "Save Changes" }); - await expect(saveChangesStep).toHaveCount(1); - await expect( - saveChangesStep.locator("..").getByRole("listitem"), - ).toHaveCount(1); - await expect(saveChangesStep).toHaveText( - "Return to App Manifest in Slack and click Save Changes. The copied manifest already contains the event, interaction, and slash-command URLs. Slack verifies the Events URL when you save; Paperclip records Interactivity and slash command health only after each signed callback is observed.", - ); - for (const removedManualStep of [ - "Event Subscriptions", - "Interactivity & Shortcuts", - "Slash Commands", - ]) { - await expect( - page.getByText(removedManualStep, { exact: true }), - ).toHaveCount(0); - } - await page - .getByRole("button", { name: "Start Slack message test" }) - .click(); + await expect(page.getByRole("heading", { name: "Verify Slack connection" })).toBeVisible(); + await expect(page.getByText("Slack needs to confirm that it can reach your Paperclip instance.")).toBeVisible(); + mock.setWebhookVerified(); + await expect(page.getByRole("heading", { name: "Connect your Slack account" })).toBeVisible(); + await expect(page.getByText("/maya-public connect", { exact: true })).toBeVisible(); + await page.getByRole("button", { name: "Link Test operator to my Paperclip account" }).click(); + await page.getByRole("button", { name: "Continue to message test" }).click(); } await expect( @@ -281,35 +249,37 @@ test.describe.serial("native chat adapter UI", () => { await expect( page.getByRole("button", { name: "I've sent the test message" }), ).toBeVisible(); - await expect( - page.getByRole("heading", { - name: "Link the account you’re testing", - }), - ).toBeVisible(); - await expect( - page.getByText( - /An observed external account is unlinked, and isolated guest work is off, so it cannot safely start Maya/, - ), - ).toBeVisible(); - await expect( - page.getByRole("button", { name: "Review identity access" }), - ).toBeVisible(); - await page - .getByRole("button", { name: "Review identity access" }) - .click(); - await expect(page).toHaveURL( - new RegExp( - `/${seed.prefix}/apps/chat/endpoint-${provider.provider}/access$`, - ), - ); - await expect( - page.getByRole("button", { name: "Continue setup" }), - ).toBeVisible(); - await page.getByRole("button", { name: "Continue setup" }).click(); - expect(new URL(page.url()).searchParams.get("reconnect")).toBeNull(); - await expect( - page.getByRole("heading", { name: `Try Maya in ${provider.name}` }), - ).toBeVisible(); + if (provider.provider !== "slack") { + await expect( + page.getByRole("heading", { + name: "Link the account you’re testing", + }), + ).toBeVisible(); + await expect( + page.getByText( + /An observed external account is unlinked, and isolated guest work is off, so it cannot safely start Maya/, + ), + ).toBeVisible(); + await expect( + page.getByRole("button", { name: "Review identity access" }), + ).toBeVisible(); + await page + .getByRole("button", { name: "Review identity access" }) + .click(); + await expect(page).toHaveURL( + new RegExp( + `/${seed.prefix}/apps/chat/endpoint-${provider.provider}/access$`, + ), + ); + await expect( + page.getByRole("button", { name: "Continue setup" }), + ).toBeVisible(); + await page.getByRole("button", { name: "Continue setup" }).click(); + expect(new URL(page.url()).searchParams.get("reconnect")).toBeNull(); + await expect( + page.getByRole("heading", { name: `Try Maya in ${provider.name}` }), + ).toBeVisible(); + } await expectSetupRail(page); await expectProviderTryInstructions(page, provider); expect(mock.configuredCredentialKeys).toEqual( @@ -336,26 +306,20 @@ test.describe.serial("native chat adapter UI", () => { await expect(page.getByText("Change agent", { exact: true })).toHaveCount( 0, ); - await expect(page.getByRole("tab")).toHaveCount(4); + await expect(page.getByRole("navigation", { name: "Chat connection" }).getByRole("link")).toHaveCount(4); for (const tab of ["Settings", "Access", "Conversations", "Activity"]) { - await expect(page.getByRole("tab", { name: tab })).toBeVisible(); + await expect(page.getByRole("navigation", { name: "Chat connection" }).getByRole("link", { name: tab , exact: true })).toBeVisible(); } await expect( page.getByRole("heading", { name: "Where this agent can work" }), ).toBeVisible(); if (provider.provider === "slack") { - await expect( - page.getByRole("heading", { name: "Slack command" }), - ).toBeVisible(); - await expect( - page.getByText("/maya-public", { exact: true }), - ).toBeVisible(); - await expect( - page.getByText( - /Slack's bare \/status command is not a Paperclip control/, - ), - ).toBeVisible(); + await expect(page.getByRole("heading", { name: "Chat in Slack" })).toBeVisible(); + 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 expect( page.getByRole("switch", { name: `Enable ${provider.resourceLabel}`, @@ -424,7 +388,7 @@ test.describe.serial("native chat adapter UI", () => { page.getByRole("switch", { name: "Allow unlinked people" }), ).toHaveCount(0); - await page.getByRole("tab", { name: "Access" }).click(); + await page.getByRole("navigation", { name: "Chat connection" }).getByRole("link", { name: "Access", exact: true }).click(); await expect( page.getByRole("heading", { name: "External identity access" }), ).toBeVisible(); @@ -471,7 +435,7 @@ test.describe.serial("native chat adapter UI", () => { await expect( page.getByText("Confirmation link copied", { exact: true }), ).toBeVisible(); - await page.getByRole("button", { name: "Revoke" }).click(); + await page.getByText("Grace Hopper", { exact: true }).locator("../..").getByRole("button", { name: "Revoke" }).click(); await expect .poll(() => mock.revokedPrincipalId) .toBe(`principal-${provider.provider}-linked`); @@ -479,12 +443,12 @@ test.describe.serial("native chat adapter UI", () => { page.getByText(`grace@${provider.provider}`, { exact: true }), ).toBeVisible(); - await page.getByRole("tab", { name: "Conversations" }).click(); + await page.getByRole("navigation", { name: "Chat connection" }).getByRole("link", { name: "Conversations", exact: true }).click(); await expect( page.getByRole("heading", { name: "Conversations" }), ).toBeVisible(); await expect( - page.getByText(`CHAT-123 · Investigate ${provider.name} delivery`), + page.getByText(`Investigate ${provider.name} delivery`, { exact: true }), ).toBeVisible(); await expect( page.getByText(provider.resourceLabel, { exact: true }), @@ -503,7 +467,7 @@ test.describe.serial("native chat adapter UI", () => { timeout: 8_000, }); - await page.getByRole("tab", { name: "Activity" }).click(); + await page.getByRole("navigation", { name: "Chat connection" }).getByRole("link", { name: "Activity", exact: true }).click(); await expect( page.getByRole("heading", { name: "Connection activity" }), ).toBeVisible(); @@ -515,9 +479,8 @@ test.describe.serial("native chat adapter UI", () => { await expect( page.getByText(`Published safe output to ${provider.name}`), ).toBeVisible(); - await expect( - page.getByText("Credential values and request bodies are redacted."), - ).toBeVisible(); + await expect(page.getByText("Recent activity", { exact: true })).toBeVisible(); + await page.getByText("Connection health and controls", { exact: true }).click(); const deliveryTimestamp = page .getByText(`Inbound ${provider.name} delivery could not be processed`) .locator("..") @@ -578,6 +541,7 @@ test.describe.serial("native chat adapter UI", () => { mock.setStatus("attention"); await page.reload(); + await page.getByText("Connection health and controls", { exact: true }).click(); await expect( page.getByRole("button", { name: "Reconnect", exact: true }), ).toBeVisible(); @@ -598,7 +562,7 @@ test.describe.serial("native chat adapter UI", () => { name: provider.provider === "github" ? "Reconnect GitHub App" - : provider.setupHeading, + : provider.provider === "slack" ? "Add Slack credentials" : provider.setupHeading, }), ).toBeVisible(); await expect( @@ -659,6 +623,7 @@ test.describe.serial("native chat adapter UI", () => { ).toBe(false); await page.goBack(); + await page.getByText("Connection health and controls", { exact: true }).click(); await expect( page.getByRole("heading", { name: "Connection activity" }), ).toBeVisible(); @@ -675,7 +640,7 @@ test.describe.serial("native chat adapter UI", () => { await expect( page.getByRole("button", { name: provider.setupButton }), ).toBeDisabled(); - mock.setGitHubWebhookVerified(); + mock.setWebhookVerified(); await expect( page.getByRole("button", { name: provider.setupButton }), ).toBeEnabled(); @@ -685,6 +650,7 @@ test.describe.serial("native chat adapter UI", () => { await page.goto( `/${seed.prefix}/apps/chat/endpoint-${provider.provider}/activity`, ); + await page.getByText("Connection health and controls", { exact: true }).click(); await page.getByRole("button", { name: "Remove connection" }).click(); const confirmation = page.getByRole("alertdialog"); await expect(confirmation).toContainText("Remove this connection?"); @@ -736,7 +702,7 @@ test.describe("iMessage Photon setup and management", () => { await expect(page.getByText(/enroll your sender in Users/)).toBeVisible(); await expect(page.getByRole("button", {name:/Copy \+1555/})).toHaveCount(0); await page.getByRole("button", {name:"I've sent the test message"}).click(); - await page.getByRole("tab", {name:"Settings"}).click(); + await page.getByRole("navigation", { name: "Chat connection" }).getByRole("link", { name: "Settings", exact: true }).click(); await expect(page.getByText(/Shared Photon project · direct messages only/)).toBeVisible(); await expect(page.getByRole("switch", {name:"Enable Family project"})).toBeDisabled(); await expect(page.getByRole("button", {name:"Copy dedicated number"})).toHaveCount(0); @@ -797,7 +763,7 @@ test.describe("iMessage Photon setup and management", () => { await page .getByRole("button", { name: "I've sent the test message" }) .click(); - await page.getByRole("tab", { name: "Settings" }).click(); + await page.getByRole("navigation", { name: "Chat connection" }).getByRole("link", { name: "Settings", exact: true }).click(); await expect( page.getByText(/replies are visible to everyone in that group/), ).toBeVisible(); @@ -806,9 +772,9 @@ test.describe("iMessage Photon setup and management", () => { await group.click(); await expect(group).toBeChecked(); await page.setViewportSize({ width: 390, height: 844 }); - await page - .getByRole("combobox", { name: "Page section" }) - .selectOption("activity"); + await page.getByRole("button", { name: "Open sidebar" }).click(); + await page.getByRole("navigation", { name: "Chat connection" }).getByRole("link", { name: "Activity", exact: true }).click(); + await page.getByText("Connection health and controls", { exact: true }).click(); await expect( page.getByRole("button", { name: "Pause", exact: true }), ).toBeVisible(); diff --git a/tests/e2e/chat-adapters-ui.shared.ts b/tests/e2e/chat-adapters-ui.shared.ts index c222afa128..8ff15104f5 100644 --- a/tests/e2e/chat-adapters-ui.shared.ts +++ b/tests/e2e/chat-adapters-ui.shared.ts @@ -52,7 +52,7 @@ export const PROVIDERS: ProviderCase[] = [ secondaryResourceLabel: "#support", resourceType: "channel", externalUrl: "https://app.slack.com/client/T-E2E/C-E2E/thread/C-E2E-1", - setupHeading: /Connect a Slack app/i, + setupHeading: /Create a Slack app/i, setupButton: "Connect Slack app", chatAndTool: true, }, @@ -293,6 +293,7 @@ export function endpointFixture(provider: ProviderCase, seed: Seed) { webhookUrl: `https://paperclip.example.test/api/chat-webhooks/public-${provider.provider}/${provider.provider}`, messagingEndpoint: `https://paperclip.example.test/api/chat-webhooks/public-${provider.provider}/microsoft-teams`, command: provider.provider === "slack" ? "/maya-public" : undefined, + testStartedAt: null as string | null, webhookVerifiedAt: null, webhookSecretConfigured: false, }, @@ -326,7 +327,7 @@ export type ChatMock = { conversationState: "active" | "waiting"; removed: boolean; setStatus: (status: string) => void; - setGitHubWebhookVerified: () => void; + setWebhookVerified: () => void; }; export async function installChatControlPlaneMock( @@ -340,6 +341,7 @@ export async function installChatControlPlaneMock( }: { enableChatConnectors: boolean; resourceCount?: number; photonShared?: boolean }, ): Promise { const endpoint = endpointFixture(provider, seed); + let slackIdentityLinked = false; const state: ChatMock & { created: boolean; failNextGitHubEndpointRead: boolean; @@ -368,7 +370,7 @@ export async function installChatControlPlaneMock( setStatus: (status) => { endpoint.status = status; }, - setGitHubWebhookVerified: () => { + setWebhookVerified: () => { endpoint.setup.webhookVerifiedAt = new Date().toISOString(); }, }; @@ -558,6 +560,7 @@ export async function installChatControlPlaneMock( botLabel: provider.botLabel, setup: { ...endpoint.setup, + testStartedAt: body.action === "verify" ? new Date().toISOString() : endpoint.setup.testStartedAt, step: provider.provider === "slack" && body.action === "configure" ? "provider_setup" @@ -573,7 +576,7 @@ export async function installChatControlPlaneMock( await fulfill(route,{projectId:"project-e2e",projectName:"Photon Test",allocation:photonShared ? "shared" : "dedicated",eligible:true,lines:photonShared ? [] : [{lineId:"line-one",phoneNumber:"+15555550100",eligible:true},{lineId:"line-two",phoneNumber:"+15555550102",eligible:true}]}); return; } if ( - pathname === `/api/chat-endpoints/${endpoint.id}/test` && + (pathname === `/api/chat-endpoints/${endpoint.id}/test` || pathname === `/api/chat-endpoints/${endpoint.id}/finish`) && method === "POST" ) { Object.assign(endpoint, { @@ -585,6 +588,20 @@ export async function installChatControlPlaneMock( return; } + if (pathname === `/api/chat-endpoints/${endpoint.id}/test-status`) { + await fulfill(route, { messageReceivedAt: null }); + return; + } + if (pathname === `/api/chat-endpoints/${endpoint.id}/principals/principal-slack-self/link-intent` && method === "POST") { + await fulfill(route, { confirmationUrl: `https://paperclip.example.test/${seed.prefix}/chat-identity/confirm?token=e2e-self` }); + return; + } + if (pathname === "/api/chat-identity-links/confirm" && method === "POST") { + slackIdentityLinked = true; + await fulfill(route, { endpointId: endpoint.id, companyId: seed.companyId }); + return; + } + if (pathname === `/api/chat-endpoints/${endpoint.id}/resources`) { if (method === "GET") { await fulfill(route, { resources }); @@ -617,6 +634,12 @@ export async function installChatControlPlaneMock( ) { await fulfill(route, { principals: [ + ...(provider.provider === "slack" && (slackIdentityLinked || endpoint.setup.step === "test") ? [{ + id: "link-slack-self", principalId: "principal-slack-self", externalLabel: "Test operator", + externalDetail: "operator@slack", lastConnectAt: new Date().toISOString(), + paperclipUserId: slackIdentityLinked ? "local-board" : null, + status: slackIdentityLinked ? "linked" : "pending", + }] : []), { id: `link-${provider.provider}`, principalId: `principal-${provider.provider}`, @@ -767,6 +790,9 @@ export async function selectMaya(page: Page) { export async function fillProviderSetup(page: Page, provider: ProviderCase) { if (provider.provider === "slack") { + await page.getByRole("button", { name: "I already created the app" }).click(); + await expect(page.getByLabel("Bot User OAuth Token")).toHaveAttribute("type", "password"); + await expect(page.getByLabel("Signing Secret")).toHaveAttribute("type", "password"); await page.getByLabel("Bot User OAuth Token").fill("xoxb-e2e-redacted"); await page.getByLabel("Signing Secret").fill("slack-signing-secret"); } else if (provider.provider === "github") { @@ -882,10 +908,13 @@ export function expectedCredentialKeys(provider: Provider): string[] { } export async function expectSetupRail(page: Page) { - const rail = page.getByRole("list", { name: "Connection setup progress" }); + const rail = page.getByRole("navigation", { name: "Connection setup progress" }); await expect(rail).toBeVisible(); - await expect(rail.getByRole("listitem")).toHaveCount(3); - for (const label of ["Choose agent", "Connect provider", "Try it"]) { + const labels = new URL(page.url()).searchParams.get("provider") === "slack" + ? ["Choose agent", "Create Slack app", "Add credentials", "Verify Slack connection", "Connect your Slack account", "Try it"] + : ["Choose agent", "Connect provider", "Try it"]; + await expect(rail.getByRole("listitem")).toHaveCount(labels.length); + for (const label of labels) { await expect(rail.getByText(label, { exact: true })).toBeVisible(); } } @@ -904,7 +933,7 @@ features: display_name: "maya" slash_commands: - command: "/maya-public" - description: Start or manage work with "Maya" + description: "Start or manage work with Maya" usage_hint: "status | new | close | " should_escape: false url: "${webhookUrl}" @@ -964,26 +993,15 @@ settings: export async function expectMinimumProviderSetup(page: Page, provider: ProviderCase) { const webhookUrl = `https://paperclip.example.test/api/chat-webhooks/public-${provider.provider}/${provider.provider}`; if (provider.provider === "slack") { - await expect(page.getByText("From an app manifest")).toBeVisible(); - await expect(page.getByText("OAuth & Permissions")).toBeVisible(); - await expect(page.getByText("Basic Information")).toBeVisible(); - const manifest = await page.getByLabel("Slack app manifest").inputValue(); - expect(manifest).toBe(expectedSlackManifest(webhookUrl)); - await expect(page.getByLabel("Slack app manifest")).toHaveAttribute( - "readonly", - "", - ); - await expect(page.getByLabel("Bot User OAuth Token")).toHaveAttribute( - "type", - "password", - ); - await expect(page.getByLabel("Signing Secret")).toHaveAttribute( - "type", - "password", - ); - await expect( - page.getByRole("button", { name: "Open Slack app settings" }), - ).toBeVisible(); + await expect(page.getByLabel("Slack app name", { exact: true })).toBeEditable(); + await expect(page.getByLabel("Bot display name", { exact: true })).toBeEditable(); + await expect(page.getByLabel("Slash command", { exact: true })).toBeEditable(); + await page.getByRole("button", { name: "View Slack App Manifest" }).click(); + const manifest = page.getByRole("textbox", { name: "Slack app manifest", exact: true }); + await expect(manifest).toHaveValue(expectedSlackManifest(webhookUrl)); + await expect(manifest).toHaveAttribute("readonly", ""); + await page.keyboard.press("Escape"); + await expect(page.getByRole("button", { name: "Create Slack app", exact: true })).toBeVisible(); return; } @@ -1149,14 +1167,16 @@ export async function expectProviderTryInstructions( page: Page, provider: ProviderCase, ) { + if (provider.provider === "slack") { + for (const text of ["Open a channel and invite @maya-paperclip if needed.", "@maya-paperclip you there?", "Continue the conversation in the thread."]) { + await expect(page.getByText(text, { exact: true })).toBeVisible(); + } + await expect(page.getByRole("button", { name: "Copy message" })).toBeVisible(); + await expect(page.getByRole("link", { name: "Open Slack", exact: true })).toHaveCount(0); + return; + } const expected = - provider.provider === "slack" - ? [ - "Open a channel and invite the bot if needed.", - "Mention @maya-paperclip in a new channel message.", - "Reply once in Maya's thread.", - ] - : provider.provider === "github" + provider.provider === "github" ? [ "Open an installed issue or pull request.", "Mention @maya-paperclip in a comment.", diff --git a/ui/src/App.test.tsx b/ui/src/App.test.tsx index bfe777b0c2..2cea7cea64 100644 --- a/ui/src/App.test.tsx +++ b/ui/src/App.test.tsx @@ -57,7 +57,7 @@ async function waitForText(container: HTMLElement, text: string) { await vi.waitFor(() => expect(container.textContent).toContain(text)); } -function renderGate(container: HTMLElement) { +function renderGate(container: HTMLElement, allowMembershipRequest = false) { const root = createRoot(container); const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, @@ -66,7 +66,7 @@ function renderGate(container: HTMLElement) { flushSync(() => { root.render( - + , ); }); @@ -123,6 +123,40 @@ describe("CloudAccessGate", () => { unmountRoot(root); }); + it("admits signed-in nonmembers only for the private invitation landing page", async () => { + mockAuthApi.getSession.mockResolvedValue({ user: { id: "invitee" } }); + mockAccessApi.getCurrentBoardAccess.mockResolvedValue({ isInstanceAdmin: false, companyIds: [] }); + const root = renderGate(container, true); + await waitForText(container, "Outlet content"); + unmountRoot(root); + }); + + it("still requires sign-in for the invitation landing page", async () => { + mockAuthApi.getSession.mockResolvedValue(null); + const root = renderGate(container, true); + await waitForText(container, "Navigate:/auth?next="); + expect(container.textContent).not.toContain("Outlet content"); + unmountRoot(root); + }); + + it("still blocks invitation pages while cloud bootstrap is pending", async () => { + mockHealthApi.get.mockResolvedValue({ deploymentMode: "authenticated", deploymentExposure: "public", bootstrapStatus: "bootstrap_pending" }); + mockAuthApi.getSession.mockResolvedValue({ user: { id: "invitee" } }); + const root = renderGate(container, true); + await waitForText(container, "This Paperclip is waiting on its first admin"); + expect(container.textContent).not.toContain("Outlet content"); + unmountRoot(root); + }); + + it("keeps invitation pages closed when cloud access checks fail", async () => { + mockAuthApi.getSession.mockResolvedValue({ user: { id: "invitee" } }); + mockAccessApi.getCurrentBoardAccess.mockRejectedValueOnce(new Error("Access check unavailable")); + const root = renderGate(container, true); + await waitForText(container, "Access check unavailable"); + expect(container.textContent).not.toContain("Outlet content"); + unmountRoot(root); + }); + it("allows authenticated users with company access through to the board", async () => { mockAuthApi.getSession.mockResolvedValue({ session: { id: "session-1", userId: "user-1" }, diff --git a/ui/src/App.tsx b/ui/src/App.tsx index c3b957a73c..c1ba93893e 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -749,10 +749,10 @@ export function App() { } /> } /> } /> - : }> - - } /> + : }> + {/* The identity APIs enforce the chat rollout flag. Nonmembers cannot + read experimental settings, but a private invitation may request membership. */} + } /> } /> } /> diff --git a/ui/src/api/chatEndpoints.ts b/ui/src/api/chatEndpoints.ts index 43d9ab629c..a9bb484608 100644 --- a/ui/src/api/chatEndpoints.ts +++ b/ui/src/api/chatEndpoints.ts @@ -1,5 +1,7 @@ import { api } from "./client"; import type { + SlackAppConfiguration, + UpdateChatEndpointInput, PhotonProjectInspection, PhotonChannelConfiguration, ChatPublicationBatchStatus, @@ -41,6 +43,8 @@ export interface ChatEndpointResource { export interface ChatIdentityLink { id: string; principalId: string; + /** Latest discovery-only connect command received by this endpoint. */ + lastConnectAt?: string | null; externalLabel: string; externalDetail?: string | null; paperclipUserId?: string | null; @@ -73,6 +77,8 @@ export interface ExternalChannelBindingSummary { } export interface ChatIdentityLinkPreview { + selfService?: boolean; + canConfirm?: boolean; endpointId: string; companyId: string; companyName: string; @@ -114,11 +120,14 @@ export interface ChatEndpoint { activity?: ChatActivityItem[]; setup?: { step: string; + testStartedAt?: string | null; + testSkipped?: boolean; authorizationUrl?: string | null; providerUrl?: string | null; webhookUrl?: string | null; messagingEndpoint?: string | null; command?: string | null; + slackApp?: SlackAppConfiguration; webhookVerifiedAt?: string | null; webhookSecretConfigured?: boolean; callbackSurfaces?: { @@ -180,12 +189,7 @@ export const chatEndpointsApi = { ) => api.post(`/companies/${companyId}/chat-endpoints`, input), update: ( endpointId: string, - input: Partial< - Pick< - ChatEndpoint, - "allowDirectMessages" | "allowGroupChats" | "allowUnlinkedPeople" - > - >, + input: UpdateChatEndpointInput, ) => api.patch(`/chat-endpoints/${endpointId}`, input), setup: ( endpointId: string, @@ -204,6 +208,9 @@ export const chatEndpointsApi = { ), test: (endpointId: string) => api.post(`/chat-endpoints/${endpointId}/test`, {}), + finishSlackSetup: (endpointId: string) => api.post(`/chat-endpoints/${endpointId}/finish`, {}), + setupTestStatus: (endpointId: string) => api.get<{ messageReceivedAt: string | null }>(`/chat-endpoints/${endpointId}/test-status`), + requestIdentityAccess: (token: string) => api.post<{ status: "member" | "pending_approval" }>("/chat-identity-links/request-access", { token }), listResources: async (endpointId: string) => rows( await api.get>( @@ -252,6 +259,10 @@ export const chatEndpointsApi = { `/chat-endpoints/${endpointId}/activity`, ), ), + listActivityPage: (endpointId: string, cursor?: string) => + api.get<{ items: ChatActivityItem[]; nextCursor: string | null }>( + `/chat-endpoints/${endpointId}/activity?limit=25${cursor ? `&cursor=${encodeURIComponent(cursor)}` : ""}`, + ), getIssueBinding: (issueId: string) => api.get( `/issues/${issueId}/chat-binding`, diff --git a/ui/src/components/AppsSidebar.production.tsx b/ui/src/components/AppsSidebar.production.tsx index a04d31f3b6..f915a3f3ba 100644 --- a/ui/src/components/AppsSidebar.production.tsx +++ b/ui/src/components/AppsSidebar.production.tsx @@ -1,3 +1,6 @@ +import { useLocation } from "@/lib/router"; +import { ChatDetailSidebar } from "./chat/ChatDetailSidebar"; +import { ChatSetupSidebar } from "./chat/ChatSetupNavigation"; import { ChevronLeft, AppWindow, Store, ShieldQuestion } from "lucide-react"; import { Link } from "@/lib/router"; import { useCompany } from "@/context/CompanyContext"; @@ -25,6 +28,7 @@ import { SidebarNavItem } from "./SidebarNavItem.production"; * (PAP-10922). */ export function AppsSidebar() { + const { pathname } = useLocation(); const { selectedCompany } = useCompany(); const { isMobile, setSidebarOpen } = useSidebar(); @@ -34,6 +38,10 @@ export function AppsSidebar() { (tab) => !isExperimentalToolTab(tab.key) || smokeLabEnabled, ); + if (pathname.endsWith("/apps/chat/connect")) return ; + const chatDetail = pathname.match(/\/apps\/chat\/([^/]+)(?:\/(?:settings|access|conversations|activity))?\/?$/); + if (chatDetail) return ; + return (