feat(chat): simplify Slack onboarding and account linking (#13638)

## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Chat connections let people start and continue that work from Slack.
> - Setup mixed app creation, credentials, URL verification, account
linking, and testing on the same screens.
> - People also needed a safe way to link their own Slack identity after
the first operator finished setup.
> - This pull request gives each step a clear place and keeps membership
approval separate from identity linking.
> - It also makes connection details easier to use and fixes misleading
callback health behind HTTPS proxies.

## Linked Issues or Issue Description

**Subsystem affected**
Cross-cutting: chat routes and services, shared contracts, and the Apps
board UI.

**Problem or motivation**
Slack onboarding made users find settings without enough guidance. A
second user needed operator help to link their account. Activity stopped
at 100 records, and TLS termination could mark working callbacks as
stale.

**Proposed solution**
Use six setup steps with editable app names, a generated manifest,
credential guidance, URL verification, account linking, and an optional
message test. Send each Slack user a private, expiring confirmation
link. Require company membership or an approved access request before
linking. Add cursor pagination and tolerate the internal HTTP hop in
callback diagnostics.

**Roadmap alignment**
This improves the existing connected-app surface and supports CEO Chat
without changing the task-and-comments model. The maintainer requested
and reviewed the flow during a live Slack test drive.

**Additional context**
Related work: #7, #3349, #13000, and #13620. Those cover broader chat
capabilities, older webhook paths, or plugins. This PR improves the
existing native connector's setup and account-linking flow. HTTPS
documentation was published separately in
paperclipai/paperclip-docs#128.

## What Changed

- Split Slack onboarding into six clickable sidebar steps. Keep
secondary and primary actions on one row.
- Generate the Slack creation link and read-only manifest from editable
app, bot, and command names. Add credential prefix validation and direct
instructions.
- Add live account-link status and an optional mention-based message
test.
- Add private, single-use Slack account invitations and membership
access requests. Retain cloud authentication/bootstrap checks and
enforce the chat rollout flag in all identity APIs. Default new Slack
connections to linked users only.
- Put Settings, Access, Conversations, and Activity in the sidebar.
Simplify conversation rows and remove active header badges.
- Add 25-item activity pages, stable timestamp/ID cursors, and replay
safety across pages. Preserve the legacy array API for clients without
pagination parameters.
- Fix false callback warnings when HTTPS terminates at a proxy. Keep
host, port, and path drift detection.
- Document the setup flow, pagination, callback diagnostics, and shared
wizard footer rule.

## Verification

- Passed: `pnpm -r typecheck`, `pnpm build`, and `pnpm
check:token-gates`.
- Passed: focused Slack callback and pagination integration tests; UI
clipboard, wizard, pagination, and activity tests; OpenAPI route tests.
The final access-gate fix also passes 27 focused tests covering cloud
authentication/bootstrap, nonmember invitations, token validity, and the
server-enforced rollout flag.
- Passed: all 1,002 chat integration tests, 6,356 UI tests, and all 11
provider browser scenarios (including mobile light/dark navigation).
After rebase, the identity route, sidebar, and 25 clipboard tests pass.
- The full local `pnpm test:run` was attempted. The first run found 14
Slack fixtures that needed explicit guest access; those are fixed and
the complete chat suite passes. Unrelated embedded PostgreSQL
startup/resource failures and timeouts prevented a clean full local run.
All CI checks pass on `2d858b036`, including the full chat, server,
workspace, build, typecheck, and browser suites.
- Live test drive: Slack app creation, credential setup, URL
verification, private account confirmation, mention messages, and thread
replies. Verified the callback warning clears for the existing proxied
connection.
- Review: create a Slack connection, follow the six steps, link a second
user's account, and browse older activity with Next and Previous.

## Risks

- Identity invitations carry a temporary capability. Tokens are hashed,
expire after 15 minutes, work once, and require explicit confirmation by
a company member. Access requests do not grant membership.
- New Slack connections reject unlinked people by default. Existing
connection settings remain intact.
- Activity is a live ledger. Updated action rows can move forward in
time. Older pages do not poll.
- Proxy tolerance affects health display only. Slack signature checks
and proxy authentication settings remain unchanged.
- No database migration or package-lock changes.

## Model Used

OpenAI Codex, based on GPT-6, with reasoning, repository tools, code
execution, and browser verification. The runtime does not expose an
exact model build ID or context-window size.

## Checklist

- [x] I have included a thinking path that traces from project context
to this change
- [x] I have specified the model used (with version and capability
details)
- [x] I have checked ROADMAP.md and confirmed this PR does not duplicate
planned core work
- [x] I have searched GitHub for duplicate or related PRs and linked
them above
- [x] I have either (a) linked existing issues with `Fixes: #` / `Closes
#` / `Refs #` OR (b) described the issue in-PR following the relevant
issue template
- [x] I have not referenced internal/instance-local Paperclip issues or
links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip`
URLs)
- [x] My branch name describes the change (e.g. `docs/...`, `fix/...`)
and contains no internal Paperclip ticket id or instance-derived details
- [x] I have run tests locally and they pass (targeted suites; full
local-run limitations documented above)
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip authored and GitHub committed 2026-09-18 17:23:53 -05:00
1 parent 4b8dc416da
commit 924f07be8c
36 files changed
+2364 -688

No files matched your search

@@ -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 } =
+1 -1
View File
@@ -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",
@@ -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])(
+50 -15
View File
@@ -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<string> {
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(
+31 -4
View File
@@ -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,
+437 -180
View File
@@ -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<typeof chatEndpoints.$inferInsert> = {
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<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
sql<string>`${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<string>`${heartbeatRuns.contextSnapshot} ->> 'issueId'`,
sql<string>`${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<string>`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,