mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 13:24:55 +01:00
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:
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 } =
|
||||
|
||||
@@ -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])(
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user