mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 00:26:28 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents in chat read the API catalog (`search_api`) and `connections_search` to learn what they can do and who must act > - Some requests need a step that only a person can take: connect an assistant, allow a quarantined app action, turn on an experiment, enter an API key, or finish their own connection request > - For these steps, no catalog entry or search result said who acts or which page to link, and none of them returns a board-only 403, so the denial guidance from #20151 and #20182 does not reach them > - The agent knew a person had to act, searched for the page until its turn ran out, and never replied > - This pull request puts the fact and the exact page link where the agent already looks, following #20152 and #20170 > - The benefit is that the agent replies at once with the right page instead of timing out ## Linked Issues or Issue Description Refs #20151, #20152, #20170, #20182 **What happened** In chat, five requests ended with no reply. The agent searched the API until the turn deadline: - "Connect my assistant so it can see our tasks." `GET /api/mcp/setup` is refused for agents. Its catalog entry had no description, so the refusal text "Its catalog description says who can use it" pointed at nothing. - "Use the app's new export feature." The new action was quarantined. No catalog entry said where a person reviews it. - "Let me chat with Morgan over iMessage." `connections_search` hides chat channels while the Chat connectors experiment is off, so the agent could not find iMessage or the setting that hides it. `POST /api/companies/{companyId}/chat-endpoints` also accepted a draft endpoint for the turned-off provider. - "Connect my account using an API key." The service was the person's own MCP server. `connections_search` returned no result and did not name the secure "Connect your own MCP server" page. - "I will finish Alex's connection request for them." No catalog entry said that only the addressee can resolve a connection request card. **Expected behavior** The agent says who must act, links the exact page, and replies in the same turn. It does not search further, does not ask for a key in chat, and does not create another request. **Steps to reproduce** 1. Ask a chat agent one of the five requests above, in the matching state (for example, Chat connectors off, or a quarantined catalog entry). 2. Watch the run events. The agent calls `search_api` and `call_api` until the turn deadline and posts no reply. ## What Changed - `server/src/routes/openapi.ts`: add catalog descriptions that say who acts, link the page, and say not to search further: - `GET /api/mcp/setup` and `POST /api/mcp/requests/{id}/consent` link [Assistant connections](/<prefix>/apps/assistant-connection) and say the consent screen offers only access within the person's own role. - `GET /api/tool-connections/{connectionId}/catalog`, `.../review-counts` and `GET /api/companies/{companyId}/tools/apps/attention` say a quarantined action waits for a person on `/<prefix>/apps/<connectionId>/permissions`. - The four `/api/connection-intents/{interactionId}/...` routes say only the addressee can resolve the card, and link the task that holds it. - `server/src/services/connection-intents.ts`: - While Chat connectors is off, a search that names a chat-only channel (for example iMessage) returns it as `unavailable`, with a reason that links `/<prefix>/company/settings/instance/experimental`. Channels that also offer tools, such as Slack, keep their current result. - A single `unavailable` result now always gets the "explain the reason, do not bypass" instruction. - When no listed service matches and the person gave the service's own server URL, the instruction links `/<prefix>/apps/byo`, the secure "Connect your own MCP server" page, and says never to ask for a key in chat. - `server/src/routes/chat-channels.ts`: `POST /api/companies/{companyId}/chat-endpoints` returns 403 with the Experimental settings page while the provider's experiment is off (Chat connectors, or GitHub review bots for GitHub). AgentMail is unchanged. The catalog entry says the same. The UI already hides the setup pages in this state. - `GET /api/issues/{id}/interactions` says only the addressee resolves a connection request card. - A hidden channel that an aggregator can serve (for example Discord) keeps its aggregator routes instead of the unavailable result. - Tests: `person-only-page-guidance.test.ts` checks the catalog text and that every named page is a route that `ui/src/App.tsx` mounts. `connection-intents-service.test.ts` covers the hidden channel (off and on) and the own-server page. ## Verification - `cd server && npx vitest run src/__tests__/person-only-page-guidance.test.ts src/__tests__/connection-intents-service.test.ts` passes. `chat-channels.integration.test.ts` passes with `-t` for every test that posts to the create route. - These related suites pass: `openapi-routes`, `connection-aggregator-fallback`, `generic-mcp-connection`, `assistant-connections-guidance`, `company-model-service-settings-guidance`, `board-access-denial`, `email-channels.integration`, and the eight suites that import `runner-api-catalog`. - `npx tsc --noEmit -p server` shows no errors in the changed files. - End to end: the full-stack chat evals for the five requests ran on a branch with this change. The run results are in the comments below. ## Risks - Low risk. The change is text in the API catalog and in `connections_search` results, plus one result for a channel that was hidden before. - A hidden channel now shows in search as `unavailable` with no methods. It cannot be set up from the result, so the gate stays in place. - Chat endpoint creation is now refused while the experiment is off. Callers that created endpoints with the setting off now get 403. Three integration tests that relied on that now turn the setting on first, and a new test covers the refusal. - The single-`unavailable` instruction now also applies in suggestion mode. Before, that mode told the agent to "respect unavailable states" with no reason. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), used through Claude Code with tool use (shell, file edits, test runs). ## 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 - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
831 lines
43 KiB
TypeScript
831 lines
43 KiB
TypeScript
import { connectionManagementService } from "../services/connection-management.js";
|
|
import { chatCredentialRotationId, connectionIntentService, savedChatSetupPath } 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,
|
|
type ConnectionRequiredUserAction,
|
|
} 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,
|
|
conflict,
|
|
notFound,
|
|
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, kind: ConnectionRequiredUserAction["kind"] = "authenticate", options: { requireCredentialRotation?: boolean; credentialFields?: 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 action = options.requireCredentialRotation ? "rotate_credentials" as const : kind === "enter_credentials" || kind === "confirm_identity" ? kind : undefined;
|
|
const handoff = 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, { action, credentialFields: options.credentialFields })
|
|
: null;
|
|
const interactionId = handoff?.interactionId ?? null;
|
|
const previousRotationId = handoff ? handoff.previousRotationId : chatCredentialRotationId(endpoint.setup);
|
|
return { status: "needs_user_action", interactionId, endpointId: endpoint.id, connectionId: endpoint.connectionId,
|
|
...requiredConnectionUserAction(req, { type: "chat_endpoint", id: endpoint.id, companyId: endpoint.companyId },
|
|
savedChatSetupPath(company!.prefix, endpoint, action, previousRotationId, { fields: options.credentialFields, requireAll: !!options.credentialFields }), reason, kind),
|
|
};
|
|
}
|
|
|
|
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");
|
|
}
|
|
|
|
function assertChatProviderEnabledForCreate(
|
|
provider: string,
|
|
experimental: { enableChatConnectors: boolean; enableGitHubReviewBots: boolean },
|
|
) {
|
|
if (provider === "agentmail") return;
|
|
if (provider === "github" ? experimental.enableGitHubReviewBots : experimental.enableChatConnectors) return;
|
|
const setting = provider === "github" ? "GitHub review bots" : "Chat connectors";
|
|
throw forbidden(`${setting} is turned off on this instance, so this chat endpoint cannot be created. An instance admin turns it on in Experimental settings at /<prefix>/company/settings/instance/experimental. Agents cannot change instance settings.`);
|
|
}
|
|
|
|
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);
|
|
assertChatProviderEnabledForCreate(req.body.provider, await instanceSettingsService(db).getExperimental());
|
|
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", { slackManagerGrant: true }), 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");
|
|
const review = await github.review(endpointId(req), reviewId.data);
|
|
const visible = await filterConnectionManagementRows(db, req, "connections:read", [review], row => ({ issueId: row.issueId }));
|
|
if (!visible.length) throw notFound("This review was not found in this connection");
|
|
res.json(visible[0]);
|
|
});
|
|
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 filterConnectionManagementRows(db, req, "connections:read", await github.reviews(endpointId(req)), row => ({ issueId: row.issueId })));
|
|
});
|
|
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(endpointId(req), 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.", "confirm_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"), async (req, res, next) => {
|
|
if (req.actor.type !== "agent") { next(); return; }
|
|
if (!(await assertEndpointManagementAccess(req, res))) return;
|
|
const endpoint = await service.get(endpointId(req));
|
|
if (endpoint.provider !== "github") throw notFound("GitHub bot not found");
|
|
if (["archived", "active", "paused"].includes(endpoint.status)) throw conflict("Use the reconnect flow for an existing active bot");
|
|
res.set("Cache-Control", "no-store");
|
|
res.json(await setupHandoff(req, "The responsible user must enter the GitHub App credentials and webhook signing secret.", "enter_credentials"));
|
|
}, 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.set("Cache-Control", "no-store");
|
|
try {
|
|
res.json(await service.configure(endpointId(req), req.body, actorUserId(req), connectionRequestContext(req)));
|
|
} catch (error) {
|
|
if (req.actor.type !== "agent" || !(error instanceof HttpError) || error.status !== 403
|
|
|| !error.details || typeof error.details !== "object" || !("code" in error.details)
|
|
|| error.details.code !== "chat_endpoint_credential_entry_requires_human") throw error;
|
|
const credentialFields = "credentialFields" in error.details && Array.isArray(error.details.credentialFields)
|
|
? error.details.credentialFields.filter((field): field is string => typeof field === "string") : undefined;
|
|
res.json(await setupHandoff(req, "The responsible user must enter provider verification credentials in the saved setup.", "enter_credentials", { credentialFields }));
|
|
}
|
|
},
|
|
);
|
|
|
|
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");
|
|
if (req.actor.type === "agent") {
|
|
if ((await service.get(endpointId(req))).provider !== "github") throw badRequest("Setup secret generation is only available for GitHub");
|
|
res.json(await setupHandoff(req, "The responsible user must rotate and enter the webhook signing secret in GitHub.", "enter_credentials", { requireCredentialRotation: true }));
|
|
return;
|
|
}
|
|
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;
|
|
}
|