Files
PaperClipAI/server/src/__tests__/openapi-routes.test.ts
T
DottaandPaperclip b8f8b904fa feat(routines): preserve automation ancestry through webhooks (#18772)
## Thinking Path

> - Paperclip is the open source app people use to manage AI agents for
work.
> - Routines let external services start agent tasks through a normal
webhook.
> - A repair routine must recognize failures from work that it created.
> - The receiver added in #18714 put Sentry payload parsing and repair
policy in core routine intake.
> - This pull request replaces that receiver with verified creation
provenance on normal routine calls.
> - Sentry can filter the resulting tags before it sends a normal
routine webhook.
> - The benefit is reusable provenance and loop prevention without a
provider-specific route.

## Linked Issues or Issue Description

**Problem or motivation**

A Sentry repair alert can start another repair when its own work fails.
The dedicated receiver in #18714 solved this through a Sentry-specific
payload contract. Ordinary routine webhooks also need a trusted way to
retain the automation that created their tasks.

**Proposed solution**

Use the existing public `/fire` route and its current authentication. An
optional source run credential proves a live Paperclip caller. Store the
caller separately from webhook JSON. Follow recorded task creation links
and export bounded ancestry tags on run-failure events. Let Sentry alert
filters exclude repair-origin work.

**Alternatives considered**

Keep the dedicated Sentry receiver, accept sender-supplied origin IDs,
or filter only the immediate routine. The first couples core intake to
one provider. The second accepts unverified claims. The third misses
child tasks, standalone follow-ups, and calls through another routine.

**Roadmap alignment**

This improves the shipped Scheduled Routines and Activity log
capabilities. It does not add a new scheduler or incident system.
Related work: #18714 is the implementation replaced here; #9727 adds
source-task navigation and UI attribution; #10206 proposes exception-led
routine dispatch. This PR uses existing task creation records and does
not implement either of those broader proposals.

## What Changed

- Remove the Sentry-only route, parser, authentication exception, and
payload-specific tests and documentation from #18714.
- Add optional `X-Paperclip-Source-Run-Token` support to normal routine
webhooks. Validate the standard run JWT, company, instance, agent, live
run, and cancellation state. Authenticated routine API calls also retain
their validated caller.
- Add nullable `routine_runs.created_by_run_id`. Keep the reference
after source-run deletion so missing history becomes unknown. Separate
callers during coalescing, including legacy open tasks.
- Follow company-scoped routine receipts, creator runs, task parents,
and legacy creation activity. Mark missing, foreign, cyclic, private, or
excessive ancestry for safe alert filtering.
- Add event-local `automation_origin_status`, `automation_routine_ids`,
and `task_shareable` tags through the existing Sentry SDK hook. Cap
ancestry at 32 reads and share a 750 ms deadline across metadata and
ancestry lookup. Keep the SDK one-second wait and eight-lookup
concurrency bound. Redact the source credential from HTTP logs and
Sentry request headers.
- Preserve unexpected database failures as server errors without
rejection bookkeeping in an aborted transaction. Document 409, 415, and
422 responses for the normal webhook.
- Keep `routineScopedRunId` as generic opt-in
`X-Paperclip-Idempotency-Scope: routine` support. Default trigger
receipts and timestamped HMAC replay protection retain their current
scope.
- Add [the Sentry repair
tutorial](https://github.com/paperclipai/paperclip/blob/codex/routine-creation-provenance/docs/guides/board-operator/sentry-run-failure-repairs.md),
API documentation, OpenAPI headers, and capability-map updates.

## Verification

At commit `0f4a0051528edd1367a921a76a8050c3fd087872`:

- `pnpm -r typecheck` and `pnpm build` passed for the full workspace.
Migration numbering and safety checks passed.
- The focused service, route, Sentry, redaction, and OpenAPI suites
passed 228 tests in five files. The database Sentry resolver and OpenAPI
invocation passed 33 tests in two files. These are separate invocations
with some overlap.
- The full routine HTTP suite passed all 15 tests. The authenticated API
case failed before the caller-wiring fix and passed after it. The
fixture loads the real route-owned services. No test deadline was
raised.
- The real `@sentry/node@10.71.0` suite passed all 12 tests. It uses an
in-memory transport and sends no events to Sentry. It covers terminal
failures, saved-message interruptions, event isolation, and source-token
redaction.
- Regression tests also cover forged, expired, foreign, and cancelled
caller credentials; payload spoofing; inherited routine ancestry; legacy
coalescing; deleted history; private work; cycles; query and time
limits; database error causes; and routine-wide receipts. The new error
and budget cases failed before their fixes.
- `node scripts/check-maps.mjs` and `git diff --check` passed. The map
check covers 35 areas and 192 capabilities.
- The full local Vitest command was stopped for a required master merge
after four chat shards passed 1,278 tests. No full local pass is
claimed. An earlier combined HTTP run hit an existing malformed-ID
timeout whose original cause is not established; the final full HTTP
suite passes. An extra local run of the unchanged heartbeat scheduling
file passed 12 of 13 tests and failed a readiness-status assertion. Its
logs also reported a missing fixture user membership. The same 13-test
file now passes in current-head CI.
- All GitHub Actions checks pass on this commit. Three jobs initially
lost their self-hosted runners. One server shard initially failed while
deleting a company in an unchanged heartbeat test because of a
`company_skills` foreign key. One retry passed all affected jobs without
code changes. That server shard passed all 499 tests, and all eight
browser shards passed.
- Fresh Greptile review is 5/5 on this commit. All six review threads
are resolved. The separate Paperclip Review check remains queued. Its
bot reports that work is paused. A maintainer must resume the assigned
review work to complete that check. No full green check state is claimed
while this external gate is pending.
- Live Sentry configuration and deployment are outside this PR's
acceptance evidence. The tutorial describes setup, a controlled failure,
and a repair-origin exclusion check for operators to run after
deployment.

## Risks

- Normal app webhooks retain the sender's full JSON in company-readable
routine history and the execution task. Alert filters and task
visibility are not content scrubbers. Operators must scrub sensitive
event data in Sentry before forwarding it and keep connection tests in
setup mode.
- Existing users of `/sentry-run-failures` must move to `/fire` and add
the documented Sentry filters before enabling repair intake. The old
route is removed. Stored tasks and receipts remain.
- The additive migration uses `ADD COLUMN IF NOT EXISTS`. It does not
rewrite historical tasks or infer missing provenance.
- Unknown or unshareable ancestry is excluded by the tutorial's alert
rule. Missing history, an enrichment timeout, or more than five routine
IDs can therefore suppress a repair webhook while retaining the Sentry
error.
- An external webhook without a verified source credential starts a new
receiving-routine origin. Its JSON cannot establish upstream Paperclip
ancestry. Sentry must not store a Paperclip run credential.
- Routine-wide receipt reuse needs a stable sender key. A static Sentry
custom header is not a per-event key. The tutorial keeps delivery
deduplication separate from loop prevention.

> For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and
discuss it in `#dev` before opening the PR. Feature PRs that overlap
with planned core work may need to be redirected — check the roadmap
first. See `CONTRIBUTING.md`.

## Model Used

OpenAI Codex, GPT-6 model family, with reasoning, repository access,
code editing, and test execution. The session does not expose the exact
model variant 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
- [x] I have run tests locally and they pass
- [x] I have added or updated tests where applicable
- [x] I have updated relevant documentation to reflect my changes
- [x] I have considered and documented any risks above
- [ ] All Paperclip CI gates are green
- [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-11-03 09:33:17 -06:00

1431 lines
63 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 { MUSE_ASSET_VERSION, MUSE_PUBLIC_ROUTES } from "@paperclipai/shared";
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> = {
"cases.ts": "/api",
"case-handoffs.ts": "/api",
"workflows.ts": "/api",
"smoke-lab.ts": "/api",
"access.ts": "/api",
"activity.ts": "/api",
"adapters.ts": "/api",
"agents.ts": "/api",
"agent-project-access.ts": "/api",
"agent-commentary.ts": "/api",
"agent-chat-messages.ts": "/api",
"agent-avatars.ts": "/api",
"agent-profile-avatar.ts": "/api",
"announcements.ts": "/api",
"ai-connections.ts": "/api",
"decision-models.ts": "/api",
"agent-observability.ts": "/api",
"fast-responses.ts": "/api",
"prompt-injection-prevention.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",
"connection-event-wake.ts": "/",
"costs.ts": "/api",
"dashboard.ts": "/api",
"dot-runner.ts": "/api",
"muse.ts": "/api",
"muse-qualification.ts": "/api",
"grok-bot.ts": "/api",
"decision-queues.ts": "/api",
"decisions.ts": "/api",
"decision-training.ts": "/api",
"environments.ts": "/api",
"execution-workspaces.ts": "/api",
"task-workspaces.ts": "/api",
"file-resources.ts": "/api",
"folders.ts": "/api",
"goals.ts": "/api",
"health.ts": "/api/health",
"ready.ts": "/api/ready",
"liveness.ts": "/api/liveness",
"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",
"voice-sessions.ts": "/api",
"tool-gateway.ts": "/api",
"user-profiles.ts": "/api",
"workspace-preferences.ts": "/api",
};
const ROUTE_LITERAL_PATTERN =
/router\.(get|head|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g;
const ROUTE_ARRAY_PATTERN = /router\.(get|head|post|put|patch|delete)\(\s*\[([^\]]+)\]/g;
const ROUTER_METHOD_PATTERN = /router\.(get|head|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([
// Exact Cloud-control signed action; ordinary board/agent credentials cannot invoke it.
"POST /api/instance/connector-events/wake",
// Muse uses its closed, versioned receiver protocol and dedicated credentials,
// documented in doc/muse-personal-agent.md and the shared protocol contract.
// Board connection/qualification management remains in this OpenAPI document.
...MUSE_PUBLIC_ROUTES.map(route => `${route.method} ${route.path}`),
// 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.
// Markdown rendering of the separately documented assistant setup page.
"GET /api/mcp/setup.md",
"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}",
"POST /api/voice-webhooks/{publicId}/events",
"POST /api/voice-webhooks/{publicId}/tools",
]);
// 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 === "connection-event-wake.ts") return routePath;
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 === "voice-sessions.ts" && routePath.startsWith("/api/voice-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" || file === "liveness.ts" || file === "ready.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);
}
}
for (const match of source.matchAll(ROUTE_ARRAY_PATTERN)) {
for (const literal of match[2].matchAll(/["'`]([^"'`]+)["'`]/g)) {
const operation = `${match[1].toUpperCase()} ${normalizeExpressPath(resolveMountedPath(file, prefix, literal[1]))}`;
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);
}
}
}
const variablePaths = file === "dot-runner.ts" ? ["path", "invitePath"]
: file === "grok-bot.ts" ? ["path"]
: file === "muse.ts" ? ["path", "invite"]
: file === "muse-qualification.ts" ? ["path"] : [];
for (const name of variablePaths) {
const basePath = new RegExp(`\\b${name}\\s*=\\s*"([^"]+)"`).exec(source)?.[1];
if (!basePath) throw new Error(`${file} ${name} route prefix is missing`);
const methods = new RegExp(`router\\.(get|post|put|delete)\\(\\s*${name}(?:\\s*\\+\\s*"([^"]+)")?\\s*,`, "g");
for (const match of source.matchAll(methods)) {
routes.add(`${match[1].toUpperCase()} ${normalizeExpressPath(prefix + basePath + (match[2] ?? ""))}`);
}
}
if (file === "muse.ts" && /for\s*\(const route of MUSE_PUBLIC_ROUTES\)/.test(source)) {
for (const route of MUSE_PUBLIC_ROUTES) excludedRoutes.add(`${route.method} ${route.path}`);
}
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 generic webhook provenance headers and accepted responses without a provider-specific endpoint", () => {
const spec = buildOpenApiSpec();
const operation = spec.paths["/api/routine-triggers/public/{publicId}/fire"].post;
expect(operation.parameters).toEqual(expect.arrayContaining([
expect.objectContaining({ in: "header", name: "X-Paperclip-Source-Run-Token", required: false }),
expect.objectContaining({ in: "header", name: "X-Paperclip-Idempotency-Scope", required: false }),
]));
for (const status of ["202", "400", "401", "404", "409", "415", "422"]) expect(operation.responses).toHaveProperty(status);
expect(operation.requestBody.content).toHaveProperty("application/json");
expect(spec.paths).not.toHaveProperty("/api/routine-triggers/public/{publicId}/sentry-run-failures");
});
it.each(["summary", "tasks"])("documents the board-only compact attention %s contract", projection => {
const spec = buildOpenApiSpec() as any;
const operation = spec.paths[`/api/companies/{companyId}/attention/${projection}`].get;
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
expect(operation.parameters.filter((parameter: { in: string }) => parameter.in === "query")).toEqual([]);
const response = operation.responses["200"].content["application/json"].schema;
expect(response.properties.totalCount).toMatchObject({ type: "integer", minimum: 0 });
expect(response.properties.generatedAt).toMatchObject({ format: "date-time" });
expect(response.properties.items).toBeUndefined();
expect(response.properties.nextCursor).toBeUndefined();
if (projection === "tasks") expect(response.properties.taskIds).toMatchObject({ type: "array", items: { type: "string", format: "uuid" } });
for (const code of ["200", "401", "403"]) expect(operation.responses[code]).toBeDefined();
});
it("documents the owned pending-question read as canonical attention items without client scope overrides", () => {
const spec = buildOpenApiSpec() as any;
const operation = spec.paths["/api/issues/{id}/pending-questions"].get;
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
expect(operation.parameters.filter((parameter: { in: string }) => parameter.in === "query")).toEqual([]);
const response = operation.responses["200"].content["application/json"].schema;
expect(response).toMatchObject({ type: "array", items: { type: "object", properties: {
sourceKind: { enum: ["issue_thread_interaction"] },
detail: { properties: { kind: { enum: ["questions"] } } },
} } });
for (const code of ["200", "400", "401", "403", "404"]) expect(operation.responses[code]).toBeDefined();
});
it("documents owned conversation reads and exact revision access separately", () => {
const spec = buildOpenApiSpec() as any;
const boardSecurity = [{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }];
for (const path of [
"/api/issues/{id}/decision-receipts",
"/api/issues/{id}/chat-handoffs",
"/api/companies/{companyId}/attention/history",
]) {
const operation = spec.paths[path].get;
expect(operation.security).toEqual(boardSecurity);
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
const query = Object.fromEntries(operation.parameters
.filter((parameter: { in: string }) => parameter.in === "query")
.map((parameter: { name: string; schema: unknown }) => [parameter.name, parameter.schema]));
expect(query.cursor).toMatchObject({ type: "string", maxLength: 1000 });
expect(query.limit).toMatchObject({ type: "integer", minimum: 1, maximum: 100 });
if (path.endsWith("/history")) {
expect(query.search).toMatchObject({ maxLength: 200 });
expect(query.outcome).toMatchObject({ maxLength: 40 });
expect(query.agentId).toMatchObject({ format: "uuid" });
expect(query.projectId).toMatchObject({ format: "uuid" });
expect(query.sort).toMatchObject({ enum: ["newest", "oldest"] });
expect(query.since).toMatchObject({ format: "date-time" });
} else {
expect(Object.keys(query).sort()).toEqual(["cursor", "limit"]);
expect(operation.responses["404"]).toBeDefined();
}
for (const code of ["200", "400", "401", "403"]) {
expect(operation.responses[code]).toBeDefined();
}
}
const revision = spec.paths["/api/issues/{id}/documents/{key}/revisions/{revisionId}"].get;
expect(revision.security).toEqual([...boardSecurity, { AgentBearerAuth: [] }]);
const params = Object.fromEntries(revision.parameters.map(
(parameter: { name: string; schema: unknown }) => [parameter.name, parameter.schema],
));
expect(params.key).toMatchObject({ minLength: 1, maxLength: 64, pattern: "^[a-z0-9][a-z0-9_-]*$" });
expect(params.revisionId).toMatchObject({ type: "string", format: "uuid" });
for (const code of ["200", "400", "401", "403", "404"]) {
expect(revision.responses[code]).toBeDefined();
}
});
it("removes Pipelines discovery while retaining Cases and Routines", () => {
const spec = buildOpenApiSpec() as any;
const paths = Object.keys(spec.paths);
expect(paths.filter((path) => /pipelines|review-cases|case-events/.test(path))).toEqual([]);
// Standalone handoffs also use caseId; that parameter name no longer
// identifies a removed Pipeline operation. Retain only their live paths.
expect(paths.filter((path) => path.startsWith("/api/cases/{caseId}")).sort()).toEqual([
"/api/cases/{caseId}/handoffs",
"/api/cases/{caseId}/handoffs/{handoffId}/cancel",
"/api/cases/{caseId}/handoffs/{handoffId}/retry",
]);
expect(spec.paths["/api/companies/{companyId}/cases"].post).toBeDefined();
expect(spec.paths["/api/companies/{companyId}/case-handoffs"].post).toBeDefined();
expect(spec.paths["/api/cases/{id}"].get).toBeDefined();
expect(spec.paths["/api/routines/{id}/run"].post).toBeDefined();
});
it("documents anonymous liveness separately from database readiness", () => {
const spec = buildOpenApiSpec() as any;
for (const method of ["get", "head"]) {
const operation = spec.paths["/api/liveness"][method];
expect(operation.security).toEqual([]);
expect(operation.responses[503]).toBeDefined();
}
expect(Object.keys(spec.paths["/api/liveness"].get.responses[200].content["application/json"].schema.properties))
.toEqual(["status", "commit"]);
expect(spec.paths["/api/health"].get.responses[503]).toBeDefined();
});
it("documents only writable agent update fields and lifecycle status requests", () => {
const document = buildOpenApiSpec() as any;
const properties = document.paths["/api/agents/{id}"].patch.requestBody.content["application/json"].schema.properties;
expect(properties).not.toHaveProperty("spentMonthlyCents");
expect(properties.status.enum).toEqual(["paused", "idle", "terminated"]);
expect(properties).toHaveProperty("name");
expect(properties).toHaveProperty("budgetMonthlyCents");
});
it("documents public Dot pairing capabilities while ordinary consent keeps board authentication", () => {
const spec = buildOpenApiSpec() as any;
for (const [method, path] of [
["get", "/api/dot-mcp/requests/{id}"],
["post", "/api/dot-mcp/requests/{id}/dot-pairing"],
["post", "/api/dot-mcp/requests/{id}/dot-pairing/preview"],
]) {
expect(spec.paths[path][method].security).toEqual([]);
expect(spec.paths[path][method].responses["404"]).toBeDefined();
}
expect(spec.paths["/api/mcp/requests/{id}/consent"].post.security).not.toEqual([]);
});
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 and board-only commentary reads", () => {
const { spec } = loadSpecRoutes();
const path = spec.paths["/api/companies/{companyId}/agent-commentary"];
expect(Object.keys(path)).toEqual(["get", "post"]);
const detail = spec.paths["/api/companies/{companyId}/agent-commentary/{id}"].get;
for (const read of [path.get, detail]) {
expect(read.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
expect(read.responses["403"]).toBeDefined();
expect(read.responses["404"]).toBeDefined();
}
expect(path.get.parameters.map((parameter: { name: string }) => parameter.name)).toEqual(
expect.arrayContaining(["agentId", "kind", "q", "limit", "offset"]),
);
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 delegated 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: [] }, { AgentBearerAuth: [] }]);
}
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 masked board-only agent export settings and revision-checked writes", () => {
const { spec } = loadSpecRoutes();
const settings = spec.paths["/api/companies/{companyId}/observability"];
const test = spec.paths["/api/companies/{companyId}/observability/test"].post;
for (const operation of [settings.get, settings.put, settings.delete, test]) {
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
}
expect(settings.delete.requestBody.content["application/json"].schema).toMatchObject({ required: ["expectedRevision"], additionalProperties: false });
expect(test.responses["409"]).toBeDefined();
expect(JSON.stringify(settings.get.responses)).not.toMatch(/credentialsSecretId|ciphertext|apiKey/);
expect(test.responses["200"].content["application/json"].schema.properties.source).toMatchObject({ enum: ["control_plane"] });
});
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("advertises the guarded prepared-task promotion contract to API clients", async () => {
const res = await request(createApp()).get("/api/openapi.json");
const patch = res.body.paths["/api/issues/{id}"].patch;
expect(patch.requestBody.content["application/json"].schema.properties.expectedStatusVersion)
.toMatchObject({ type: "integer", minimum: 0 });
expect(patch.responses["409"]).toBeDefined();
expect(patch.description).toContain("send exactly status=todo and expectedStatusVersion");
});
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 delegated chat configuration and human-only confirmation boundaries", () => {
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;
const humanOnly = new Set([
"/api/chat-endpoints/{endpointId}/deliveries/{deliveryId}/replay",
"/api/chat-endpoints/{endpointId}/publications/{publicationId}/replay",
"/api/chat-endpoints/{endpointId}/conversations/{conversationId}/publications",
"/api/chat-endpoints/{endpointId}/github/identity", "/api/chat-identity-links/preview", "/api/chat-identity-links/confirm",
"/api/chat-endpoints/{endpointId}/publications/{publicationId}/resolve", "/api/chat-endpoints/{endpointId}/actions/{actionId}/resolve",
]);
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} retains its actor boundary`,
).toEqual(humanOnly.has(routePath) ? boardSecurity : [...boardSecurity, { AgentBearerAuth: [] }]);
expect(operation["x-paperclip-authorization"]).toEqual({
actor: humanOnly.has(routePath) ? "board" : "board_or_agent",
});
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", "speko", "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;
const setupResponse = setup.responses["200"].content["application/json"].schema;
expect((setupResponse.oneOf ?? setupResponse.anyOf)[1].properties.requiredUserAction.properties.kind.enum).toEqual(["enter_credentials"]);
expect(setup.description).toContain("without changing credentials");
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;
const secretHandoff = setupSecret.responses["200"].content["application/json"].schema;
expect(secretHandoff.properties.requiredUserAction.properties.kind.enum).toEqual(["enter_credentials"]);
expect(secretHandoff.properties).not.toHaveProperty("webhookSecret");
expect(setupSecret.description).toContain("without rotating or reading");
expect(
setupSecret.responses["201"].content["application/json"].schema,
).toMatchObject({
type: "object",
additionalProperties: false,
required: ["webhookSecret", "credentialRotationId"],
});
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("documents session-bound voice authority without durable credentials", () => {
const {spec} = loadSpecRoutes();
const start = spec.paths["/api/companies/{companyId}/voice-sessions"].post;
expect(start.security).toEqual([{BoardSessionAuth: []}]);
expect(start["x-paperclip-authorization"]).toEqual({actor: "board", sessionBound: true});
expect(start.requestBody.content["application/json"].schema.required).toEqual(expect.arrayContaining(["endpointId", "idempotencyKey"]));
expect(start.responses["201"]).toBeDefined();
expect(JSON.stringify(start.responses)).not.toContain("signingSecret");
expect(spec.paths["/api/voice-webhooks/{publicId}/tools"]).toBeUndefined();
expect(spec.paths["/api/voice-webhooks/{publicId}/events"]).toBeUndefined();
const decision=spec.paths["/api/companies/{companyId}/voice-phone/{endpointId}/incoming/{callId}"].post;
expect(decision.requestBody.content["application/json"].schema.required).toEqual(expect.arrayContaining(["approve", "approvalCode"]));
});
it("documents board-only prompt injection settings and fixed-sample testing", () => {
const { spec } = loadSpecRoutes();
const base = "/api/companies/{companyId}/prompt-injection-prevention";
for (const [method, suffix] of [["get", ""], ["put", ""], ["get", "/models"], ["post", "/test"]]) {
expect(spec.paths[base + suffix][method]["x-paperclip-authorization"]).toEqual({ actor: "board" });
expect(spec.paths[base + suffix][method].security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
}
expect(spec.paths[base].put.requestBody.content["application/json"].schema).toMatchObject({
additionalProperties: false, required: ["enabled", "connectionId", "grantId", "model"],
});
expect(spec.paths[base + "/test"].post.requestBody.content["application/json"].schema).toMatchObject({
type: "object", additionalProperties: false, properties: {},
});
});
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 additive project access as board-only with a strict request", () => {
const { spec } = loadSpecRoutes();
const path = spec.paths["/api/agents/{id}/project-access"];
for (const operation of [path.get, path.post]) {
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
expect(operation.responses["200"].content["application/json"].schema.required).toEqual(["projectIds", "canManage"]);
}
expect(path.post.requestBody.content["application/json"].schema).toMatchObject({
additionalProperties: false, required: ["projectIds"],
properties: { projectIds: { type: "array", items: { type: "string", format: "uuid" } } },
});
expect(path.post.responses["409"]).toBeDefined();
});
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 Muse operator management separately from receiver credentials", () => {
const { spec } = loadSpecRoutes();
const base = "/api/companies/{companyId}/agents/{agentId}/muse-binding";
const operations = [
["get", "/api/companies/{companyId}/muse-invitations"],
["post", "/api/companies/{companyId}/muse-invitations"],
["get", base], ["post", base], ["post", `${base}/verify`],
["post", `${base}/revoke`], ["post", `${base}/attest-stop`],
["post", `${base}/attest-detector-cleanup`],
["get", `${base}/qualification`], ["post", `${base}/qualification`], ["delete", `${base}/qualification`],
];
for (const [method, routePath] of operations) {
const operation = spec.paths[routePath][method];
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
expect(operation.responses["403"]).toBeDefined();
}
expect(spec.paths[base].post.responses["201"]).toBeDefined();
expect(spec.paths[base].post.responses["201"].content["application/json"].schema.properties.assetVersion.enum).toEqual([MUSE_ASSET_VERSION]);
expect(spec.paths[`${base}/revoke`].post.responses["204"]).toBeDefined();
expect(spec.paths[`${base}/qualification`].delete.responses["204"]).toBeDefined();
expect(spec.paths[`${base}/qualification`].get.parameters).toEqual(expect.arrayContaining([
expect.objectContaining({ name: "bindingId", in: "query", required: true }),
expect.objectContaining({ name: "qualificationId", in: "query", required: true }),
]));
for (const route of MUSE_PUBLIC_ROUTES) expect(spec.paths[route.path]).toBeUndefined();
});
it("documents operator Dot invitations and current pairing and event-test fields", () => {
const { spec } = loadSpecRoutes();
const invitations = spec.paths["/api/companies/{companyId}/dot-invitations"];
for (const operation of [invitations.get, invitations.post]) {
expect(operation["x-paperclip-authorization"]).toEqual({ actor: "board" });
expect(operation.responses["200"]).toBeDefined();
}
expect(invitations.post.requestBody.content["application/json"].schema.additionalProperties).toBe(false);
const base = "/api/companies/{companyId}/agents/{agentId}/dot-binding";
expect(spec.paths[base].post.requestBody.content["application/json"].schema.properties.replaceBindingId).toMatchObject({ type: "string", format: "uuid" });
expect(spec.paths[base + "/event-test"].post.requestBody.content["application/json"].schema.properties.bindingId).toMatchObject({ type: "string", format: "uuid" });
});
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 delegated GitHub setup with human identity confirmation and compatible owner-aware registration", () => {
const { spec } = loadSpecRoutes();
for (const [suffix, method] of [["draft", "put"], ["setup", "post"], ["identity/start", "post"], ["identity/confirm", "post"]]) {
const operation = spec.paths[`/api/chat-endpoints/{endpointId}/github/${suffix}`][method];
expect(operation["x-paperclip-authorization"]).toEqual({ actor: suffix === "identity/confirm" ? "board" : "board_or_agent" });
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }, ...(suffix === "identity/confirm" ? [] : [{ AgentBearerAuth: [] }])]);
}
const registration = spec.paths["/api/chat-endpoints/{endpointId}/github/registration"].post;
const input = registration.requestBody.content["application/json"].schema;
expect(input.properties.ownerType.enum).toEqual(["personal", "organization"]);
expect(input.required).not.toContain("ownerType");
const response = registration.responses["200"].content["application/json"].schema;
expect(response.oneOf ?? response.anyOf).toHaveLength(2);
const appImport = spec.paths["/api/chat-endpoints/{endpointId}/github/app"].post;
expect(appImport.description).toContain("Only a human session");
const appImportResponse = appImport.responses["200"].content["application/json"].schema;
expect(appImportResponse.oneOf ?? appImportResponse.anyOf).toHaveLength(2);
});
it("documents GitHub recovery, draft receipts, paged repositories and old review lookups", () => {
const { spec } = loadSpecRoutes();
const root = "/api/chat-endpoints/{endpointId}/github/";
const setup = spec.paths[root + "setup"].post.responses["200"].content["application/json"].schema;
expect(setup.properties.restartableRegistrationId.format).toBe("uuid");
const restart = spec.paths[root + "registration/restart"].post;
expect(restart["x-paperclip-authorization"]).toEqual({ actor: "board_or_agent" });
const restartInput = restart.requestBody.content["application/json"].schema;
expect(restartInput.required).toEqual(expect.arrayContaining(["registrationId", "appNotCreated"]));
expect(restartInput.properties.appNotCreated.enum ?? [restartInput.properties.appNotCreated.const]).toEqual([true]);
const receipt = spec.paths[root + "draft"].put.responses["200"].content["application/json"].schema;
expect(receipt.required).toEqual(["saved"]);
expect(receipt.properties).not.toHaveProperty("state");
const appInput = spec.paths[root + "app"].post.requestBody.content["application/json"].schema;
const app = (appInput.oneOf ?? appInput.anyOf).find((variant: { properties?: { appId?: unknown } }) => variant.properties?.appId);
expect(app.properties.clientId).toBeDefined();
expect(app.properties.clientSecret).toBeDefined();
expect(app.required).not.toContain("clientId");
const page = spec.paths[root + "repositories"].get;
expect(page.parameters.map((param: { name: string }) => param.name)).toEqual(expect.arrayContaining(["endpointId", "offset", "limit", "search"]));
expect(page.responses["200"].content["application/json"].schema.properties).toHaveProperty("totalCount");
expect(spec.paths[root + "repositories/access"].put["x-paperclip-authorization"]).toEqual({ actor: "board_or_agent" });
const review = spec.paths[root + "reviews/{reviewId}"].get;
expect(review.parameters.map((param: { name: string }) => param.name)).toEqual(["endpointId", "reviewId"]);
expect(review.responses["404"]).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);
});
});