mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 08:22:20 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Slack connects a person to an agent through an installed app and a verified identity. > - Independent installations must remain usable without Paperclip Cloud or manager credentials. > - Approved Cloud deployments can use Slack manager grants to create and install each agent's app. > - Both paths should share credential storage, identity checks, recovery, and the existing runtime. > - This pull request adds gated managed setup and keeps independent app setup under Advanced. > - It also makes ongoing connection settings and personal test messages easier to use. ## Linked Issues or Issue Description **Subsystem affected** Slack setup, connection settings, tenant credential storage, and the Cloud connector handoff. **Problem or motivation** Approved deployments need an installation flow without pasted configuration tokens. Open-source users must retain their independent Slack app flow. A connection manager also needs a simple way to check a bot from Settings. **Proposed solution** Use the managed path when the instance is configured to support it. Choose agent and Connect are the two visible stops. Continue starts authorization or installation. Use your own app remains under Advanced before creation. Save personal manager grants by company, Paperclip user, Slack user, workspace, and manager app. Reuse only verified identities for test messages. Unknown identities must complete a private Slack connection challenge before linking and receiving the test DM. **Alternatives considered** A shared bot would change each agent's Slack identity. Requiring Slack CLI or a hosted service for self-hosted users would remove independent installation. **Roadmap alignment** This maintainer-requested change improves Connected Apps and experimental Agent Chat. It adds provisioning and management controls to the existing Slack runtime. **Additional context** The companion Cloud broker profile is a separate deployment prerequisite. Managed setup remains gated. Existing-app adoption is deferred. ## What Changed - Extract Slack registration, provider requests, completion, customer-owned setup, managed setup, and personal manager grants into focused services. Preserve the compatibility facade and independent removal cleanup. - Add company-scoped manager grants and a registration reference through an additive, idempotent migration after current master. Keep runtime credentials separate from app-registration secrets. - Add authenticated manager authorization, sealed handoff, serialized refresh and revocation, app creation, managed installation, and installer-bound OAuth fallback. - Preserve creation intent, block automatic uncertain recreation, save credentials before downstream checks, and validate app, workspace, bot, scopes, and installer identity. - Add the two-stop production wizard and ordered stories. Keep the own-app, manual, existing-app, and legacy-draft paths. - Simplify Settings. Open Slack configuration targets the saved app. The test-message action has its own row. Remove the old manual-avatar section, channel-message section, and broken permissions link. - Add paginated account discovery and a private connection challenge for unknown accounts. Expired invitations stop waiting and offer a new private invitation. Save send receipts before provider dispatch and preserve confirmed delivery when activity logging fails. - Reinstall the saved managed app on explicit reconnect, replace revoked credentials, and resume completion. Persist reconnect intent and duplicate-request receipts without creating another app. - Update provider browser tests, recovery tests, secret redaction, setup documentation, and the agent prompt. Preserve current master's durable verification scheduling. ## Verification - Full workspace typecheck and repository build passed on `56d389d47a`. The subsequent reconnect retry fix in `288364eada` also passed UI typecheck and UI build. - 114 focused Slack registration cases and 13 production managed-wizard cases passed, including fresh-token reconnect, retry after installation failure, and duplicate reconnect requests. - All nine affected Slack browser cases passed against the production UI and a disposable fixture server. They cover managed setup, automatic setup, own-app selection, refresh, recovery, mobile behavior, and connection settings. - Eight focused real-database test-message cases passed. They cover a private ownership challenge, saved grant reuse, account pagination, revoked and conflicting links, workspace mismatch, disabled DMs, revoked membership, audit failure, ambiguous dispatch, and secret canaries. - UI token gates and the production Storybook build passed. - Greptile reviewed exact head `288364eada5d9c114fad991ff634612639d06672` at 5/5 with no actionable findings or unresolved review threads. - The full repository test suite and current-head CI are running. Their final results will replace this line before merge. - Earlier live feature previews created two bots in the authorized Paperclip Slack workspace and reused one personal manager grant. Local managed Continue created and installed a bot without a configuration token or a separate Add to Slack action. Avatars, welcome DMs, signed callbacks, and direct replies were observed. A Settings test DM also arrived in the signed-in user's Slack account. - Earlier staging thread qualification required manual publication recovery for one bot. Current-head live qualification remains required before enabling the production default. Fixture results do not establish Slack approval behavior in other workspaces. ## Risks - Personal manager tokens can create apps. They stay in scoped vault entries. The manager client secret stays at the broker. Company, actor/session, origin, and app identity checks apply throughout setup. - The additive migration must also work on qualification instances that applied earlier branch migrations. Table, column, index, and constraint creation is idempotent. - Creation and message timeouts can leave uncertain outcomes. Saved request IDs prevent automatic duplicate dispatch. - Unknown Slack identities must receive and complete their private challenge while signed in. Account selection alone does not grant Paperclip permissions. Existing revoked or conflicting links are not overwritten. - A development-only manager transport uses an explicitly configured private credential file for local qualification. Production ignores this transport; no manager credentials are bundled with open source. - Managed rollout requires Slack approval, the companion broker deployment, configured secrets, and final live qualification. Removing a connection does not delete its Slack app. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, code execution, and browser tools. The session does not expose the exact serving model 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 - [ ] 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 - [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>
752 lines
32 KiB
TypeScript
752 lines
32 KiB
TypeScript
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,
|
|
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 {
|
|
const actor = getActorInfo(req);
|
|
return actor.actorType === "user" ? actor.actorId : null;
|
|
}
|
|
|
|
async function assertEndpointAccess(
|
|
req: ExpressRequest,
|
|
res: ExpressResponse,
|
|
service: ChatChannelService,
|
|
): Promise<boolean> {
|
|
assertBoard(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 service = options.service ?? chatChannelService(db, options);
|
|
const access = accessService(db);
|
|
const github = githubChatManagementService(db, options.fetch);
|
|
|
|
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,
|
|
) {
|
|
assertBoard(req);
|
|
assertCompanyAccess(req, companyId);
|
|
if (req.actor.source === "local_implicit" || req.actor.isInstanceAdmin)
|
|
return;
|
|
const userId = req.actor.userId;
|
|
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", async (req, res) => {
|
|
assertBoard(req);
|
|
const companyId = req.params.companyId as string;
|
|
assertCompanyAccess(req, companyId);
|
|
res.json(await service.list(companyId));
|
|
});
|
|
|
|
router.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)));
|
|
},
|
|
);
|
|
|
|
router.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) => {
|
|
assertBoard(req);
|
|
const actor = getActorInfo(req);
|
|
if (actor.actorType !== "user") throw forbidden("A board session is required");
|
|
return { userId: actor.actorId, sessionId: actor.sessionId,
|
|
bypassPermissionCheck: req.actor.source === "local_implicit" || req.actor.isInstanceAdmin === true };
|
|
};
|
|
router.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", validate(slackSetupActionSchema), async (req, res) => {
|
|
if (!(await assertEndpointManagementAccess(req, res))) return;
|
|
res.set("Cache-Control", "no-store");
|
|
res.json(await service.slackRegistration.managerGrants.authorize(endpointId(req), slackActor(req)));
|
|
});
|
|
router.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", 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");
|
|
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", 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", 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", validate(slackSetupActionSchema), async (req, res) => {
|
|
if (!(await assertEndpointManagementAccess(req, res))) return;
|
|
res.set("Cache-Control", "no-store");
|
|
res.json(await service.slackRegistration.install(endpointId(req), slackActor(req)));
|
|
});
|
|
router.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");
|
|
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", 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", validate(updateGitHubChatConfigurationSchema), async (req, res) => {
|
|
if (!(await assertEndpointManagementAccess(req, res))) return;
|
|
res.json(await github.saveConfiguration(endpointId(req), req.body, githubUser(req)));
|
|
});
|
|
router.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", 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", 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", 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", 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", 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", 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", 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");
|
|
res.json(req.body.ownerType === undefined
|
|
? await service.startGitHubRegistration(endpointId(req), githubUser(req), req.body.name)
|
|
: await service.githubWizard.start(endpointId(req), githubUser(req), req.body));
|
|
});
|
|
router.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));
|
|
});
|
|
router.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));
|
|
});
|
|
router.post("/chat-endpoints/:endpointId/github/setup", async (req, res) => {
|
|
if (!(await assertEndpointManagementAccess(req, res))) return;
|
|
res.set("Cache-Control", "no-store");
|
|
res.json(await service.githubWizard.advance(endpointId(req), githubUser(req)));
|
|
});
|
|
router.post("/chat-endpoints/:endpointId/github/identity/start", async (req, res) => {
|
|
if (!(await assertEndpointManagementAccess(req, res))) return;
|
|
res.set("Cache-Control", "no-store");
|
|
res.json(await service.githubWizard.startIdentity(endpointId(req), githubUser(req)));
|
|
});
|
|
router.post("/chat-endpoints/:endpointId/github/identity/confirm", 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", 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));
|
|
});
|
|
router.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)));
|
|
});
|
|
router.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", 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",
|
|
validate(updateChatEndpointSchema),
|
|
async (req, res) => {
|
|
if (!(await assertEndpointManagementAccess(req, res))) return;
|
|
res.json(
|
|
await service.update(endpointId(req), req.body, actorUserId(req)),
|
|
);
|
|
},
|
|
);
|
|
|
|
router.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",
|
|
validate(configureChatEndpointSchema),
|
|
async (req, res) => {
|
|
if (!(await assertEndpointManagementAccess(req, res))) return;
|
|
res.json(
|
|
await service.configure(endpointId(req), req.body, actorUserId(req)),
|
|
);
|
|
},
|
|
);
|
|
|
|
router.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)),
|
|
);
|
|
});
|
|
|
|
router.post("/chat-endpoints/:endpointId/test", async (req, res) => {
|
|
if (!(await assertEndpointManagementAccess(req, res))) return;
|
|
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)));
|
|
});
|
|
|
|
router.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),
|
|
),
|
|
);
|
|
},
|
|
);
|
|
|
|
router.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",
|
|
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",
|
|
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", async (req, res) => {
|
|
if (!(await assertEndpointAccess(req, res, service))) return;
|
|
res.json(await service.listConversations(endpointId(req)));
|
|
});
|
|
|
|
router.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));
|
|
} else {
|
|
res.json(await service.listActivity(endpointId(req)));
|
|
}
|
|
});
|
|
|
|
router.post(
|
|
"/chat-endpoints/:endpointId/deliveries/:deliveryId/replay",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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",
|
|
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", async (req, res) => {
|
|
assertBoard(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;
|
|
}
|
|
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;
|
|
}
|