mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-21 16:27:18 +02:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Integrations can prepare a backlog task before its assigned agent starts. > - A person can change its status or assignment during preparation. > - An unconditional promotion can replace that newer decision. > - Safe promotion needs a saved revision that includes every assignment path. > - This pull request checks that revision under the task row lock and derives policy cleanup from the locked row. > - The benefit is safe initialization recovery with the existing company, task, and run authority. ## Linked Issues or Issue Description Prerequisite #16896 has merged. This PR now targets master and contains only the conditional promotion API. The prerequisite fixed the confirmed baseline first-upload browser regression. **What happened?** A client that saves a task's initial documents and then sends an unconditional status update can override a concurrent status or assignment decision. The update validator previously dropped an expected version, so clients could not make this promotion conditional. Some direct assignment and release paths also did not advance the revision. **Expected behavior** A prepared task moves from backlog to todo only if its original status and assignment revision still matches. A stale request returns 409 before a task mutation, audit publication, or wake. Policy cleanup preserves unrelated saved settings. **Steps to reproduce** 1. Create a backlog task and save its statusVersion before preparing it. 2. Release its assignment, or change an assignee and then restore that assignee. 3. Send `{ "status": "todo", "expectedStatusVersion": <original revision> }` to its PATCH endpoint. 4. The stale promotion must return 409. A matching revision on an unchanged backlog task must succeed. Duplicate and related PR searches found no existing prepared-task promotion fix. ## What Changed - Accept an optional nonnegative expectedStatusVersion for exactly a status-only backlog-to-todo promotion. Reject extra public fields before parsing can discard them. - Check the current revision and backlog state under the issue row lock. Reject stale requests before checkout adoption or execution-control effects. - Add SQL migration 0358 to advance the revision for both assignee fields at the database boundary. Release, administrative release, direct writes, and assignment round trips now invalidate an old version. - Derive guarded policy cleanup from the locked row. Preserve review limits and protected authorization when removing a monitor. Reject derived ownership changes or review decisions. - Add schema, real HTTP/PostgreSQL, concurrency, migration replay, and policy regression coverage. Update the response type, OpenAPI, developer docs, and access map. ## Verification - Red before the initial fix: stale requests returned 200 instead of 409, validation removed the expected version, and a stale active task could adopt a checkout. - Review found two further defects. Four new release/direct-assignment tests showed unchanged revisions. The real HTTP/service test returned 422 for a valid two-field promotion after monitor cleanup. Helper tests also proved that cleanup lost valid review limits and protected authorization. All now pass after focused repairs. - Green: 124 route and policy tests, including 7 real Express/PostgreSQL cases, 37 existing route tests, and 80 policy/helper tests. The real row-lock race preserves a policy edit committed while promotion waits. - Green: all 23 database client tests, plus 9 focused assignment and replay checks. Historical migration 0227 receipt recovery cannot remove the new assignment trigger. Reapplying migration 0358 leaves existing rows unchanged and installs one trigger. Combined status/assignment writes increment once; unchanged assignees do not increment. - Earlier verification passed all 218 schema/route/service cases and the generated OpenAPI capability test. Rebased focused regressions passed all 27 selected cases. Final server TypeScript passed after all repairs; shared TypeScript passed. Migration safety/numbering, five-snapshot retention, map validation, and diff checks pass. - The baseline browser attachment case failed twice on the earlier head. Separate #16896 proved the lost-composer cause, passed 335 component tests and the real first-upload journey, and has green CI with a 5/5 review. No assertion or timeout was weakened. - Some early local checks were blocked by missing or stale sibling dependency artifacts. This worktree now has its own frozen offline install and standard SDK prerequisites. The failures and successful reruns remain in the task evidence. - Before rebasing, head `3304bb00de6ad6a248b5e046c008072ad20ac8f5` passed all CI (55 successful checks and one neutral), Greptile 5/5, and a secondary source review at 5/5 with no unresolved findings. The secondary reviewer did not execute project tests, inspect CI, or exercise a live agent journey. Earlier incomplete and must-fix assessments remain visible; both established findings are repaired above. - After #16896 merged, all five CAS commits were rebased onto master `87d9386ae637c652d1fed09d2046be1bcaeb413b`. Migration 0358 was regenerated because master added another 0357. Range comparison shows no other change to the CAS patches; inherited UI commits are absent. The original head is preserved locally. - Rebased head `1c0264e51a280b1dbf597725a7e42e72d4be1b1b` passes all 124 route/policy tests, 9 selected assignment/promotion tests, and all 24 database client/snapshot-drift tests. Database build, migration checks, generated snapshot linkage, five-snapshot retention (0354–0358), maps, and diff checks pass. Fresh-head CI and review are pending. No deployment or live agent journey was performed for this API. ## Risks - The guard supports prepared backlog promotion only. Other statuses or mixed public mutation fields return 422. A stale revision or non-backlog row returns 409. - Preserve the original version from a server that already advertises this guard before initialization. Older versions did not track every assignment path. Reconcile older or uncertain initialization instead of fetching a new revision and blindly retrying. - Migration 0358 is SQL-only and idempotent. It adds no table, column, or task backfill. Its separate trigger survives historical status-migration replay. - Existing authorization and unguarded updates remain in place. Guarded promotion does not provide a general lifecycle or review-stage control API. ## Model Used OpenAI Codex, GPT-6 family. The exact serving model ID and context window are not exposed by this session. Used tool calls, code execution, and test-first regression verification. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1279 lines
53 KiB
TypeScript
1279 lines
53 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",
|
|
"smoke-lab.ts": "/api",
|
|
"access.ts": "/api",
|
|
"activity.ts": "/api",
|
|
"adapters.ts": "/api",
|
|
"agents.ts": "/api",
|
|
"agent-commentary.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",
|
|
"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",
|
|
"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",
|
|
"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",
|
|
};
|
|
|
|
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([
|
|
// 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 === "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") && 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("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|\{caseId\}/.test(path))).toEqual([]);
|
|
expect(spec.paths["/api/companies/{companyId}/cases"].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 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 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 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", "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;
|
|
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("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("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 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 the manager-only GitHub wizard 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: "board" });
|
|
expect(operation.security).toEqual([{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]);
|
|
}
|
|
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);
|
|
});
|
|
|
|
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" });
|
|
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 app = spec.paths[root + "app"].post.requestBody.content["application/json"].schema;
|
|
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" });
|
|
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);
|
|
});
|
|
});
|