Files
PaperClipAI/server/src/routes/chat-channels.ts
T
DottaandPaperclip dfc9d612eb feat(connections): let agents configure connections within delegated authority
Keep management bounded by responsible users, scoped grants, current trust, and existing execution policy. Preserve human authentication handoffs and delegated gateway token authority.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-10-26 08:55:24 -05:00

790 lines
40 KiB
TypeScript

import { connectionManagementService } from "../services/connection-management.js";
import { connectionIntentService } from "../services/connection-intents.js";
import { companies } from "@paperclipai/db";
import { eq } from "drizzle-orm";
import { requiredConnectionUserAction, filterConnectionManagementRows, connectionManagementGuard, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js";
import { slackTestMessageSchema, slackRegistrationSchema, slackSetupActionSchema, slackManagedProvisionSchema } from "@paperclipai/shared";
import { isCrossSiteOAuthCallbackNavigation, oauthCallbackInterstitialHtml } from "../lib/oauth-browser-return.js";
import {
Router,
type Request as ExpressRequest,
type Response as ExpressResponse,
} from "express";
import { z } from "zod";
import { githubChatManagementService } from "../services/chat-github-management.js";
import { updateGitHubChatConfigurationSchema } from "@paperclipai/shared";
import type { Db } from "@paperclipai/db";
import {
CHAT_PROVIDERS,
configureChatEndpointSchema,
inspectPhotonProjectSchema,
confirmChatIdentityLinkSchema,
createChatEndpointSchema,
createChatIdentityLinkIntentSchema,
isUuidLike,
publishChatPublicationSchema,
replaceChatEndpointResourcesSchema,
gitHubRepositoryPageQuerySchema,
toggleAllGitHubRepositoriesSchema,
resolveChatActionSchema,
resolveChatPublicationSchema,
updateChatEndpointSchema,
type ChatProvider,
} from "@paperclipai/shared";
import { validate } from "../middleware/validate.js";
import {
chatChannelService,
type ChatChannelService,
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,
type InviteRateLimiter,
} from "../services/invite-rate-limit.js";
import {
assertBoard,
assertBoardOrAgent,
assertCompanyAccess,
getAccessibleResource,
getActorInfo,
} from "./authz.js";
import {
badRequest,
forbidden,
HttpError,
tooManyRequests,
} from "../errors.js";
type ChatChannelRouteOptions = ChatChannelServiceOptions & {
service?: ChatChannelService;
};
type ChatWebhookRouteOptions = {
rateLimiter?: InviteRateLimiter;
};
const CHAT_WEBHOOK_RATE_LIMIT_WINDOW_MS = 60_000;
const CHAT_WEBHOOK_RATE_LIMIT_MAX_REQUESTS = 600;
function endpointId(req: ExpressRequest): string {
return req.params.endpointId as string;
}
function actorUserId(req: ExpressRequest): string | null {
return connectionRequestUserId(req);
}
async function assertEndpointAccess(
req: ExpressRequest,
res: ExpressResponse,
service: ChatChannelService,
): Promise<boolean> {
assertBoardOrAgent(req);
const endpoint = await getAccessibleResource(
req,
res,
service.get(endpointId(req)).catch((error) => {
if (error instanceof HttpError && error.status === 404) return null;
throw error;
}),
"Chat endpoint not found",
);
return endpoint !== null;
}
export function chatChannelRoutes(db: Db, options: ChatChannelRouteOptions) {
const router = Router();
const managementGuard = connectionManagementGuard(db, "chat");
const service = options.service ?? chatChannelService(db, options);
const access = accessService(db);
const github = githubChatManagementService(db, options.fetch);
async function setupHandoff(req: ExpressRequest, reason: string) {
const endpoint = await service.get(endpointId(req));
const [company] = await db.select({ prefix: companies.issuePrefix }).from(companies).where(eq(companies.id, endpoint.companyId));
const interactionId = req.actor.type === "agent" && req.actor.runId && endpoint.assignedAgentId === req.actor.agentId
? await connectionIntentService(db).requestChatHandoff({ sub: req.actor.agentId!, company_id: endpoint.companyId,
run_id: req.actor.runId, responsible_user_id: connectionRequestUserId(req)! }, endpoint.id)
: null;
return { status: "needs_user_action", interactionId, endpointId: endpoint.id, connectionId: endpoint.connectionId,
...requiredConnectionUserAction(req, { type: "chat_endpoint", id: endpoint.id, companyId: endpoint.companyId },
`/${encodeURIComponent(company!.prefix)}/apps/chat/connect?${new URLSearchParams({ provider: endpoint.provider, resume: endpoint.id })}`, reason),
};
}
async function assertIdentityLinkAccess(req: ExpressRequest) {
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.
const experimental = await instanceSettingsService(db).getExperimental();
if (!experimental.enableChatConnectors && !experimental.enableGitHubReviewBots) {
throw forbidden("Chat connectors are not enabled on this instance");
}
return { userId, experimental };
}
function assertIdentityProviderEnabled(
provider: string,
experimental: { enableChatConnectors: boolean; enableGitHubReviewBots: boolean },
) {
const enabled = provider === "agentmail" || (provider === "github"
? experimental.enableGitHubReviewBots
: experimental.enableChatConnectors);
if (!enabled) throw forbidden("This connector is not enabled on this instance");
}
async function assertConnectionManager(
req: ExpressRequest,
companyId: string,
) {
assertBoardOrAgent(req);
assertCompanyAccess(req, companyId);
if (req.actor.type === "agent") {
const context = connectionRequestContext(req);
if (!context || context.resource.companyId !== companyId) throw forbidden("Connection management authority is required");
await context.reauthorize();
return;
}
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)
return;
const userId = connectionRequestUserId(req);
if (
userId &&
(await access.hasPermission(
companyId,
"user",
userId,
"tools:manage_connections",
))
)
return;
throw forbidden("Missing permission: tools:manage_connections");
}
async function assertEndpointManagementAccess(
req: ExpressRequest,
res: ExpressResponse,
): Promise<boolean> {
if (!(await assertEndpointAccess(req, res, service))) return false;
const endpoint = await service.get(endpointId(req));
await assertConnectionManager(req, endpoint.companyId);
return true;
}
router.get("/companies/:companyId/chat-endpoints", managementGuard("connections:read", "GET /companies/:companyId/chat-endpoints"), async (req, res) => {
assertBoardOrAgent(req);
const companyId = req.params.companyId as string;
assertCompanyAccess(req, companyId);
res.json(await filterConnectionManagementRows(db, req, "connections:read", await service.list(companyId), row => ({ connectionId: row.connectionId, targetAgentId: row.assignedAgentId })));
});
router.post(
"/companies/:companyId/chat-endpoints", managementGuard("connections:configure", "POST /companies/:companyId/chat-endpoints"),
validate(createChatEndpointSchema),
async (req, res) => {
const companyId = req.params.companyId as string;
await assertConnectionManager(req, companyId);
if (req.body.provider === "slack" && !req.body.slackSetupMethod) {
req.body.slackSetupMethod = service.slackRegistration.managerGrants.defaultMethod();
}
if (req.body.slackSetupMethod === "managed" && service.slackRegistration.managerGrants.defaultMethod() !== "managed") throw forbidden("Managed Slack setup is unavailable");
res
.status(201)
.json(await service.create(companyId, req.body, actorUserId(req), connectionRequestContext(req)));
},
);
router.get("/chat-endpoints/:endpointId", managementGuard("connections:read", "GET /chat-endpoints/:endpointId"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
res.json(await service.get(endpointId(req)));
});
const slackActor = (req: ExpressRequest) => {
assertBoardOrAgent(req);
const userId = connectionRequestUserId(req);
if (!userId) throw forbidden("A responsible user is required");
return { userId, sessionId: req.actor.type === "board" ? req.actor.sessionId ?? null : null,
managementContext: connectionRequestContext(req),
bypassPermissionCheck: req.actor.type === "board" && (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true) };
};
router.get("/companies/:companyId/chat-slack/setup-options", managementGuard("connections:read", "GET /companies/:companyId/chat-slack/setup-options"), async (req, res) => {
const companyId = req.params.companyId as string;
await assertConnectionManager(req, companyId);
res.set("Cache-Control", "no-store");
res.json(await service.slackRegistration.managerGrants.choices(companyId, slackActor(req)));
});
router.post("/chat-endpoints/:endpointId/slack/managed/authorize", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/slack/managed/authorize"), validate(slackSetupActionSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
if (req.actor.type === "agent") { res.json(await setupHandoff(req, "The responsible user must open the saved setup to bind their browser session and consent to Slack access.")); return; }
res.json(await service.slackRegistration.managerGrants.authorize(endpointId(req), slackActor(req)));
});
router.post("/chat-endpoints/:endpointId/slack/managed/provision", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/slack/managed/provision"), validate(slackManagedProvisionSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
res.json(await service.slackRegistration.managed.provision(endpointId(req), slackActor(req), req.body));
});
router.delete("/companies/:companyId/chat-slack/manager-grants/:grantId", managementGuard("connections:configure", "DELETE /companies/:companyId/chat-slack/manager-grants/:grantId", { slackManagerGrant: true }), async (req, res) => {
const companyId = req.params.companyId as string;
await assertConnectionManager(req, companyId);
await service.slackRegistration.managerGrants.revoke(req.params.grantId as string, companyId, slackActor(req));
res.sendStatus(204);
});
router.get("/chat-slack/managed/oauth/callback", async (req, res) => {
res.set("Cache-Control", "no-store"); res.set("Referrer-Policy", "no-referrer");
assertBoard(req);
const actor = slackActor(req);
const state = typeof req.query.state === "string" ? req.query.state : "";
const expiredId = await service.slackRegistration.managerGrants.expiredReturn(state, actor);
if (expiredId) { res.redirect(303, await service.slackRegistration.returnPath(expiredId)); return; }
const pending = await service.slackRegistration.managerGrants.pending(state, actor);
await assertConnectionManager(req, pending.current.endpoint.companyId);
if (req.get("accept")?.includes("text/html") && isCrossSiteOAuthCallbackNavigation(req)) {
res.type("html").send(oauthCallbackInterstitialHtml()); return;
}
const id = await service.slackRegistration.managerGrants.complete(state,
typeof req.query.claim_id === "string" ? req.query.claim_id : null,
typeof req.query.error === "string" ? req.query.error : null, actor,
typeof req.query.code === "string" ? req.query.code : null);
res.redirect(303, await service.slackRegistration.returnPath(id));
});
router.post("/chat-endpoints/:endpointId/slack/test-message", managementGuard("connections:test", "POST /chat-endpoints/:endpointId/slack/test-message"), validate(slackTestMessageSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
res.json(await service.sendSlackTestMessage(endpointId(req), slackActor(req), req.body));
});
router.post("/chat-endpoints/:endpointId/slack/registration", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/slack/registration"), validate(slackRegistrationSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
await service.slackRegistration.create(endpointId(req), slackActor(req), req.body);
res.json(await service.get(endpointId(req)));
});
router.post("/chat-endpoints/:endpointId/slack/install", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/slack/install"), validate(slackSetupActionSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
if (req.actor.type === "agent") { res.json(await setupHandoff(req, "The responsible user must open the saved setup to bind their browser session and consent to Slack access.")); return; }
res.json(await service.slackRegistration.install(endpointId(req), slackActor(req)));
});
router.post("/chat-endpoints/:endpointId/slack/resume", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/slack/resume"), validate(slackSetupActionSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
await service.slackRegistration.resume(endpointId(req), slackActor(req));
res.json(await service.get(endpointId(req)));
});
router.get("/chat-slack/oauth/callback", async (req, res) => {
res.set("Cache-Control", "no-store");
res.set("Referrer-Policy", "no-referrer");
assertBoard(req);
const actor = slackActor(req);
const state = typeof req.query.state === "string" ? req.query.state : "";
const expiredReturn = await service.slackRegistration.expiredReturn(state, actor);
if (expiredReturn) { res.redirect(303, expiredReturn); return; }
const pending = await service.slackRegistration.pending(state, actor);
await assertConnectionManager(req, pending.row.companyId);
if (req.get("accept")?.includes("text/html") && isCrossSiteOAuthCallbackNavigation(req)) {
res.type("html").send(oauthCallbackInterstitialHtml());
return;
}
const endpoint = await service.slackRegistration.complete(state,
typeof req.query.code === "string" ? req.query.code : null,
typeof req.query.error === "string" ? req.query.error : null, actor);
res.redirect(303, await service.slackRegistration.returnPath(endpoint));
});
const githubUser = (req: ExpressRequest) => {
const userId = actorUserId(req);
if (!userId) throw badRequest("Sign in to your Paperclip account to set up this bot");
return userId;
};
router.get("/chat-endpoints/:endpointId/github/configuration", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/github/configuration"), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(await github.configuration(endpointId(req), githubUser(req)));
});
router.put("/chat-endpoints/:endpointId/github/configuration", managementGuard("connections:configure", "PUT /chat-endpoints/:endpointId/github/configuration"), validate(updateGitHubChatConfigurationSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(await github.saveConfiguration(endpointId(req), req.body, githubUser(req), connectionRequestContext(req)));
});
router.post("/chat-endpoints/:endpointId/github/verify", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/verify"), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(await github.verification(endpointId(req)));
});
router.put("/chat-endpoints/:endpointId/github/progress", managementGuard("connections:configure", "PUT /chat-endpoints/:endpointId/github/progress"), validate(z.object({ stage: z.enum(["setup", "connect", "install", "repositories", "verify", "identity", "behavior", "test"]) }).strict()), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(await service.saveGitHubSetupProgress(endpointId(req), req.body.stage));
});
router.get("/chat-endpoints/:endpointId/github/reviews/:reviewId", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/github/reviews/:reviewId"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
const reviewId = z.string().uuid().safeParse(req.params.reviewId);
if (!reviewId.success) throw badRequest("Invalid review ID");
res.json(await github.review(endpointId(req), reviewId.data));
});
router.get("/chat-endpoints/:endpointId/github/reviews", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/github/reviews"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
res.json(await github.reviews(endpointId(req)));
});
router.get("/chat-endpoints/:endpointId/github/personal-connections", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/github/personal-connections"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
res.json(await github.personalConnections(endpointId(req), githubUser(req)));
});
router.post("/chat-endpoints/:endpointId/github/identity", (req, _res, next) => { assertBoard(req); next(); }, validate(z.object({ connectionId: z.string().uuid(), confirmedGithubUserId: z.string().regex(/^[1-9][0-9]*$/).optional() }).strict()), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
res.json(await github.identity(endpointId(req), req.body.connectionId, githubUser(req), req.body.confirmedGithubUserId));
});
router.post("/chat-endpoints/:endpointId/github/people/lookup", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/people/lookup"), validate(z.object({ login: z.string().min(1).max(44) }).strict()), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(await github.lookupPerson(req.body.login));
});
router.post("/chat-endpoints/:endpointId/github/registration", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/registration"), validate(z.object({ name: z.string().trim().min(1).max(34), ownerType: z.enum(["personal", "organization"]).optional(), ownerLogin: z.string().regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/).optional() }).strict().refine(value => value.ownerType !== "organization" || !!value.ownerLogin, "Enter the GitHub organization")), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
const result = req.body.ownerType === undefined
? await service.startGitHubRegistration(endpointId(req), githubUser(req), req.body.name, connectionRequestContext(req))
: await service.githubWizard.start(endpointId(req), githubUser(req), req.body, connectionRequestContext(req));
res.json({ ...result, ...(req.actor.type === "agent" ? await setupHandoff(req, "Complete GitHub App registration and consent from this saved setup.") : {}) });
});
router.post("/chat-endpoints/:endpointId/github/registration/restart", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/registration/restart"), validate(z.object({ registrationId: z.string().uuid(), appNotCreated: z.literal(true) }).strict()), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
res.json(await service.githubWizard.restartRegistration(endpointId(req), githubUser(req), req.body.registrationId, connectionRequestContext(req)));
});
router.put("/chat-endpoints/:endpointId/github/draft", managementGuard("connections:configure", "PUT /chat-endpoints/:endpointId/github/draft"), validate(z.object({ name: z.string().trim().min(1).max(34), ownerType: z.enum(["personal", "organization"]), ownerLogin: z.string().regex(/^[A-Za-z0-9](?:[A-Za-z0-9-]{0,37}[A-Za-z0-9])?$/).optional() }).strict()), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
res.json(await service.githubWizard.saveDraft(endpointId(req), githubUser(req), req.body, connectionRequestContext(req)));
});
router.post("/chat-endpoints/:endpointId/github/setup", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/setup"), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
const result = await service.githubWizard.advance(endpointId(req), githubUser(req), connectionRequestContext(req));
res.json({ ...result, ...(req.actor.type === "agent" && ["register", "install", "identity"].includes(result.state)
? await setupHandoff(req, "Complete the required GitHub authentication or installation step from this saved setup.") : {}) });
});
router.post("/chat-endpoints/:endpointId/github/identity/start", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/identity/start"), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
if (req.actor.type === "agent") { res.json(await setupHandoff(req, "The responsible user must authenticate and confirm their own GitHub identity.")); return; }
res.json(await service.githubWizard.startIdentity(endpointId(req), githubUser(req), req.actor));
});
router.post("/chat-endpoints/:endpointId/github/identity/confirm", (req, _res, next) => { assertBoard(req); next(); }, validate(z.object({ githubUserId: z.string().regex(/^[1-9][0-9]*$/) }).strict()), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
res.json(await service.githubWizard.confirmIdentity(endpointId(req), githubUser(req), req.body.githubUserId));
});
router.post("/chat-endpoints/:endpointId/github/app", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/app"), validate(z.object({ appId: z.string().regex(/^[1-9][0-9]*$/), privateKey: z.string().min(1).max(32000), webhookSecret: z.string().min(16).max(1024), clientId: z.string().min(1).max(128).optional(), clientSecret: z.string().min(1).max(1024).optional() }).strict().refine(value => !!value.clientId === !!value.clientSecret, "Supply both OAuth client credentials")), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(await service.storeGitHubApp(endpointId(req), githubUser(req), req.body, "existing_app", connectionRequestContext(req)));
});
router.post("/chat-endpoints/:endpointId/github/repositories/refresh", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/github/repositories/refresh"), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(await service.refreshGitHubRepositories(endpointId(req), githubUser(req), connectionRequestContext(req)));
});
router.get("/chat-endpoints/:endpointId/github/repositories", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/github/repositories"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
const query = gitHubRepositoryPageQuerySchema.safeParse(req.query);
if (!query.success) throw badRequest("Invalid repository search or page");
res.json(await service.listGitHubRepositories(endpointId(req), query.data));
});
router.put("/chat-endpoints/:endpointId/github/repositories/access", managementGuard("connections:configure", "PUT /chat-endpoints/:endpointId/github/repositories/access"), validate(toggleAllGitHubRepositoriesSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(await service.toggleAllGitHubRepositories(endpointId(req), req.body.enabled, actorUserId(req)));
});
router.patch(
"/chat-endpoints/:endpointId", managementGuard("connections:configure", "PATCH /chat-endpoints/:endpointId"),
validate(updateChatEndpointSchema),
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(
await service.update(endpointId(req), req.body, actorUserId(req), connectionRequestContext(req)),
);
},
);
router.post("/chat-endpoints/:endpointId/photon/inspect", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/photon/inspect"), validate(inspectPhotonProjectSchema), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
res.json(await service.inspectPhoton(endpointId(req), req.body));
});
router.post(
"/chat-endpoints/:endpointId/setup", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/setup"),
validate(configureChatEndpointSchema),
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(
await service.configure(endpointId(req), req.body, actorUserId(req), connectionRequestContext(req)),
);
},
);
router.post("/chat-endpoints/:endpointId/setup-secret", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/setup-secret"), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.set("Cache-Control", "no-store");
res
.status(201)
.json(
await service.generateSetupSecret(endpointId(req), actorUserId(req), connectionRequestContext(req)),
);
});
router.post("/chat-endpoints/:endpointId/test", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/test"), async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(await service.test(endpointId(req), undefined, connectionRequestContext(req)));
});
router.post("/chat-endpoints/:endpointId/finish", managementGuard("connections:configure", "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, connectionRequestContext(req)));
});
router.get("/chat-endpoints/:endpointId/test-status", managementGuard("connections:read", "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", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/resources"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
res.json(await service.listResources(endpointId(req)));
});
router.put(
"/chat-endpoints/:endpointId/resources", managementGuard("connections:configure", "PUT /chat-endpoints/:endpointId/resources"),
validate(replaceChatEndpointResourcesSchema),
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res.json(
await service.replaceResources(
endpointId(req),
req.body.resources,
actorUserId(req),
{ managementContext: connectionRequestContext(req) },
),
);
},
);
router.get("/chat-endpoints/:endpointId/principals", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/principals"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
res.json(await service.listPrincipals(endpointId(req)));
});
router.post(
"/chat-endpoints/:endpointId/principals/:principalId/link-intent", managementGuard("connections:configure", "POST /chat-endpoints/:endpointId/principals/:principalId/link-intent"),
validate(createChatIdentityLinkIntentSchema),
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
res
.status(201)
.json(
await service.createLinkIntent(
endpointId(req),
req.params.principalId as string,
req.body.expiresInSeconds,
),
);
},
);
router.delete(
"/chat-endpoints/:endpointId/principals/:principalId/link", managementGuard("connections:configure", "DELETE /chat-endpoints/:endpointId/principals/:principalId/link"),
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
await service.revokeLink(
endpointId(req),
req.params.principalId as string,
);
res.status(204).end();
},
);
router.post(
"/chat-identity-links/confirm",
validate(confirmChatIdentityLinkSchema),
async (req, res) => {
const { userId, experimental } = await assertIdentityLinkAccess(req);
const preview = await service.previewIdentityLink(req.body.token, userId);
assertIdentityProviderEnabled(preview.provider, experimental);
res.json(await service.confirmIdentityLink(req.body.token, userId));
},
);
router.post("/chat-identity-links/request-access", validate(confirmChatIdentityLinkSchema), async (req, res) => {
const { userId, experimental } = await assertIdentityLinkAccess(req);
const preview = await service.previewIdentityLink(req.body.token, userId);
assertIdentityProviderEnabled(preview.provider, experimental);
res.json(await service.requestIdentityAccess(req.body.token, userId, req.ip ?? "unknown"));
});
router.get("/chat-identity-links/preview", async (req, res) => {
const { userId, experimental } = 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");
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;
assertIdentityProviderEnabled(preview.provider, experimental);
res.json(preview);
});
router.get("/chat-endpoints/:endpointId/conversations", managementGuard("connections:read", "GET /chat-endpoints/:endpointId/conversations"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
res.json(await filterConnectionManagementRows(db, req, "connections:read", await service.listConversations(endpointId(req)), row => ({ issueId: row.issueId })));
});
router.get("/chat-endpoints/:endpointId/activity", managementGuard("connections:audit", "GET /chat-endpoints/:endpointId/activity"), async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
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, connectionRequestContext(req)));
} else {
res.json(await service.listActivity(endpointId(req), undefined, connectionRequestContext(req)));
}
});
router.post(
"/chat-endpoints/:endpointId/deliveries/:deliveryId/replay", (req, _res, next) => { assertBoard(req); next(); },
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
await service.replayDelivery(
endpointId(req),
req.params.deliveryId as string,
);
res.status(204).end();
},
);
router.post(
"/chat-endpoints/:endpointId/publications/:publicationId/replay", (req, _res, next) => { assertBoard(req); next(); },
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
await service.replayPublication(
endpointId(req),
req.params.publicationId as string,
);
res.status(204).end();
},
);
router.post(
"/chat-endpoints/:endpointId/publications/:publicationId/resolve", (req, _res, next) => { assertBoard(req); next(); },
validate(resolveChatPublicationSchema),
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
const userId = actorUserId(req);
if (!userId) throw badRequest("A board user is required");
await service.resolvePublication(
endpointId(req),
req.params.publicationId as string,
req.body.action,
userId,
req.body.fileTransfer,
);
res.status(204).end();
},
);
router.post(
"/chat-endpoints/:endpointId/actions/:actionId/resolve", (req, _res, next) => { assertBoard(req); next(); },
validate(resolveChatActionSchema),
async (req, res) => {
if (!(await assertEndpointManagementAccess(req, res))) return;
const userId = actorUserId(req);
if (!userId) throw badRequest("A board user is required");
await service.resolveAction(
endpointId(req),
req.params.actionId as string,
req.body.action,
userId,
);
res.status(204).end();
},
);
router.post(
"/chat-endpoints/:endpointId/conversations/:conversationId/publications", (req, _res, next) => { assertBoard(req); next(); },
validate(publishChatPublicationSchema),
async (req, res) => {
if (!(await assertEndpointAccess(req, res, service))) return;
if ("commentId" in req.body) {
res
.status(201)
.json(
await service.publishComment(
endpointId(req),
req.params.conversationId as string,
req.body.commentId,
),
);
return;
}
const userId = actorUserId(req);
if (!userId) throw badRequest("A board user is required");
res
.status(201)
.json(
await service.publishBoardMessage(
endpointId(req),
req.params.conversationId as string,
req.body.body,
req.body.idempotencyKey,
userId,
req.body.attachmentIds,
),
);
},
);
router.get(
"/chat-endpoints/:endpointId/conversations/:conversationId/publications/:publicationId/status", managementGuard("connections:test", "GET /chat-endpoints/:endpointId/conversations/:conversationId/publications/:publicationId/status"),
async (req, res) => {
if (
![
endpointId(req),
req.params.conversationId,
req.params.publicationId,
].every((id) => typeof id === "string" && isUuidLike(id))
) {
throw badRequest(
"Valid endpoint, conversation, and publication IDs are required",
);
}
if (!(await assertEndpointAccess(req, res, service))) return;
res.json(
await service.getPublicationBatchStatus(
endpointId(req),
req.params.conversationId as string,
req.params.publicationId as string,
),
);
},
);
router.get("/issues/:issueId/chat-binding", managementGuard("connections:read", "GET /issues/:issueId/chat-binding"), async (req, res) => {
assertBoardOrAgent(req);
const issueId = req.params.issueId as string;
if (issueId !== issueId.trim() || !isUuidLike(issueId)) {
throw badRequest("Task ID must be a UUID");
}
const binding = await service.getIssueBinding(issueId);
if (binding) {
const endpoint = await getAccessibleResource(
req,
res,
service.get(binding.endpointId).catch((error) => {
if (error instanceof HttpError && error.status === 404) return null;
throw error;
}),
"Issue not found",
);
if (!endpoint) return;
await connectionManagementService(db).authorize(req.actor, "connections:read", {
...connectionRequestContext(req)!.resource, connectionId: endpoint.connectionId, targetAgentId: endpoint.assignedAgentId,
}, { mutation: false });
}
res.json(binding);
});
return router;
}
function standardRequest(req: ExpressRequest): Request {
const headers = new Headers();
for (const [name, value] of Object.entries(req.headers)) {
if (Array.isArray(value))
value.forEach((entry) => headers.append(name, entry));
else if (value !== undefined) headers.set(name, value);
}
const host = req.get("host") ?? "localhost";
const protocol = req.protocol || "https";
const capturedBody = (req as ExpressRequest & { rawBody?: Buffer }).rawBody;
const rawBody =
capturedBody ??
(Buffer.isBuffer(req.body)
? req.body
: Buffer.from(JSON.stringify(req.body ?? {})));
return new Request(`${protocol}://${host}${req.originalUrl}`, {
method: req.method,
headers,
body:
req.method === "GET" || req.method === "HEAD"
? undefined
: new Uint8Array(rawBody),
});
}
async function writeStandardResponse(response: Response, res: ExpressResponse) {
response.headers.forEach((value, name) => {
if (
!["content-encoding", "content-length", "transfer-encoding"].includes(
name.toLowerCase(),
)
) {
res.setHeader(name, value);
}
});
const body = Buffer.from(await response.arrayBuffer());
res.status(response.status).send(body);
}
/** Provider-authenticated ingress. Mount outside the board mutation guard. */
export function chatWebhookRoutes(
service: ChatChannelService,
options: ChatWebhookRouteOptions = {},
) {
const router = Router();
const rateLimiter =
options.rateLimiter ??
createInviteRateLimiter({
windowMs: CHAT_WEBHOOK_RATE_LIMIT_WINDOW_MS,
maxRequests: CHAT_WEBHOOK_RATE_LIMIT_MAX_REQUESTS,
});
router.post("/api/chat-webhooks/:publicId/:provider", async (req, res) => {
recordChatWebhookStage("handler_started");
// Provider signatures are intentionally verified inside Chat SDK, but an
// attacker should not receive an unbounded cryptographic/JSON-processing
// budget. `req.ip` follows Express's configured trusted-proxy boundary;
// the public endpoint id also keeps unrelated bots from sharing a bucket.
const limit = rateLimiter.consume(
`${req.params.publicId}:${req.ip || req.socket?.remoteAddress || "unknown"}`,
);
res.setHeader("X-RateLimit-Limit", String(limit.limit));
res.setHeader("X-RateLimit-Remaining", String(limit.remaining));
if (!limit.allowed) {
res.setHeader("Retry-After", String(limit.retryAfterSeconds));
throw tooManyRequests("Too many chat webhook requests", {
retryAfterSeconds: limit.retryAfterSeconds,
});
}
const provider = req.params.provider as ChatProvider;
if (!CHAT_PROVIDERS.includes(provider) || provider === "agentmail" || provider === "speko")
throw badRequest("Unsupported chat provider");
const response = await service.handleWebhook(
req.params.publicId as string,
provider,
standardRequest(req),
);
recordChatWebhookStage("response_ready");
await writeStandardResponse(response, res);
});
return router;
}