mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-03 01:27:09 +01:00
## Thinking Path > - Paperclip lets people manage work done by agents. > - Tasks, Inbox, and Needs You must stay usable as work history grows. > - The Coordinator views bypassed some existing collection optimizations. > - Navigation fetched complete decision cards for a count, and Tasks mounted every loaded row. > - This PR uses the canonical attention rules for small count and task-ID responses. It also restores bounded rendering and the optimized Inbox selection. > - The benefit is less data and less initial rendering work without a second definition of pending work. ## Linked Issues or Issue Description **What happened?** Coordinator navigation fetched the full attention feed on every cold collection visit. Tasks rendered the entire loaded collection. Coordinator could also select the old Inbox loader when Streamlined UI was off. **Expected behavior** Fetch only the projection needed by each surface. Start with a bounded set of rows. Keep complete task membership, accurate counts, current permissions, and stable loaded rows. **Steps to reproduce** 1. Enable Coordinator and open Tasks or Inbox with an empty browser query cache. 2. Observe a complete attention-card download for the navigation badge. 3. Open a large task list and observe that every loaded row mounts at once. 4. Disable Streamlined UI while keeping Coordinator enabled, then open Mine and observe three overlapping compact task reads. Related work: #17756, #3744, #17983, and #18312. ## What Changed - Add board-only attention count and complete task-ID projections. Reuse canonical company, privacy, addressee, deduplication, dismissal, snooze, and archive rules. Skip card bodies, plan/image reads, and presentation roster lookup. - Share the small count read across desktop and mobile navigation. Reuse the page-owned response on Needs You and Needs attention. Keep the newest known count through navigation, including zero. - Use the task-ID index for Needs attention and keep task row hydration in batches of at most 100. - Render 100 task rows initially, across the whole expanded tree. Reveal 150 more at a time through scrolling or a button. Keep existing rows through refresh and append. Defer offscreen leaf layout with shared tokens. - Keep Coordinator on the optimized Inbox when Streamlined UI is off. Use index reads for expanded project recents. - Add real-database, component, and production-build browser regression checks. Update the access and feature maps. ## Verification - All required CI checks pass on `1c68ac8957490a6c8585ba0939355f536c8120bf`: typecheck, build, server/workspace tests, serialized database suites, and eight E2E shards. Greptile is **5/5**, with its row-retention finding fixed and the review thread resolved. Storybook checks were intentionally skipped; Paperclip Review completed neutral. - Workspace typecheck and production build passed locally. The final rebased source also passed UI typecheck and production build. Token gates and map checks passed. - The attention service suite passed all 34 tests on disposable PostgreSQL. Final targeted checks cover compact projections, complete-set counts, quality approvals, snoozes, private tasks, and board/company access. Database response capture confirms compact projections do not read plans or images. All 31 OpenAPI tests passed after registering both endpoint contracts. - Six production-build browser journeys passed: desktop, mobile, Coordinator Inbox with Streamlined UI off, Needs You, Needs attention, and an older server page followed by task open/Back. They verify the initial 100-row budget, scrolling, retained DOM identity/height, one owner per attention read, complete task membership, 220 retained rows on Back, and less than 2 px of return-position difference. - The row-budget and Inbox regressions fail against original source and pass with this change. The final three affected UI suites passed 40 tests, and the broader Inbox/IssuesList suites passed 101 tests serially. ### Controlled production-build comparison Baseline `b31ec0a2dc079e32272a8f618425fe92b68bbaec` versus PR head `1c68ac8957490a6c8585ba0939355f536c8120bf`. Isolated baseline asset `index-CS9uPxMx.js`; candidate asset `index-BxJeTQ30.js`. Both UI builds used the same warm disposable API server and database, with 220 tasks and 110 pending board approvals. Chromium **151.0.7922.34**, 1440 × 900 viewport, **4× CPU throttle**, loopback with no added network latency, alternating baseline/candidate order. Cold cases use a new browser context with network cache disabled; warm cases navigate within the same context. Other host workloads are uncontrolled. Readiness requires the destination collection's first visible task row, then two animation frames. Inbox readiness waits for its canonical Mine route so previous-page rows cannot satisfy the measurement. Five paired samples per case; cold Inbox has a second five-pair confirmation wave because the first result was slower. Times below are **milliseconds**; parentheses show min–max. Paired savings are the median of individual before-minus-after pairs, not the difference between medians. | Journey | Pairs | Baseline median (range) | Candidate median (range) | Median paired saving | Faster pairs | | --- | ---: | ---: | ---: | ---: | ---: | | Cold Tasks | 5 | 2752 (2542–3025) | 2300 (2275–2524) | 328 | 5/5 | | Cold Inbox | 10 | 2536 (2283–2927) | 2582 (2117–2856) | 37 | 5/10 | | Warm Tasks navigation | 5 | 2054 (1826–2479) | 1448 (1419–1548) | 635 | 5/5 | | Warm Inbox navigation | 5 | 2020 (1485–2254) | 1568 (1289–1848) | 350 | 5/5 | | Task → Back to Tasks | 5 | 1747 (1609–1948) | 1327 (1238–1355) | 472 | 5/5 | Cold Tasks, warm Tasks, warm Inbox, and Back improved in every measured pair. **Cold Inbox latency is inconclusive:** the two waves pointed in opposite directions, with the candidate faster in 5/10 pairs. This PR claims less badge work there, not a proven cold Inbox latency improvement. The canonical badge response shrank from **200,039 to 359 decoded bytes** (encoded HTTP transfer: **8,125 to 598 bytes**). The compact response also skips card payloads, plans, images, and presentation-only roster work. These are measured mechanism reductions, separate from end-to-end latency. Needs You still owns its full card feed; Needs attention owns its complete task-ID projection. Reproduction setup and assertions are in `tests/perf/collections/README.md`. <details> <summary>Individual paired readiness measurements (ms)</summary> | Journey | Pair | Baseline | Candidate | | --- | ---: | ---: | ---: | | Cold Tasks | 1 | 3025 | 2283 | | Cold Tasks | 2 | 2542 | 2275 | | Cold Tasks | 3 | 2752 | 2424 | | Cold Tasks | 4 | 2783 | 2300 | | Cold Tasks | 5 | 2641 | 2524 | | Cold Inbox | 1 | 2927 | 2702 | | Cold Inbox | 2 | 2422 | 2629 | | Cold Inbox | 3 | 2409 | 2606 | | Cold Inbox | 4 | 2621 | 2769 | | Cold Inbox | 5 | 2539 | 2856 | | Cold Inbox | 6 | 2283 | 2117 | | Cold Inbox | 7 | 2466 | 2558 | | Cold Inbox | 8 | 2674 | 2286 | | Cold Inbox | 9 | 2696 | 2389 | | Cold Inbox | 10 | 2533 | 2183 | | Warm Tasks navigation | 1 | 2479 | 1423 | | Warm Tasks navigation | 2 | 2228 | 1548 | | Warm Tasks navigation | 3 | 2054 | 1419 | | Warm Tasks navigation | 4 | 1826 | 1448 | | Warm Tasks navigation | 5 | 1954 | 1518 | | Warm Inbox navigation | 1 | 2020 | 1661 | | Warm Inbox navigation | 2 | 2198 | 1848 | | Warm Inbox navigation | 3 | 1733 | 1497 | | Warm Inbox navigation | 4 | 2254 | 1568 | | Warm Inbox navigation | 5 | 1485 | 1289 | | Task → Back to Tasks | 1 | 1721 | 1330 | | Task → Back to Tasks | 2 | 1747 | 1275 | | Task → Back to Tasks | 3 | 1948 | 1355 | | Task → Back to Tasks | 4 | 1609 | 1327 | | Task → Back to Tasks | 5 | 1790 | 1238 | </details> ### Failed attempts and limits - The local full repository command hit embedded PostgreSQL startup failures and 16 failures in the untouched native session executor suite; it was stopped. The full local suite is not claimed green. CI on the final revision provides the broad test result. - A concurrent local UI run hit timing failures under host contention. Inbox/IssuesList subsequently passed all 101 tests serially. No tests were weakened to address these timeouts. - CI caught two integration gaps introduced during this work: missing OpenAPI entries for the new endpoints, and an attention fixture incompatible with the routine filters added on master. Both were fixed; the affected 31 OpenAPI and 40 UI tests pass. - The final revision's first CI attempt failed two untouched tests: scheduled-retry batch cancellation and the tool-access audience/archive race. Both failed jobs passed when rerun without changing the revision. Their original failed attempts remain visible in [the workflow history](https://github.com/paperclipai/paperclip/actions/runs/41083379875). - Browser evidence uses synthetic disposable data. This PR is not deployed staging or production verification. ## Risks - New attention sources must preserve canonical eligibility. Both projections call the existing collector, so they share its permissions and complete-set behavior. - The count and task-ID caches must not stand in for full decision cards. Each projection has its own key under the existing attention invalidation prefix. - A row budget can omit descendants if applied per parent. The tests cover one global budget across a large expanded tree, refresh, append, reset, and incremental loading. - Local browser evidence uses synthetic data and is not staging deployment proof. ## Model Used OpenAI Codex, GPT-6 family, with reasoning, tool use, and code execution. The exact backend model deployment ID and context window were not exposed in this session. ## 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 (affected suites; broad-run limitations are listed above) - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
1414 lines
62 KiB
TypeScript
1414 lines
62 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-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",
|
|
"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",
|
|
"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([
|
|
// 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.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);
|
|
});
|
|
});
|