Files
PaperClipAI/server/src/__tests__/openapi-routes.test.ts
T
DottaandPaperclip fc6304dfe5 feat(runner): add experimental OpenAI Dot provider over MCP Events (#15402)
## Thinking Path

> - Paperclip manages AI agents, tasks, permissions, and execution
budgets.
> - Paperclip Runner gives each provider the same admitted task and tool
authority.
> - OpenAI Dot runs outside the local process tree and needs
asynchronous work delivery.
> - The merged MCP gateway supplies OAuth consent and signed event
delivery.
> - A personal assistant grant cannot safely stand in for an assigned
agent.
> - This pull request adds a separate Dot agent connection and a durable
Rust Runner bridge.
> - The operator can assign work to Dot and inspect its accepted work,
tool receipts, and result.

## Linked Issues or Issue Description

**Agent or provider**

OpenAI Dot, as an experimental provider of the existing Paperclip Runner
adapter.

**Why this adapter is useful**

An operator can assign normal Paperclip tasks to an existing Dot. Dot
can read its mailbox, request work on an assigned task, use admitted
task tools, and submit a result. Paperclip keeps company scope,
checkout, approvals, known budget limits, and activity attribution.

**How the agent is invoked**

A dedicated `/mcp/runner` OAuth resource pairs one Dot grant with one
agent. A signed MCP mailbox event wakes Dot. Dot explicitly accepts the
assignment. The Rust Runner owns the durable turn and operation
receipts. The first release supports self-hosted instances with a local
Runner controller.

**Additional context**

This extends the merged public MCP gateway from #14846 and the assistant
invitation and device-consent work from #14933. This also integrates the
merged assistant tool and configuration expansion in #15380. Dot retains
its dedicated agent resource and cannot receive personal configuration
permission. The public assistant connection remains a personal
connection.

## What Changed

- Add a durable Rust Dot provider and its TypeScript Runner driver.
- Add closed PRP v3 external-provider operations and native execution
input v6.
- Add company-scoped pairing, mailbox, assignment, and operation
records.
- Reuse merged browser/device consent, client metadata verification,
webhook admissions, refresh, secret rotation, and warm-standby gates.
- Keep Dot scopes, issuer, grants, event workers, and tool access
separate from personal assistant access.
- Add Dot configuration, pairing, readiness, and consent UI. Keep agent
grants out of the personal Connections entry.
- Regenerate the Dot-only migration after master. Preserve published
gateway migrations. Make the new migration safe to reapply.
- Document setup, recovery, accounting limits, evidence, and remaining
account qualification.
- Reverify reconnect callbacks and wake outstanding work with a fresh
mailbox reference; preserve the existing assignment and operation
receipts.
- Clean up Dot bindings and waiting runs on OAuth revoke and
refresh-token replay. Old grants cannot revoke replacement bindings.
- Restore the pairing reference when an unsaved agent form is reopened;
document board-only pairing routes in OpenAPI.
- Accept a clean Rust exit after the acknowledged shutdown receipt.
Unexpected exits still require recovery.
- Clear the cached binding after a successful revoke so a failed
connection refresh cannot restore it.
- Add production-component Storybook states and screenshots for pairing
and connection review. All preview account data is synthetic.
- Persist normalized completion, serialize Dot turns and durable work
admission, and poll subscription readiness.
- Serialize mailbox writes and cursor reads; retain paused fence
acknowledgement without task authority.
- Authorize admitted review runs without changing the worker assignee.
Include the fenced assignment ID in production stop notices.

## Verification

- This PR integrates master `4a8178e9c`. Dot migration
`0317_messy_famine.sql` follows the published history and is safe to
reapply. The merge preserves the reserved migration connection,
batch-commit handling, private task checks, task monitors, and native
accounting.
- Local workspace typecheck, full build, and UI token gates pass. The
server typecheck passes after the review fixes. Database and native
executor regressions pass.
- All twelve real Rust/PostgreSQL Dot integration tests and twelve Dot
driver tests pass. The tests cover native document writing and
finalization, durable replay, queue admission, mailbox ordering,
admitted reviews, stale authority, production stop references, and
paused acknowledgements.
- Current head `d0e7e0626` passes all 57 checks: 53 pass and four are
intentionally skipped. This includes full typecheck, build, tests, Rust
Runner verification, browser E2E, release verification, and Canary Dry
Run. Greptile rates this exact head 5/5. All review threads are
resolved.
- The full local root test run is slower than the sharded CI run and has
not completed. The full CI test gates pass on the current commit.
Focused local regressions pass.
- Real-account pairing and event delivery on this base commit remain
unqualified. Live account and setup proof are recorded in the follow-up
#15414.

The following screenshots use synthetic preview data. They show the
production pairing component and do not qualify a real account or the
full agent setup journey.

![Synthetic pairing
preview](https://github.com/paperclipai/paperclip/blob/codex/dot-events-prototype/doc/screenshots/openai-dot-runner/pairing.jpg?raw=true)

![Synthetic connected
preview](https://github.com/paperclipai/paperclip/blob/codex/dot-events-prototype/doc/screenshots/openai-dot-runner/connected.jpg?raw=true)

## Risks

- This base adapter uses `PAPERCLIP_ENABLE_OPENAI_DOT=1` plus Public MCP
and Paperclip Runner. The separate experimental-settings follow-up in
#15414 replaces this environment flag with saved operator settings.
- Dot does not expose provider token usage or cost. The operator must
acknowledge external billing. Known Paperclip budget gates still apply.
- Cancellation fences Paperclip authority. It does not confirm that Dot
stopped all external activity.
- Assigned skill files and third-party MCP bindings are unsupported and
reject admission. There is no mounted workspace, model selector, or
provider thread identifier.
- Hosted agent-broker and remote controller deployments are not
qualified.
- The new migration follows the merged master history. Existing
prototype databases still need the normal master migration history
before this Dot-only migration.

## Model Used

OpenAI Codex, based on GPT-6. The exact deployment ID and context window
size are not exposed in this session. Capabilities used: reasoning,
repository editing, code execution, and test inspection.

## 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 #123` 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 references)
- [x] My branch name describes the change 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
- [x] All Paperclip CI gates are green
- [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups
- [x] I will address all Greptile and reviewer comments before
requesting merge

---------

Co-authored-by: Paperclip <noreply@paperclip.ing>
2026-10-07 19:11:50 -05:00

1076 lines
41 KiB
TypeScript

import express from "express";
import fs from "node:fs";
import path from "node:path";
import { fileURLToPath } from "node:url";
import request from "supertest";
import { describe, expect, it } from "vitest";
import { COMPANY_IMPORT_TRANSFERS_ROUTE_PATH } from "@paperclipai/shared/company-import-transfer";
import { errorHandler } from "../middleware/index.js";
import { buildOpenApiSpec, openApiRoutes } from "../routes/openapi.js";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const ROUTES_DIR = path.resolve(__dirname, "../routes");
const apiPrefixes: Record<string, string> = {
"pipelines.ts": "/api",
"cases.ts": "/api",
"smoke-lab.ts": "/api",
"access.ts": "/api",
"activity.ts": "/api",
"adapters.ts": "/api",
"agents.ts": "/api",
"agent-commentary.ts": "/api",
"agent-avatars.ts": "/api",
"announcements.ts": "/api",
"ai-connections.ts": "/api",
"decision-models.ts": "/api",
"attention.ts": "/api",
"approvals.ts": "/api",
"assets.ts": "/api",
"auth.ts": "/api/auth",
"board-chat.ts": "/api",
"browser-use.ts": "/api",
"built-in-agents.ts": "/api",
"chat-channels.ts": "/api",
"slack-tools.ts": "/api",
"email.ts": "/api",
"cloud.ts": "/api/cloud",
"customer-success.ts": "/api/customer-success/v1",
"companies.ts": "/api/companies",
"company-skills.ts": "/api",
"company-skill-policy.ts": "/api",
"connection-intents.ts": "/api",
"costs.ts": "/api",
"dashboard.ts": "/api",
"dot-runner.ts": "/api",
"decision-queues.ts": "/api",
"decisions.ts": "/api",
"decision-training.ts": "/api",
"environments.ts": "/api",
"execution-workspaces.ts": "/api",
"file-resources.ts": "/api",
"folders.ts": "/api",
"goals.ts": "/api",
"health.ts": "/api/health",
"inbox-agent-policy.ts": "/api",
"inbox-dismissals.ts": "/api",
"instance-database-backups.ts": "/api",
"instance-settings.ts": "/api",
"issues.ts": "/api",
"issue-tree-control.ts": "/api",
"llms.ts": "/api",
"managed-agent-profiles.ts": "/api",
"onboarding-seed.ts": "/api",
"openapi.ts": "/api",
"plugin-ui-static.ts": "/api",
"plugins.ts": "/api",
"projects.ts": "/api",
"primary-agent.ts": "/api",
"public-mcp.ts": "/api",
"project-tools.ts": "/api",
"resource-memberships.ts": "/api",
"remote-agent-profiles.ts": "/api",
"routines.ts": "/api",
"secrets.ts": "/api",
"sidebar-badges.ts": "/api",
"sidebar-preferences.ts": "/api",
"summary-slots.ts": "/api",
"status-cards.ts": "/api",
"teams-catalog.ts": "/api",
"tool-access.ts": "/api",
"tool-gateway.ts": "/api",
"user-profiles.ts": "/api",
};
const ROUTE_LITERAL_PATTERN =
/router\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g;
const ROUTER_METHOD_PATTERN = /router\.(get|post|put|patch|delete)\(/;
const HTTP_METHODS = new Set([
"get",
"put",
"post",
"delete",
"options",
"head",
"patch",
"trace",
]);
const explicitOpenApiCoverageExclusions = new Set<string>();
const explicitOpenApiOperationCoverageExclusions = new Set([
// Inspection uses its own versioned Cloud-permit/managed-run protocol,
// documented in CUSTOMER-SUCCESS-INSPECTION.md and the shared contract.
// Ordinary board sessions and agent API keys cannot invoke these endpoints.
"GET /api/customer-success/v1/run-authority",
"POST /api/customer-success/v1/read",
// OAuth discovery and protocol endpoints have their own metadata contract;
// browser connection-management operations remain documented in the board API.
"GET /.well-known/oauth-authorization-server",
"POST /mcp/oauth/register",
"POST /mcp/oauth/device_authorization",
"GET /mcp/oauth/authorize",
"POST /mcp/oauth/token",
"POST /mcp/oauth/revoke",
"GET /.well-known/oauth-authorization-server/mcp/runner/oauth",
"POST /mcp/runner/oauth/register",
"POST /mcp/runner/oauth/device_authorization",
"GET /mcp/runner/oauth/authorize",
"POST /mcp/runner/oauth/token",
"POST /mcp/runner/oauth/revoke",
// This endpoint is authenticated by the provider signature rather than by a
// 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}",
"POST /api/chat-webhooks/{publicId}/{provider}",
]);
// The set of contract-first routes whose OpenAPI document leads the mounted
// request handler. The company-and-environment Claude setup-token login routes
// now have request handlers, so the set is empty. A new contract-first route
// belongs here only until its handler lands.
const specOnlyContractFirstRoutes = new Set<string>([]);
function createApp() {
const app = express();
app.use("/api", openApiRoutes());
app.use(errorHandler);
return app;
}
// Route files may compose paths from shared path constants inside template
// literals; substitute the constants' values before normalizing.
const routePathConstantSubstitutions: Record<string, string> = {
"${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}": COMPANY_IMPORT_TRANSFERS_ROUTE_PATH,
};
function normalizeExpressPath(routePath: string) {
let substituted = routePath;
for (const [placeholder, value] of Object.entries(
routePathConstantSubstitutions,
)) {
substituted = substituted.split(placeholder).join(value);
}
return substituted
.replace(/\*([A-Za-z0-9_]+)/g, "{$1}")
.replace(/:([A-Za-z0-9_]+)/g, "{$1}")
.replace(/\/+/g, "/");
}
function resolveMountedPath(file: string, prefix: string, routePath: string) {
if (file === "public-mcp.ts" && (routePath.startsWith("/mcp/oauth/") || routePath.startsWith("/.well-known/"))) return routePath;
if (
(file === "chat-channels.ts" || file === "email.ts") &&
routePath.startsWith("/api/chat-webhooks/")
) {
return routePath;
}
if (file === "tool-gateway.ts" && routePath.startsWith("/mcp/gateways/")) {
return routePath;
}
if (
file === "connection-intents.ts" &&
(routePath.startsWith("/mcp/") || routePath.startsWith("/runtime-tools/"))
) {
return routePath;
}
if ((file === "companies.ts" || file === "health.ts") && routePath === "/") {
return prefix;
}
if (file === "companies.ts" || file === "health.ts") {
return `${prefix}${routePath}`;
}
if (file === "auth.ts") {
return `${prefix}${routePath === "/" ? "" : routePath}`;
}
return `${prefix}${routePath}`;
}
function loadActualRoutes() {
const routes = new Set<string>();
const excludedRoutes = new Set<string>();
const unknownRouteFiles: string[] = [];
for (const file of fs
.readdirSync(ROUTES_DIR)
.filter((entry) => entry.endsWith(".ts"))) {
if (explicitOpenApiCoverageExclusions.has(file)) continue;
const prefix = apiPrefixes[file];
const source = fs.readFileSync(path.join(ROUTES_DIR, file), "utf8");
if (!prefix) {
if (ROUTER_METHOD_PATTERN.test(source)) {
unknownRouteFiles.push(file);
}
continue;
}
for (const match of source.matchAll(ROUTE_LITERAL_PATTERN)) {
const method = match[1].toUpperCase();
const routePath = match[2];
const operation = `${method} ${normalizeExpressPath(resolveMountedPath(file, prefix, routePath))}`;
if (explicitOpenApiOperationCoverageExclusions.has(operation)) {
excludedRoutes.add(operation);
} else {
routes.add(operation);
}
}
if (file === "public-mcp.ts") {
// The shared gateway mounts these protocol paths for each OAuth resource.
if (source.includes("router.get(metadataPath,")) {
excludedRoutes.add("GET /.well-known/oauth-authorization-server");
excludedRoutes.add("GET /.well-known/oauth-authorization-server/mcp/runner/oauth");
}
for (const match of source.matchAll(/router\.(get|post)\(oauthPath \+ "([^"]+)"/g)) {
for (const oauthPath of ["/mcp/oauth", "/mcp/runner/oauth"]) {
const operation = `${match[1].toUpperCase()} ${oauthPath}${match[2]}`;
if (explicitOpenApiOperationCoverageExclusions.has(operation)) excludedRoutes.add(operation);
else routes.add(operation);
}
}
}
if (file === "dot-runner.ts") {
const basePath = /const path = "([^"]+)"/.exec(source)?.[1];
if (!basePath) throw new Error("Dot binding route prefix is missing");
for (const match of source.matchAll(/router\.(get|post|delete)\(path(?: \+ "([^"]+)")?/g)) {
routes.add(`${match[1].toUpperCase()} ${normalizeExpressPath(prefix + basePath + (match[2] ?? ""))}`);
}
}
if (
file === "companies.ts" &&
source.includes("router.post(COMPANY_IMPORT_ROUTE_PATH")
) {
routes.add("POST /api/companies/import");
}
if (
file === "companies.ts" &&
source.includes("router.post(COMPANY_IMPORT_TRANSFERS_ROUTE_PATH")
) {
routes.add(`POST /api/companies${COMPANY_IMPORT_TRANSFERS_ROUTE_PATH}`);
}
}
return {
routes,
excludedRoutes,
unknownRouteFiles: unknownRouteFiles.sort(),
};
}
function loadSpecRoutes() {
const spec = buildOpenApiSpec();
const routes = new Set<string>();
for (const [routePath, pathItem] of Object.entries<
Record<string, Record<string, unknown>>
>(spec.paths ?? {})) {
for (const method of Object.keys(pathItem)) {
if (HTTP_METHODS.has(method)) {
routes.add(`${method.toUpperCase()} ${routePath}`);
}
}
}
return { spec, routes };
}
describe("openapi routes", () => {
it("documents manager-only task privacy hints without protected scope identity", () => {
const spec = buildOpenApiSpec() as any;
const operation = spec.paths["/api/issues/{id}/privacy-constraints"].get;
const schema = operation.responses["200"].content["application/json"].schema;
expect(schema.properties).toEqual({
publicBlockedBy: { type: "string", enum: ["parent", "project"], nullable: true },
leavesPersonalProject: { type: "boolean" },
});
expect(schema.required).toEqual(["publicBlockedBy", "leavesPersonalProject"]);
expect(operation.responses["403"]).toBeDefined();
expect(operation.responses["404"]).toBeDefined();
});
it("documents strict run-attributed feedback without a read endpoint", () => {
const { spec } = loadSpecRoutes();
const path = spec.paths["/api/companies/{companyId}/agent-commentary"];
expect(Object.keys(path)).toEqual(["post"]);
const operation = path.post;
expect(operation.security).toEqual([{ AgentBearerAuth: [] }]);
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "agent", heartbeatBound: true });
expect(operation.description).toContain("X-Paperclip-Run-Id");
const body = operation.requestBody.content["application/json"].schema;
expect(body.additionalProperties).toBe(false);
expect(Object.keys(body.properties).sort()).toEqual(["body", "idempotencyKey", "kind"]);
expect(body.properties.body.maxLength).toBe(524288);
for (const code of ["200", "201"]) {
const result = operation.responses[code].content["application/json"].schema;
expect(result.additionalProperties).toBe(false);
expect(Object.keys(result.properties).sort()).toEqual(["createdAt", "id", "kind", "replayed"]);
}
expect(operation.responses["409"]).toBeDefined();
expect(operation.responses["503"]).toBeDefined();
});
it("documents board-only pool management with revision-checked deletion", () => {
const { spec } = loadSpecRoutes();
const pools = spec.paths["/api/companies/{companyId}/ai-connection-pools"];
const remove = spec.paths["/api/companies/{companyId}/ai-connection-pools/{poolId}"].delete;
const inspection = spec.paths["/api/companies/{companyId}/ai-connection-pools/{poolId}/inspection"].get;
for (const operation of [pools.get, pools.post, remove, inspection]) {
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
}
expect(remove.requestBody.required).toBe(true);
expect(remove.requestBody.content["application/json"].schema).toMatchObject({
required: ["expectedRevision"], additionalProperties: false,
properties: { expectedRevision: { type: "integer", minimum: 0, exclusiveMinimum: true } },
});
expect(Object.keys(remove.responses)).toEqual(expect.arrayContaining(["200", "400", "401", "403", "404", "409"]));
});
it("documents personal board-only announcements and private responses", () => {
const { spec } = loadSpecRoutes();
const current = spec.paths["/api/announcements/current"].get;
const image = spec.paths["/api/announcements/{id}/image"].get;
const animation = spec.paths["/api/announcements/{id}/animation"].get;
const dismiss = spec.paths["/api/announcements/{id}/dismiss"].post;
for (const operation of [current, image, animation, dismiss]) {
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
const success = operation.responses["200"] ?? operation.responses["204"];
expect(success.headers["Cache-Control"].schema.enum).toEqual(["private, no-store"]);
}
expect(current.responses["200"].content["application/json"].schema.nullable).toBe(true);
expect(Object.keys(image.responses["200"].content)).toEqual(["image/png", "image/jpeg", "image/webp"]);
expect(dismiss.requestBody.content["application/json"].schema).toMatchObject({
required: ["companyId"], additionalProperties: false,
});
expect(dismiss.description).toContain("viewers may dismiss their own");
});
it("documents exact failed-run selection and durable accepted retry responses", async () => {
const res = await request(createApp()).get("/api/openapi.json");
const wake = res.body.paths["/api/agents/{id}/wakeup"].post;
expect(
wake.requestBody.content["application/json"].schema.properties
.failedRunId,
).toMatchObject({ type: "string", format: "uuid" });
expect(wake.responses["202"]).toBeDefined();
expect(wake.responses["409"]).toBeDefined();
expect(wake.description).toContain("durable queued/deferred receipt");
});
it("serves the generated OpenAPI document", async () => {
const res = await request(createApp()).get("/api/openapi.json");
expect(res.status).toBe(200);
expect(res.body.openapi).toBe("3.0.0");
expect(res.body.info.title).toBe("Paperclip API");
expect(res.body.paths["/api/openapi.json"].get.summary).toBe(
"Get the generated OpenAPI document",
);
expect(
res.body.paths["/api/companies/{companyId}/agents"].get.summary,
).toBe("List agents in a company");
expect(res.body.paths["/api/agents/{id}/keys"].post.summary).toBe(
"Create an agent API key",
);
expect(res.body.components.securitySchemes).toMatchObject({
BoardSessionAuth: { type: "apiKey", in: "cookie" },
BoardApiKeyAuth: { type: "http", scheme: "bearer" },
AgentBearerAuth: { type: "http", scheme: "bearer" },
});
expect(res.body.paths["/api/health"].get.security).toEqual([]);
expect(res.body.paths["/api/mcp/project-tools"].post.security).toEqual([{ AgentRunAuth: [] }]);
expect(res.body.paths["/api/mcp/project-tools"].post["x-paperclip-authorization"]).toEqual({ actor: "agent", heartbeatBound: true, taskBound: true });
expect(res.body.paths["/mcp/gateways/{gatewayPublicId}"].post.security).toEqual([]);
expect(res.body.paths["/api/mcp/gateways/{gatewayPublicId}"]).toBeUndefined();
expect(res.body.paths["/api/companies"].get.parameters).toContainEqual({
name: "scope",
in: "query",
required: false,
schema: { type: "string", enum: ["accessible"] },
});
expect(res.body.paths["/api/companies"].get.responses["403"]).toBeDefined();
expect(res.body.paths["/api/companies"].get.responses["400"]).toBeDefined();
expect(
res.body.paths["/api/companies"].post.responses["201"],
).toBeDefined();
expect(
res.body.paths["/api/companies"].post.requestBody.content[
"application/json"
].schema,
).toMatchObject({
type: "object",
properties: {
name: { type: "string", minLength: 1 },
},
required: ["name"],
});
expect(
JSON.stringify(res.body.paths["/api/companies"].post.responses),
).not.toContain("candidates");
expect(
res.body.paths["/api/companies/{companyId}/skills/scan-projects"].post
.responses["200"].content["application/json"].schema,
).toMatchObject({
type: "object",
properties: {
candidates: { type: "array" },
},
required: expect.arrayContaining(["candidates"]),
});
expect(
res.body.paths["/api/agents/{id}/keys"].post.requestBody.content[
"application/json"
].schema,
).toMatchObject({
type: "object",
properties: {
name: { type: "string" },
},
});
expect(
res.body.paths["/api/companies/{companyId}/folders"].post.responses[
"201"
],
).toBeDefined();
expect(
Object.keys(
res.body.paths[
"/api/issues/{id}/work-products/{workProductId}/review-document"
].post.responses,
).sort(),
).toEqual(["200", "201", "401", "403", "404", "409", "413", "415", "422"]);
expect(
res.body.paths["/api/issues/{id}/interactions/{interactionId}/withdraw"]
.post.summary,
).toBe("Withdraw a pending issue thread interaction");
const createInteraction =
res.body.paths["/api/issues/{id}/interactions"].post;
expect(createInteraction.description).toContain(
"defaults to canonical `anyone`",
);
const createInteractionSchema = JSON.stringify(
createInteraction.requestBody.content["application/json"].schema,
);
for (const resolverPolicy of [
"anyone",
"not_creator",
"human_only",
"board_or_agents",
"board_only",
]) {
expect(createInteractionSchema).toContain(`\"${resolverPolicy}\"`);
}
expect(
res.body.paths["/api/companies/{companyId}/folders/items/move"].post
.summary,
).toBe("Move an item into or out of a folder");
const createQueue =
res.body.paths["/api/companies/{companyId}/decision-queues"].post;
expect(createQueue.security).toContainEqual({ AgentBearerAuth: [] });
expect(createQueue.responses["200"]).toBeDefined();
expect(createQueue.responses["201"]).toBeDefined();
expect(
createQueue.requestBody.content["application/json"].schema,
).toMatchObject({
type: "object",
properties: {
key: { type: "string", minLength: 1, maxLength: 80 },
title: { type: "string", minLength: 1, maxLength: 120 },
},
required: ["key", "title"],
});
const updateTriage =
res.body.paths[
"/api/companies/{companyId}/decision-triage/{sourceKind}/{sourceId}"
].put;
expect(updateTriage.responses["422"]).toBeDefined();
expect(
updateTriage.requestBody.content["application/json"].schema.properties,
).toMatchObject({
decideBy: { nullable: true },
snoozedUntil: { type: "string", format: "date-time", nullable: true },
});
expect(
JSON.stringify(res.body.paths["/api/tool-gateway/tools"].get),
).not.toContain("sessionToken");
expect(
JSON.stringify(res.body.paths["/api/tool-gateway/tools/call"].post),
).not.toContain("sessionToken");
});
it("publishes the complete board contract for chat channels", () => {
const { spec } = loadSpecRoutes();
const boardSecurity = [{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }];
const operations = [
["get", "/api/companies/{companyId}/chat-endpoints"],
["post", "/api/companies/{companyId}/chat-endpoints"],
["get", "/api/chat-endpoints/{endpointId}"],
["get", "/api/chat-endpoints/{endpointId}/github/configuration"],
["put", "/api/chat-endpoints/{endpointId}/github/configuration"],
["post", "/api/chat-endpoints/{endpointId}/github/verify"],
["put", "/api/chat-endpoints/{endpointId}/github/progress"],
["get", "/api/chat-endpoints/{endpointId}/github/reviews"],
["get", "/api/chat-endpoints/{endpointId}/github/personal-connections"],
["post", "/api/chat-endpoints/{endpointId}/github/identity"],
["post", "/api/chat-endpoints/{endpointId}/github/people/lookup"],
["post", "/api/chat-endpoints/{endpointId}/github/registration"],
["post", "/api/chat-endpoints/{endpointId}/github/app"],
["post", "/api/chat-endpoints/{endpointId}/github/repositories/refresh"],
["patch", "/api/chat-endpoints/{endpointId}"],
["post", "/api/chat-endpoints/{endpointId}/setup"],
["post", "/api/chat-endpoints/{endpointId}/setup-secret"],
["post", "/api/chat-endpoints/{endpointId}/test"],
["post", "/api/chat-endpoints/{endpointId}/photon/inspect"],
["get", "/api/chat-endpoints/{endpointId}/resources"],
["put", "/api/chat-endpoints/{endpointId}/resources"],
["get", "/api/chat-endpoints/{endpointId}/principals"],
[
"post",
"/api/chat-endpoints/{endpointId}/principals/{principalId}/link-intent",
],
[
"delete",
"/api/chat-endpoints/{endpointId}/principals/{principalId}/link",
],
["get", "/api/chat-identity-links/preview"],
["post", "/api/chat-identity-links/confirm"],
["get", "/api/chat-endpoints/{endpointId}/conversations"],
["get", "/api/chat-endpoints/{endpointId}/activity"],
[
"post",
"/api/chat-endpoints/{endpointId}/deliveries/{deliveryId}/replay",
],
[
"post",
"/api/chat-endpoints/{endpointId}/publications/{publicationId}/replay",
],
[
"post",
"/api/chat-endpoints/{endpointId}/publications/{publicationId}/resolve",
],
["post", "/api/chat-endpoints/{endpointId}/actions/{actionId}/resolve"],
[
"post",
"/api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications",
],
["get", "/api/issues/{issueId}/chat-binding"],
[
"get",
"/api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications/{publicationId}/status",
],
] as const;
for (const [method, routePath] of operations) {
const operation = spec.paths[routePath]?.[method];
expect(
operation,
`${method.toUpperCase()} ${routePath} is documented`,
).toBeDefined();
expect(
operation.security,
`${method.toUpperCase()} ${routePath} is board-only`,
).toEqual(boardSecurity);
expect(operation["x-paperclip-authorization"]).toEqual({
actor: "board",
});
expect(operation.tags).toContain("chat-channels");
}
const create = spec.paths["/api/companies/{companyId}/chat-endpoints"].post;
expect(create.responses["201"]).toBeDefined();
expect(create.requestBody.content["application/json"].schema).toMatchObject(
{
type: "object",
additionalProperties: false,
properties: {
provider: {
type: "string",
enum: ["slack", "github", "discord", "microsoft-teams", "telegram", "imessage-photon"],
},
assignedAgentId: { type: "string", format: "uuid" },
},
required: ["provider", "assignedAgentId"],
},
);
const endpointResponse =
spec.paths["/api/chat-endpoints/{endpointId}"].get.responses["200"]
.content["application/json"].schema;
expect(endpointResponse).toMatchObject({
type: "object",
additionalProperties: false,
properties: {
assignedAgentId: { type: "string", format: "uuid" },
status: {
type: "string",
enum: [
"draft",
"verifying",
"active",
"paused",
"attention",
"revoked",
"archived",
],
},
capabilities: { type: "object", additionalProperties: false },
setup: { type: "object", additionalProperties: false },
},
});
expect(JSON.stringify(endpointResponse)).not.toContain("credentials");
expect(JSON.stringify(endpointResponse)).not.toContain("privateKey");
expect(JSON.stringify(endpointResponse)).not.toContain("signingSecret");
expect(
endpointResponse.properties.setup.properties.callbacksNeedUpdate,
).toEqual({ type: "boolean" });
expect(
endpointResponse.properties.setup.properties.callbackSurfaces.properties
.events.properties.status.enum,
).toEqual(["current", "stale", "unverified"]);
const setup = spec.paths["/api/chat-endpoints/{endpointId}/setup"].post;
expect(
setup.requestBody.content["application/json"].schema.properties.action
.enum,
).toEqual([
"configure",
"verify",
"pause",
"resume",
"reconnect",
"remove",
]);
expect(setup.description).toContain(
"Discord: `applicationId`, `guildId`, `botToken`",
);
expect(setup.responses["409"]).toBeDefined();
expect(setup.responses["422"]).toBeDefined();
expect(setup.responses["502"]).toBeDefined();
expect(setup.responses["503"]).toBeDefined();
const photon = spec.paths["/api/chat-endpoints/{endpointId}/photon/inspect"].post;
expect(photon.requestBody.content["application/json"].schema.required).toEqual([
"projectId", "projectSecret",
]);
const photonResponse = photon.responses["200"].content["application/json"].schema;
expect(photonResponse.properties.allocation.enum).toEqual(["dedicated", "shared"]);
expect(photonResponse.properties.lines.items.additionalProperties).toBe(false);
expect(JSON.stringify(photonResponse)).not.toMatch(/projectSecret|token/);
expect(photon.responses["422"]).toBeDefined();
expect(photon.responses["429"]).toBeDefined();
expect(photon.responses["502"]).toBeDefined();
expect(photon.responses["503"]).toBeDefined();
const setupSecret =
spec.paths["/api/chat-endpoints/{endpointId}/setup-secret"].post;
expect(
setupSecret.responses["201"].content["application/json"].schema,
).toMatchObject({
type: "object",
additionalProperties: false,
required: ["webhookSecret"],
});
expect(setupSecret.responses["409"]).toBeDefined();
expect(setupSecret.responses["422"]).toBeDefined();
const resolveAction =
spec.paths["/api/chat-endpoints/{endpointId}/actions/{actionId}/resolve"]
.post;
expect(
resolveAction.requestBody.content["application/json"].schema.properties
.action.enum,
).toEqual(["mark_delivered", "retry_anyway", "cancel"]);
expect(resolveAction.responses["409"]).toBeDefined();
expect(resolveAction.responses["422"]).toBeDefined();
const activity =
spec.paths["/api/chat-endpoints/{endpointId}/activity"].get.responses[
"200"
].content["application/json"].schema.oneOf[0].items;
expect(activity.properties.actionType.enum).toEqual([
"slash_task_start",
"provider_effect",
"github_webhook_ingress",
"slack_session_sync",
"slack_session_stop",
]);
const fileTransfer = activity.properties.fileTransfer;
expect(fileTransfer).toMatchObject({
type: "object",
additionalProperties: false,
required: ["provider", "phase", "filename", "version"],
properties: {
provider: { type: "string", enum: ["microsoft-teams"] },
version: { type: "integer", minimum: 0, exclusiveMinimum: true },
},
});
expect(Object.keys(fileTransfer.properties).sort()).toEqual([
"expiresAt",
"filename",
"phase",
"provider",
"version",
]);
expect(fileTransfer.properties.phase.enum).toHaveLength(15);
const boardSend =
spec.paths[
"/api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications"
].post;
expect(boardSend.responses["409"]).toBeDefined();
expect(boardSend.responses["422"]).toBeDefined();
expect(boardSend.description).toContain(
"chat_board_send_attachments_already_bound",
);
expect(boardSend.description).toContain(
"Other errors do not establish non-delivery",
);
const batchStatus =
spec.paths[
"/api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications/{publicationId}/status"
].get.responses["200"].content["application/json"].schema;
expect(batchStatus.required).toEqual(
expect.arrayContaining([
"publication",
"parts",
"total",
"published",
"awaitingConsent",
"declined",
"expired",
"cancelled",
"settled",
"canDismiss",
]),
);
expect(batchStatus.properties.parts.items.properties.fileTransfer).toEqual(
fileTransfer,
);
expect(batchStatus.properties.publication.properties.state.enum).toContain(
"awaiting_consent",
);
const resolvePublication =
spec.paths[
"/api/chat-endpoints/{endpointId}/publications/{publicationId}/resolve"
].post.requestBody.content["application/json"].schema;
expect(resolvePublication.properties.fileTransfer).toMatchObject({
type: "object",
additionalProperties: false,
required: ["phase", "version"],
properties: {
phase: { enum: fileTransfer.properties.phase.enum },
version: { type: "integer", minimum: 0, exclusiveMinimum: true },
},
});
expect(JSON.stringify(batchStatus)).not.toMatch(
/uploadUrl|privateState|tokenSha256|credentialFingerprint/,
);
const replaceResources =
spec.paths["/api/chat-endpoints/{endpointId}/resources"].put;
expect(
replaceResources.requestBody.content["application/json"].schema,
).toMatchObject({
type: "object",
additionalProperties: false,
required: ["resources"],
});
expect(replaceResources.responses["409"]).toBeDefined();
expect(replaceResources.responses["422"]).toBeDefined();
expect(
spec.paths[
"/api/chat-endpoints/{endpointId}/principals/{principalId}/link"
].delete.responses["204"],
).toBeDefined();
expect(
spec.paths[
"/api/chat-endpoints/{endpointId}/deliveries/{deliveryId}/replay"
].post.responses["204"],
).toBeDefined();
expect(
spec.paths[
"/api/chat-endpoints/{endpointId}/publications/{publicationId}/replay"
].post.responses["204"],
).toBeDefined();
const endpointScopedOperations = operations.filter(
([, routePath]) =>
routePath.includes("{endpointId}") ||
routePath === "/api/issues/{issueId}/chat-binding",
);
for (const [method, routePath] of endpointScopedOperations) {
expect(
spec.paths[routePath][method].responses["404"],
`${method.toUpperCase()} ${routePath} preserves the non-member 404 boundary`,
).toBeDefined();
}
expect(
spec.paths["/api/chat-webhooks/{publicId}/{provider}"],
).toBeUndefined();
});
it("covers the mounted server routes exactly", () => {
const {
routes: actualRoutes,
excludedRoutes,
unknownRouteFiles,
} = loadActualRoutes();
const { routes: specRoutes } = loadSpecRoutes();
const missingInSpec = [...actualRoutes]
.filter((route) => !specRoutes.has(route))
.sort();
const extraInSpec = [...specRoutes]
.filter(
(route) =>
!actualRoutes.has(route) && !specOnlyContractFirstRoutes.has(route),
)
.sort();
expect({
unknownRouteFiles,
missingInSpec,
extraInSpec,
excludedRoutes: [...excludedRoutes].sort(),
}).toEqual({
unknownRouteFiles: [],
missingInSpec: [],
extraInSpec: [],
excludedRoutes: [...explicitOpenApiOperationCoverageExclusions].sort(),
});
});
it("documents the authenticated personal primary-agent contract", () => {
const { spec } = loadSpecRoutes();
const path = spec.paths["/api/companies/{companyId}/primary-agent/me"];
for (const operation of [path.get, path.put]) {
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
expect(operation.responses["200"].content["application/json"].schema.required).toEqual([
"companyId", "userId", "primaryAgentId", "initialized",
]);
}
expect(path.put.requestBody.content["application/json"].schema).toMatchObject({
additionalProperties: false,
required: ["primaryAgentId"],
properties: { primaryAgentId: { type: "string", format: "uuid" } },
});
expect(path.put.responses["422"]).toBeDefined();
});
it("documents board-only repository discovery and selection", () => {
const { spec } = loadSpecRoutes();
const discovery = spec.paths["/api/companies/{companyId}/project-repositories"].get;
const replacement = spec.paths["/api/projects/{id}/repositories"].put;
for (const operation of [discovery, replacement]) {
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
}
expect(replacement.requestBody.content["application/json"].schema.required).toContain("repositoryIds");
expect(replacement.responses["422"]).toBeDefined();
});
it("documents auth and reviewed response-code invariants", () => {
const { spec } = loadSpecRoutes();
expect(spec.paths["/api/openapi.json"].get.security).toEqual([]);
expect(
spec.paths["/runtime-tools/github/credentials"].post.security,
).toEqual([{ RuntimeToolsBearerAuth: [] }]);
expect(spec.paths["/api/plugins/install"].post.security).toEqual([
{ BoardSessionAuth: [] },
{ BoardApiKeyAuth: [] },
]);
expect(
spec.paths["/api/plugins/install"].post["x-paperclip-authorization"],
).toEqual({
actor: "board",
instanceAdmin: true,
});
expect(
spec.paths["/api/execution-workspaces/{id}/reconcile-branch"].post
.security,
).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
expect(
spec.paths["/api/execution-workspaces/{id}/reconcile-branch"].post[
"x-paperclip-authorization"
],
).toEqual({
actor: "board",
});
expect(
spec.paths["/api/companies/{companyId}/cost-events"].post.responses[
"201"
],
).toBeDefined();
expect(
spec.paths["/api/companies/{companyId}/cost-events"].post.responses[
"403"
],
).toBeDefined();
expect(
spec.paths["/api/companies/{companyId}/managed-agent-profiles"].post
.security,
).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
expect(
spec.paths["/api/companies/{companyId}/remote-agent-profiles"].get
.security,
).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
const remoteAgentProfileBody =
spec.paths["/api/companies/{companyId}/remote-agent-profiles"].post
.requestBody.content["application/json"].schema;
expect(remoteAgentProfileBody.properties.service).toMatchObject({
type: "string",
enum: ["aws_bedrock_agentcore_harness"],
});
expect(
remoteAgentProfileBody.properties.credentialSecretId,
).toBeUndefined();
expect(
spec.paths["/api/instance/database-backups"].post.responses["201"],
).toBeDefined();
expect(
spec.paths["/api/invites/{token}/accept"].post.responses["202"],
).toBeDefined();
expect(
spec.paths["/api/board-api-keys"].post.responses["201"],
).toBeDefined();
expect(
spec.paths["/api/companies/import"].post.responses["202"],
).toBeDefined();
expect(
spec.paths["/api/routines/{id}/run"].post.responses["422"],
).toBeDefined();
});
it("publishes the Claude browser-code grammar and strict setup-token response shapes", () => {
const { spec } = loadSpecRoutes();
const base = "/api/companies/{companyId}/setup-token-login-sessions";
// The submitted browser code carries the bounded printable-ASCII grammar.
const codeBody =
spec.paths[`${base}/{sessionId}/code`].post.requestBody.content[
"application/json"
].schema;
const browserCode = codeBody.properties.browserCode;
expect(browserCode.minLength).toBe(1);
expect(browserCode.maxLength).toBe(512);
expect(typeof browserCode.pattern).toBe("string");
expect(browserCode.pattern.length).toBeGreaterThan(0);
// Every Claude request object forbids an unknown property.
const startBody =
spec.paths[base].post.requestBody.content["application/json"].schema;
expect(startBody.additionalProperties).toBe(false);
expect(codeBody.additionalProperties).toBe(false);
// The four contract-first routes carry typed strict response schemas.
const responseSchemas: Record<string, Record<string, unknown>> = {
start:
spec.paths[base].post.responses["201"].content["application/json"]
.schema,
status:
spec.paths[`${base}/{sessionId}`].get.responses["200"].content[
"application/json"
].schema,
prompt:
spec.paths[`${base}/{sessionId}/prompt`].get.responses["200"].content[
"application/json"
].schema,
code: spec.paths[`${base}/{sessionId}/code`].post.responses["200"]
.content["application/json"].schema,
};
const forbiddenProperties = ["token", "accountId", "leaseId"];
for (const [name, schema] of Object.entries(responseSchemas)) {
expect(schema.type, `${name} response is a typed object`).toBe("object");
expect(schema.additionalProperties, `${name} response is strict`).toBe(
false,
);
const properties = (schema.properties ?? {}) as Record<string, unknown>;
expect(
Object.keys(properties).length,
`${name} response lists properties`,
).toBeGreaterThan(0);
for (const forbidden of forbiddenProperties) {
expect(
properties[forbidden],
`${name} response hides ${forbidden}`,
).toBeUndefined();
}
// No property name looks like a raw prompt secret or a token.
for (const property of Object.keys(properties)) {
expect(
/token|secret|accountId|leaseId/i.test(property),
`${name}.${property} is not secret-adjacent`,
).toBe(false);
}
}
// The status and code routes share the public response; it hides the prompt.
expect(responseSchemas.status.properties).toEqual(
responseSchemas.code.properties,
);
expect(
(responseSchemas.status.properties as Record<string, unknown>).prompt,
).toBeUndefined();
// The owner start response adds the panel mode and the one-time prompt.
expect(
(responseSchemas.start.properties as Record<string, unknown>).panelMode,
).toBeDefined();
expect(
(responseSchemas.start.properties as Record<string, unknown>).prompt,
).toBeDefined();
// The prompt route returns the authorization URL and the optional transport
// advisory. The advisory is present on a non-confidential transport, so the
// client can show a non-blocking disclaimer.
expect(
Object.keys(responseSchemas.prompt.properties as Record<string, unknown>),
).toEqual(["authorizationUrl", "transportAdvisory"]);
});
it("documents the 404 non-member gate on the Claude setup-token cancel route", () => {
const { spec } = loadSpecRoutes();
const cancel =
spec.paths[
"/api/companies/{companyId}/setup-token-login-sessions/{sessionId}/cancel"
].post;
// The 404 is reachable at run time. The company-access gate returns a fixed
// 404 for a non-member before the cancel logic runs, so the spec declares
// it. The idempotent cancel still returns 200 for an owner-scoped missing,
// terminal, or foreign session id.
const codes = Object.keys(cancel.responses).sort();
expect(codes).toEqual(["200", "401", "403", "404"]);
});
});
describe("heartbeat run ID OpenAPI contract", () => {
it("publishes the runtime UUID constraint and 400 response on all agent-router run endpoints", async () => {
const response = await request(createApp()).get("/api/openapi.json");
expect(response.status).toBe(200);
const paths = response.body.paths;
let checked = 0;
for (const [path, operations] of Object.entries(paths)) {
if (!path.startsWith("/api/heartbeat-runs/{runId}") || path.endsWith("/issues")) continue;
for (const operation of Object.values(operations as Record<string, any>)) {
const parameter = operation.parameters.find((param: { name: string }) => param.name === "runId");
expect(parameter.schema.pattern).toEqual(expect.any(String));
const pattern = new RegExp(parameter.schema.pattern);
for (const id of [
"aaaaaaaa-aaaa-1aaa-8aaa-aaaaaaaaaaaa",
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
"AAAAAAAA-AAAA-5AAA-BAAA-AAAAAAAAAAAA",
]) expect(pattern.test(id), id).toBe(true);
for (const id of [
"undefined", "not-a-uuid", " aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa ",
"aaaaaaaa-aaaa-7aaa-8aaa-aaaaaaaaaaaa", "aaaaaaaa-aaaa-4aaa-0aaa-aaaaaaaaaaaa",
"aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa\n",
]) expect(pattern.test(id), JSON.stringify(id)).toBe(false);
expect(operation.responses["400"]).toBeDefined();
checked++;
}
}
expect(checked).toBe(12);
});
});