docs(api): publish X setup and task tool contracts

Document board and task/run authentication and preserve provider-only webhook coverage exclusions.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-09-27 21:03:40 -05:00
1 parent a9070242d1
commit b4d81f3905
3 files changed
+54 -3

No files matched your search

+21 -1
View File
@@ -95,6 +95,7 @@ const explicitOpenApiOperationCoverageExclusions = new Set([
// Paperclip board/agent credential. It intentionally stays out of the public
// board API document, while this exact exclusion keeps route coverage honest.
"POST /api/chat-webhooks/agentmail/{publicId}",
"GET /api/chat-webhooks/{publicId}/{provider}",
"POST /api/chat-webhooks/{publicId}/{provider}",
]);
@@ -485,7 +486,7 @@ describe("openapi routes", () => {
properties: {
provider: {
type: "string",
enum: ["slack", "github", "discord", "microsoft-teams", "telegram", "imessage-photon"],
enum: ["x", "slack", "github", "discord", "microsoft-teams", "telegram", "imessage-photon"],
},
assignedAgentId: { type: "string", format: "uuid" },
},
@@ -710,6 +711,25 @@ describe("openapi routes", () => {
).toBeUndefined();
});
it("documents X setup as board-only and tools as task/run-bound", () => {
const { spec } = loadSpecRoutes();
for (const [method, routePath] of [
["post", "/api/chat-endpoints/{endpointId}/x/authorize"],
["get", "/api/chat-endpoints/{endpointId}/x/identity"],
["put", "/api/chat-endpoints/{endpointId}/x/progress"],
["post", "/api/chat-endpoints/{endpointId}/x/finish"],
["get", "/api/x/oauth/callback"],
["get", "/api/x/identity/{confirmationId}"],
["post", "/api/x/identity/{confirmationId}/confirm"],
]) {
expect(spec.paths[routePath][method]["x-paperclip-authorization"]).toEqual({ actor: "board" });
}
const tool = spec.paths["/api/companies/{companyId}/x/tasks/{issueId}/tools"].post;
expect(tool["x-paperclip-authorization"]).toEqual({ actor: "agent", heartbeatBound: true, taskBound: true });
expect(tool.requestBody.content["application/json"].schema.properties.tool.enum).toEqual(["x_read_thread", "x_reply", "x_delivery"]);
expect(spec.paths["/api/chat-webhooks/{publicId}/{provider}"]).toBeUndefined();
});
it("covers the mounted server routes exactly", () => {
const {
routes: actualRoutes,
+2 -1
View File
@@ -621,7 +621,8 @@ export function chatWebhookRoutes(
windowMs: CHAT_WEBHOOK_RATE_LIMIT_WINDOW_MS,
maxRequests: CHAT_WEBHOOK_RATE_LIMIT_MAX_REQUESTS,
});
router.route("/api/chat-webhooks/:publicId/:provider").get(webhook).post(webhook);
router.get("/api/chat-webhooks/:publicId/:provider", webhook);
router.post("/api/chat-webhooks/:publicId/:provider", webhook);
async function webhook(req: ExpressRequest, res: ExpressResponse) {
if (req.method === "GET" && req.params.provider !== "x") throw badRequest("GET challenges are only supported for X");
recordChatWebhookStage("handler_started");
+31 -1
View File
@@ -795,6 +795,8 @@ const chatEndpointSetupResponseSchema = z
.strict()
.optional(),
callbacksNeedUpdate: z.boolean().optional(),
x: z.object({ stage: z.number().int().min(1).max(5), clientConfigured: z.boolean().optional() }).optional(),
testSkipped: z.boolean().optional(),
})
.strict();
@@ -1491,6 +1493,13 @@ const BOARD_ONLY_OPERATIONS = new Set([
// policy, and replay controls. Every mounted handler asserts a board actor;
// keep the generated security contract equally restrictive.
"GET /api/slack/search/callback",
"POST /api/chat-endpoints/{endpointId}/x/authorize",
"GET /api/chat-endpoints/{endpointId}/x/identity",
"PUT /api/chat-endpoints/{endpointId}/x/progress",
"POST /api/chat-endpoints/{endpointId}/x/finish",
"GET /api/x/oauth/callback",
"GET /api/x/identity/{confirmationId}",
"POST /api/x/identity/{confirmationId}/confirm",
"GET /api/companies/{companyId}/slack/endpoints/{endpointId}/capabilities",
"GET /api/companies/{companyId}/slack/endpoints/{endpointId}/search",
"PUT /api/companies/{companyId}/slack/endpoints/{endpointId}/search",
@@ -1635,7 +1644,7 @@ function resolveOperationAuthLevel(
): OpenApiAuthLevel {
const key = operationKey(method, path);
if (PUBLIC_OPERATIONS.has(key)) return "public";
if (key === "POST /api/mcp/project-tools" || key === "POST /api/companies/{companyId}/slack/tasks/{issueId}/tools") return "agent_run";
if (key === "POST /api/mcp/project-tools" || key === "POST /api/companies/{companyId}/slack/tasks/{issueId}/tools" || key === "POST /api/companies/{companyId}/x/tasks/{issueId}/tools") return "agent_run";
if (RUNTIME_TOOLS_OPERATIONS.has(key)) return "runtime_tools";
if (INSTANCE_ADMIN_OPERATIONS.has(key)) return "instance_admin";
if (
@@ -2138,6 +2147,27 @@ registry.registerPath({ method: "get", path: "/api/slack/search/callback", tags:
// ─── Chat Channels ─────────────────────────────────────────────────────────
for (const [method, path, summary, body] of [
["post", "/api/chat-endpoints/{endpointId}/x/authorize", "Authorize the X bot or link a personal X identity", z.object({ purpose: z.enum(["bot", "identity"]), client: z.object({ clientId: z.string().min(1), clientSecret: z.string().min(1) }).strict().optional() }).strict()],
["get", "/api/chat-endpoints/{endpointId}/x/identity", "Read the signed-in user's X identity link", undefined],
["put", "/api/chat-endpoints/{endpointId}/x/progress", "Save X setup progress", z.object({ stage: z.number().int().min(1).max(5) }).strict()],
["post", "/api/chat-endpoints/{endpointId}/x/finish", "Finish X setup after verified delivery and identity linking", undefined],
["get", "/api/x/identity/{confirmationId}", "Preview a private X identity confirmation", undefined],
["post", "/api/x/identity/{confirmationId}/confirm", "Confirm a personal X identity against the signed-in account", undefined],
["post", "/api/companies/{companyId}/x/tasks/{issueId}/tools", "Execute a conversation-bound X tool", z.object({ tool: z.enum(["x_read_thread", "x_reply", "x_delivery"]), arguments: z.record(z.string(), z.unknown()) }).strict()],
] as const) {
registry.registerPath({ method, path, tags: ["chat-channels"], summary,
description: "Experimental X connector. Current company, account, participant and task/run authority are rechecked. Only x_reply queues a public post; its exact text and invoking target are immutable. Reuse the UUID idempotencyKey and check x_delivery after uncertainty. Internal task activity never posts. Human OAuth tokens cannot replace bot credentials.",
request: { params: z.object(Object.fromEntries([...path.matchAll(/\{([^}]+)\}/g)].map(match => [match[1], z.string().uuid()]))), ...(body ? { body: jsonBody(body) } : {}) },
responses: { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict, 422: r.unprocessable },
});
}
registry.registerPath({ method: "get", path: "/api/x/oauth/callback", tags: ["chat-channels"], summary: "Complete user-bound X PKCE authorization",
description: "Consumes single-use state for the same signed-in Paperclip account. Bot authorization also requires current connection management permission. Redirects to setup or private identity confirmation.",
request: { query: z.object({ state: z.string(), code: z.string() }) },
responses: { 302: { description: "Redirect to X setup or identity confirmation" }, 400: r.badRequest, 401: r.unauthorized, 403: r.forbidden, 404: r.notFound, 409: r.conflict },
});
registry.registerPath({
method: "get",
path: "/api/companies/{companyId}/chat-endpoints",