From 1227bb8ead79c827c06d027d409f9ace71bfc7ba Mon Sep 17 00:00:00 2001 From: Aron Prins Date: Thu, 4 Jun 2026 18:59:22 +0200 Subject: [PATCH] Improve OpenAPI spec coverage and auth metadata (#4579) ## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Its REST API is the control-plane contract for the board UI, agents, plugins, and external integrations > - This branch adds `/api/openapi.json`, which makes the generated OpenAPI document part of that contract instead of an internal implementation detail > - Once the spec is published, it has to match the mounted Express routes, auth model, and real HTTP behavior closely enough for client generation and review > - The existing spec drifted from the live server: it missed mounted routes, documented a few nonexistent ones, omitted auth semantics, and normalized response codes too aggressively > - This pull request makes the generated spec track the real API surface, exposes security requirements, and adds regression coverage so drift is caught automatically > - The benefit is that Paperclip's published API description becomes trustworthy for integrators, SDK generation, and review without changing runtime auth enforcement ## What Changed - Added the OpenAPI endpoint wiring under `server/src/routes/openapi.ts` so `/api/openapi.json` is generated from the current route-backed OpenAPI builder. - Replaced generic request/response bodies with typed schemas where available so the generated document carries useful structure instead of opaque blobs. - Expanded the generated spec to cover the mounted route set, including access/member flows, CLI auth challenge routes, invite acceptance, issue thread interaction routes, adapter environment testing, budget policy routes, resource memberships, secret provider routes, cloud upstream routes, and `/api/openapi.json` itself. - Corrected documented path mismatches such as `skills/scan` vs `skills/scan-projects`, and other route-name/path drift. - Added security schemes plus operation-level security metadata so public, authenticated, board-only, and instance-admin endpoints are distinguishable in the generated contract. - Fixed reviewed response-code mismatches for create/accept flows and authz failures, including `201`, `202`, and `403` cases that were previously flattened away. - Added `server/src/__tests__/openapi-routes.test.ts` to diff the generated spec against mounted server routes and assert key auth/response invariants. - Hardened the route-drift test after review feedback: it now handles single/double/template route literals, fails on unlisted route files that declare router methods, and filters OpenAPI path-item keys to HTTP methods only. ## Verification - `pnpm exec vitest run server/src/__tests__/openapi-routes.test.ts` - `pnpm --filter @paperclipai/ui exec vitest run src/pages/Inbox.test.tsx` - `pnpm -r typecheck` - `pnpm test:run` - `pnpm build` Manual notes: - Confirmed the generated spec now matches the mounted route set in the focused regression test. - Confirmed `/api/plugins/install` is marked privileged in the generated security metadata. - Confirmed `POST /api/invites/{token}/accept` documents `202`. - Addressed the Greptile route coverage comments and reran the focused OpenAPI test, typecheck, and build successfully. ## Risks - Medium-low risk. The main risk is ongoing spec drift if new routes are added without updating the OpenAPI builder, but the regression test now fails on unknown route files that declare router methods. - The auth metadata is descriptive only; it does not change runtime enforcement. If reviewers assume this PR hardens server auth behavior, that would be an incorrect expectation. - This change increases the amount of hand-maintained OpenAPI mapping in `server/src/routes/openapi.ts`, so future API additions still need discipline. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected. See `CONTRIBUTING.md`. ## Model Used - OpenAI Codex, GPT-5-based coding agent in Codex desktop. Exact internal model variant/version and context-window size are not exposed in this environment. Tool-enabled coding workflow with terminal execution, git, and GitHub integration. ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots or confirmed screenshots are not applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Claude Opus 4.7 (1M context) --- server/src/__tests__/openapi-routes.test.ts | 144 +++++- server/src/routes/openapi.ts | 485 ++++++++++++++++++++ ui/src/pages/Inbox.test.tsx | 5 +- 3 files changed, 630 insertions(+), 4 deletions(-) diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 248818e3..4c4e8efb 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -1,8 +1,53 @@ 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 { errorHandler } from "../middleware/index.js"; -import { openApiRoutes } from "../routes/openapi.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 = { + "access.ts": "/api", + "activity.ts": "/api", + "adapters.ts": "/api", + "agents.ts": "/api", + "approvals.ts": "/api", + "assets.ts": "/api", + "auth.ts": "/api/auth", + "cloud-upstreams.ts": "/api", + "companies.ts": "/api/companies", + "company-skills.ts": "/api", + "costs.ts": "/api", + "dashboard.ts": "/api", + "environments.ts": "/api", + "execution-workspaces.ts": "/api", + "goals.ts": "/api", + "health.ts": "/api/health", + "inbox-dismissals.ts": "/api", + "instance-database-backups.ts": "/api", + "instance-settings.ts": "/api", + "issues.ts": "/api", + "issue-tree-control.ts": "/api", + "llms.ts": "/api", + "openapi.ts": "/api", + "plugin-ui-static.ts": "/api", + "plugins.ts": "/api", + "projects.ts": "/api", + "resource-memberships.ts": "/api", + "routines.ts": "/api", + "secrets.ts": "/api", + "sidebar-badges.ts": "/api", + "sidebar-preferences.ts": "/api", + "user-profiles.ts": "/api", +}; + +const ROUTE_LITERAL_PATTERN = /router\.(get|post|put|patch|delete)\(\s*["'`]([^"'`]+)["'`]/g; +const ROUTER_METHOD_PATTERN = /router\.(get|post|put|patch|delete)\(/; +const HTTP_METHODS = new Set(["get", "put", "post", "delete", "options", "head", "patch", "trace"]); function createApp() { const app = express(); @@ -11,6 +56,69 @@ function createApp() { return app; } +function normalizeExpressPath(routePath: string) { + return routePath + .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 === "companies.ts" || file === "health.ts") && routePath === "/") { + return prefix; + } + if (file === "companies.ts" || file === "health.ts") { + return `${prefix}${routePath}`; + } + if (file === "auth.ts") { + return `${prefix}${routePath === "/" ? "" : routePath}`; + } + return `${prefix}${routePath}`; +} + +function loadActualRoutes() { + const routes = new Set(); + const unknownRouteFiles: string[] = []; + + for (const file of fs.readdirSync(ROUTES_DIR).filter((entry) => entry.endsWith(".ts"))) { + 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]; + routes.add(`${method} ${normalizeExpressPath(resolveMountedPath(file, prefix, routePath))}`); + } + + if (file === "companies.ts" && source.includes("router.post(COMPANY_IMPORT_ROUTE_PATH")) { + routes.add("POST /api/companies/import"); + } + } + + return { routes, unknownRouteFiles: unknownRouteFiles.sort() }; +} + +function loadSpecRoutes() { + const spec = buildOpenApiSpec(); + const routes = new Set(); + + for (const [routePath, pathItem] of Object.entries>>(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("serves the generated OpenAPI document", async () => { const res = await request(createApp()).get("/api/openapi.json"); @@ -42,4 +150,38 @@ describe("openapi routes", () => { }, }); }); + + it("covers the mounted server routes exactly", () => { + const { routes: actualRoutes, unknownRouteFiles } = loadActualRoutes(); + const { routes: specRoutes } = loadSpecRoutes(); + + const missingInSpec = [...actualRoutes].filter((route) => !specRoutes.has(route)).sort(); + const extraInSpec = [...specRoutes].filter((route) => !actualRoutes.has(route)).sort(); + + expect({ unknownRouteFiles, missingInSpec, extraInSpec }).toEqual({ + unknownRouteFiles: [], + missingInSpec: [], + extraInSpec: [], + }); + }); + + it("documents auth and reviewed response-code invariants", () => { + const { spec } = loadSpecRoutes(); + + expect(spec.paths["/api/openapi.json"].get.security).toEqual([]); + 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/companies/{companyId}/cost-events"].post.responses["201"]).toBeDefined(); + expect(spec.paths["/api/companies/{companyId}/cost-events"].post.responses["403"]).toBeDefined(); + 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(); + }); }); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 4e1ece62..e0aab890 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -98,6 +98,7 @@ import { claimJoinRequestApiKeySchema, createCliAuthChallengeSchema, resolveCliAuthChallengeSchema, + createBoardApiKeySchema, updateCompanyMemberSchema, updateCompanyMemberWithPermissionsSchema, archiveCompanyMemberSchema, @@ -106,6 +107,23 @@ import { // Instance settings patchInstanceGeneralSettingsSchema, patchInstanceExperimentalSettingsSchema, + issueGraphLivenessAutoRecoveryRequestSchema, + // Resource memberships + updateResourceMembershipSchema, + // Document annotations + createDocumentAnnotationCommentSchema, + createDocumentAnnotationThreadSchema, + updateDocumentAnnotationThreadSchema, + // Issue recovery and decomposition + createAcceptedPlanDecompositionSchema, + resolveIssueRecoveryActionSchema, + cancelIssueThreadInteractionSchema, + // Secret provider configs and remote import + createSecretProviderConfigSchema, + updateSecretProviderConfigSchema, + secretProviderConfigDiscoveryPreviewSchema, + remoteSecretImportPreviewSchema, + remoteSecretImportSchema, } from "@paperclipai/shared"; type JsonSchema = Record; @@ -115,6 +133,7 @@ type OpenApiPathRegistration = { path: string; request?: { params?: z.ZodTypeAny; + query?: z.ZodTypeAny; body?: { content: Record; required?: boolean; @@ -343,6 +362,12 @@ class OpenAPIRegistry { if (request?.params) { normalizedOperation.parameters = parametersFromSchema(request.params, "path"); } + if (request?.query) { + normalizedOperation.parameters = [ + ...((normalizedOperation.parameters as unknown[]) ?? []), + ...parametersFromSchema(request.query, "query"), + ]; + } if (request?.body) { normalizedOperation.requestBody = { ...request.body, @@ -404,6 +429,43 @@ const jsonBody = (schema: z.ZodTypeAny) => ({ const r = responses; +function paramsSchemaFromPath(routePath: string): z.ZodObject | undefined { + const names = [...routePath.matchAll(/\{([A-Za-z0-9_]+)\}/g)].map((match) => match[1]); + if (names.length === 0) return undefined; + const shape: z.ZodRawShape = {}; + for (const name of names) { + shape[name] = z.string(); + } + return z.object(shape); +} + +function registerCurrentRoute(input: { + method: string; + path: string; + tags: string[]; + summary: string; + query?: z.ZodTypeAny; + body?: z.ZodTypeAny; + responses?: Record; +}) { + const params = paramsSchemaFromPath(input.path); + const request = params || input.query || input.body + ? { + ...(params ? { params } : {}), + ...(input.query ? { query: input.query } : {}), + ...(input.body ? { body: jsonBody(input.body) } : {}), + } + : undefined; + registry.registerPath({ + method: input.method, + path: input.path, + tags: input.tags, + summary: input.summary, + ...(request ? { request } : {}), + responses: input.responses ?? { 200: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, + }); +} + type OpenApiAuthLevel = | "public" | "authenticated" @@ -449,6 +511,7 @@ const PUBLIC_OPERATIONS = new Set([ const BOARD_ONLY_PREFIXES = [ "/api/auth/", "/api/admin/", + "/api/cloud-upstreams", "/api/plugins", "/api/instance/", ]; @@ -472,6 +535,27 @@ const BOARD_ONLY_OPERATIONS = new Set([ "POST /api/companies/{companyId}/members/{memberId}/archive", "PATCH /api/companies/{companyId}/members/{memberId}/permissions", "GET /api/companies/{companyId}/user-directory", + "GET /api/board-api-keys", + "POST /api/board-api-keys", + "DELETE /api/board-api-keys/{keyId}", + "POST /api/bootstrap/claim", + "GET /api/companies/{companyId}/resource-memberships/me", + "PUT /api/companies/{companyId}/resource-memberships/me/agents/{agentId}", + "PUT /api/companies/{companyId}/resource-memberships/me/projects/{projectId}", + "GET /api/companies/{companyId}/secret-provider-configs", + "POST /api/companies/{companyId}/secret-provider-configs", + "GET /api/companies/{companyId}/secret-providers/health", + "POST /api/companies/{companyId}/secret-provider-configs/discovery/preview", + "GET /api/secret-provider-configs/{id}", + "PATCH /api/secret-provider-configs/{id}", + "DELETE /api/secret-provider-configs/{id}", + "POST /api/secret-provider-configs/{id}/default", + "POST /api/secret-provider-configs/{id}/health", + "POST /api/companies/{companyId}/secrets/remote-import", + "POST /api/companies/{companyId}/secrets/remote-import/preview", + "GET /api/secrets/{id}/usage", + "GET /api/secrets/{id}/access-events", + "POST /api/health/dev-server/restart", "POST /api/issues/{id}/interactions/{interactionId}/accept", "POST /api/issues/{id}/interactions/{interactionId}/reject", "POST /api/issues/{id}/interactions/{interactionId}/respond", @@ -496,14 +580,18 @@ const CREATED_OPERATIONS = new Set([ "POST /api/companies/{companyId}/assets/images", "POST /api/companies/{companyId}/logo", "POST /api/cli-auth/challenges", + "POST /api/board-api-keys", "POST /api/companies", "POST /api/companies/{companyId}/invites", "POST /api/companies/{companyId}/openclaw/invite-prompt", "POST /api/companies/{companyId}/cost-events", "POST /api/companies/{companyId}/finance-events", + "POST /api/companies/{companyId}/secret-provider-configs", "POST /api/companies/{companyId}/environments", "POST /api/companies/{companyId}/goals", "POST /api/companies/{companyId}/labels", + "POST /api/issues/{id}/documents/{key}/annotations", + "POST /api/issues/{id}/documents/{key}/annotations/{threadId}/comments", "POST /api/issues/{id}/work-products", "POST /api/issues/{id}/approvals", "POST /api/companies/{companyId}/issues", @@ -525,6 +613,8 @@ const CREATED_OPERATIONS = new Set([ ]); const ACCEPTED_OPERATIONS = new Set([ + "POST /api/companies/import", + "POST /api/health/dev-server/restart", "POST /api/invites/{token}/accept", ]); @@ -3814,6 +3904,401 @@ registry.registerPath({ responses: { 200: { description: "JavaScript file" }, 404: r.notFound }, }); +// ─── Current route coverage ───────────────────────────────────────────────── + +registerCurrentRoute({ + method: "get", + path: "/api/adapters/{type}", + tags: ["adapters"], + summary: "Get adapter registration details", +}); + +registerCurrentRoute({ + method: "get", + path: "/api/companies/{companyId}/adapters/{type}/model-profiles", + tags: ["adapters"], + summary: "List adapter model profiles for a company", +}); + +registerCurrentRoute({ + method: "post", + path: "/api/health/dev-server/restart", + tags: ["health"], + summary: "Request a managed dev-server restart", + responses: { 202: r.ok(), 403: r.forbidden, 404: r.notFound, 409: { description: "Restart is not required" } }, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/bootstrap/claim", + tags: ["access"], + summary: "Claim first instance admin from a browser session", + responses: { 200: r.ok(), 401: r.unauthorized, 404: r.notFound, 409: { description: "Instance admin already claimed" } }, +}); + +registerCurrentRoute({ + method: "get", + path: "/api/board-api-keys", + tags: ["access"], + summary: "List board API keys", + responses: { 200: r.ok(), 401: r.unauthorized }, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/board-api-keys", + tags: ["access"], + summary: "Create a named board API key", + body: createBoardApiKeySchema, + responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized }, +}); + +registerCurrentRoute({ + method: "delete", + path: "/api/board-api-keys/{keyId}", + tags: ["access"], + summary: "Revoke a board API key", +}); + +for (const route of [ + ["get", "/api/companies/import/jobs/{jobId}", "Get company import job status"], + ["get", "/api/companies/{companyId}/search", "Search company data"], + ["get", "/api/companies/{companyId}/issues/count", "Count issues in a company"], +] as const) { + registerCurrentRoute({ + method: route[0], + path: route[1], + tags: ["companies"], + summary: route[2], + }); +} + +registerCurrentRoute({ + method: "get", + path: "/api/issues/{id}/cost-summary", + tags: ["costs"], + summary: "Get issue cost summary", +}); + +for (const route of [ + ["get", "/api/companies/{companyId}/resource-memberships/me", "List current user's resource memberships"], + ["put", "/api/companies/{companyId}/resource-memberships/me/agents/{agentId}", "Join or leave an agent resource"], + ["put", "/api/companies/{companyId}/resource-memberships/me/projects/{projectId}", "Join or leave a project resource"], +] as const) { + registerCurrentRoute({ + method: route[0], + path: route[1], + tags: ["resource-memberships"], + summary: route[2], + ...(route[0] === "put" ? { body: updateResourceMembershipSchema } : {}), + }); +} + +const cloudCompanyQuerySchema = z.object({ + companyId: z.string().min(1), +}); +const cloudCompanyBodySchema = z.object({ + companyId: z.string().min(1), +}); +const cloudConnectStartSchema = z.object({ + companyId: z.string().min(1), + remoteUrl: z.string().min(1), + redirectUri: z.string().min(1), +}); +const cloudConnectFinishSchema = z.object({ + pendingConnectionId: z.string().min(1), + code: z.string().min(1), + state: z.string().min(1), +}); +const cloudPushRunSchema = cloudCompanyBodySchema.extend({ + retryOfRunId: z.string().optional(), +}); +const cloudPushRunActivationSchema = cloudCompanyBodySchema.extend({ + entityType: z.enum(["agents", "routines", "monitors"]), +}); + +registerCurrentRoute({ + method: "get", + path: "/api/cloud-upstreams", + tags: ["cloud-upstreams"], + summary: "List cloud upstream connections", + query: cloudCompanyQuerySchema, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/cloud-upstreams/connect/start", + tags: ["cloud-upstreams"], + summary: "Start a cloud upstream connection", + body: cloudConnectStartSchema, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/cloud-upstreams/connect/finish", + tags: ["cloud-upstreams"], + summary: "Finish a cloud upstream connection", + body: cloudConnectFinishSchema, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/cloud-upstreams/{connectionId}/push-runs/preview", + tags: ["cloud-upstreams"], + summary: "Preview a cloud upstream push run", + body: cloudCompanyBodySchema, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/cloud-upstreams/{connectionId}/push-runs", + tags: ["cloud-upstreams"], + summary: "Create a cloud upstream push run", + body: cloudPushRunSchema, +}); + +registerCurrentRoute({ + method: "get", + path: "/api/cloud-upstreams/{connectionId}/push-runs/{runId}", + tags: ["cloud-upstreams"], + summary: "Get a cloud upstream push run", + query: cloudCompanyQuerySchema, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/cloud-upstreams/{connectionId}/push-runs/{runId}/cancel", + tags: ["cloud-upstreams"], + summary: "Cancel a cloud upstream push run", + body: cloudCompanyBodySchema, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/cloud-upstreams/{connectionId}/push-runs/{runId}/activation", + tags: ["cloud-upstreams"], + summary: "Activate cloud upstream push run entities", + body: cloudPushRunActivationSchema, +}); + +for (const route of [ + ["get", "/api/companies/{companyId}/secret-providers/health", "Check configured secret providers"], + ["get", "/api/companies/{companyId}/secret-provider-configs", "List secret provider configurations"], + ["get", "/api/secret-provider-configs/{id}", "Get a secret provider configuration"], + ["delete", "/api/secret-provider-configs/{id}", "Delete a secret provider configuration"], + ["post", "/api/secret-provider-configs/{id}/default", "Set the default secret provider configuration"], + ["post", "/api/secret-provider-configs/{id}/health", "Check a secret provider configuration"], + ["get", "/api/secrets/{id}/usage", "Get secret usage"], + ["get", "/api/secrets/{id}/access-events", "List secret access events"], +] as const) { + registerCurrentRoute({ + method: route[0], + path: route[1], + tags: ["secrets"], + summary: route[2], + }); +} + +registerCurrentRoute({ + method: "post", + path: "/api/companies/{companyId}/secret-provider-configs", + tags: ["secrets"], + summary: "Create a secret provider configuration", + body: createSecretProviderConfigSchema, + responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registerCurrentRoute({ + method: "patch", + path: "/api/secret-provider-configs/{id}", + tags: ["secrets"], + summary: "Update a secret provider configuration", + body: updateSecretProviderConfigSchema, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/companies/{companyId}/secret-provider-configs/discovery/preview", + tags: ["secrets"], + summary: "Preview secret provider discovery", + body: secretProviderConfigDiscoveryPreviewSchema, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/companies/{companyId}/secrets/remote-import/preview", + tags: ["secrets"], + summary: "Preview remote secret import", + body: remoteSecretImportPreviewSchema, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/companies/{companyId}/secrets/remote-import", + tags: ["secrets"], + summary: "Import remote secrets", + body: remoteSecretImportSchema, +}); + +for (const route of [ + ["get", "/api/skills/catalog", "List catalog skills"], + ["get", "/api/skills/catalog/{catalogId}", "Get a catalog skill"], + ["get", "/api/skills/catalog/{catalogId}/files", "List catalog skill files"], + ["post", "/api/companies/{companyId}/skills/install-catalog", "Install a catalog skill"], + ["post", "/api/companies/{companyId}/skills/{skillId}/audit", "Audit a company skill"], + ["post", "/api/companies/{companyId}/skills/{skillId}/reset", "Reset a company skill"], +] as const) { + registerCurrentRoute({ + method: route[0], + path: route[1], + tags: ["skills"], + summary: route[2], + ...(route[0] === "post" ? { body: z.record(z.unknown()).optional() } : {}), + }); +} + +registerCurrentRoute({ + method: "post", + path: "/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview", + tags: ["instance-settings"], + summary: "Preview issue graph liveness auto-recovery", + body: issueGraphLivenessAutoRecoveryRequestSchema, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/run", + tags: ["instance-settings"], + summary: "Run issue graph liveness auto-recovery", + body: issueGraphLivenessAutoRecoveryRequestSchema, +}); + +registerCurrentRoute({ + method: "get", + path: "/api/issues/{id}/accepted-plan-decompositions", + tags: ["issues"], + summary: "List accepted plan decompositions", +}); + +registerCurrentRoute({ + method: "post", + path: "/api/issues/{id}/accepted-plan-decompositions", + tags: ["issues"], + summary: "Create accepted plan decomposition child issues", + body: createAcceptedPlanDecompositionSchema, +}); + +for (const route of [ + ["get", "/api/issues/{id}/documents/{key}/annotations", "List document annotation threads"], + ["get", "/api/issues/{id}/documents/{key}/annotations/{threadId}", "Get a document annotation thread"], + ["post", "/api/issues/{id}/documents/{key}/lock", "Lock an issue document"], + ["post", "/api/issues/{id}/documents/{key}/unlock", "Unlock an issue document"], +] as const) { + registerCurrentRoute({ + method: route[0], + path: route[1], + tags: ["issues"], + summary: route[2], + }); +} + +registerCurrentRoute({ + method: "post", + path: "/api/issues/{id}/documents/{key}/annotations", + tags: ["issues"], + summary: "Create a document annotation thread", + body: createDocumentAnnotationThreadSchema, + responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/issues/{id}/documents/{key}/annotations/{threadId}/comments", + tags: ["issues"], + summary: "Add a document annotation comment", + body: createDocumentAnnotationCommentSchema, + responses: { 201: r.ok(), 400: r.badRequest, 401: r.unauthorized, 404: r.notFound }, +}); + +registerCurrentRoute({ + method: "patch", + path: "/api/issues/{id}/documents/{key}/annotations/{threadId}", + tags: ["issues"], + summary: "Update a document annotation thread", + body: updateDocumentAnnotationThreadSchema, +}); + +registerCurrentRoute({ + method: "get", + path: "/api/issues/{id}/recovery-actions", + tags: ["issues"], + summary: "List issue recovery actions", +}); + +registerCurrentRoute({ + method: "post", + path: "/api/issues/{id}/recovery-actions/resolve", + tags: ["issues"], + summary: "Resolve an issue recovery action", + body: resolveIssueRecoveryActionSchema, +}); + +registerCurrentRoute({ + method: "post", + path: "/api/issues/{id}/scheduled-retry/retry-now", + tags: ["issues"], + summary: "Retry a scheduled issue run now", +}); + +registerCurrentRoute({ + method: "post", + path: "/api/issues/{id}/monitor/check-now", + tags: ["issues"], + summary: "Run an issue monitor check now", +}); + +registerCurrentRoute({ + method: "post", + path: "/api/issues/{id}/interactions/{interactionId}/cancel", + tags: ["issues"], + summary: "Cancel an issue question interaction", + body: cancelIssueThreadInteractionSchema, +}); + +for (const route of [ + ["get", "/api/routines/{id}/revisions", "List routine revisions"], + ["post", "/api/routines/{id}/revisions/{revisionId}/restore", "Restore a routine revision"], +] as const) { + registerCurrentRoute({ + method: route[0], + path: route[1], + tags: ["routines"], + summary: route[2], + }); +} + +const pluginLocalFolderRequestSchema = z.object({ + path: z.string().min(1), + access: z.enum(["read", "readWrite"]).optional(), + requiredDirectories: z.array(z.string()).optional(), + requiredFiles: z.array(z.string()).optional(), +}); + +for (const route of [ + ["get", "/api/plugins/{pluginId}/companies/{companyId}/local-folders", "List plugin local folders"], + ["get", "/api/plugins/{pluginId}/companies/{companyId}/local-folders/{folderKey}/status", "Get plugin local folder status"], + ["post", "/api/plugins/{pluginId}/companies/{companyId}/local-folders/{folderKey}/validate", "Validate a plugin local folder"], + ["put", "/api/plugins/{pluginId}/companies/{companyId}/local-folders/{folderKey}", "Save a plugin local folder"], +] as const) { + registerCurrentRoute({ + method: route[0], + path: route[1], + tags: ["plugins"], + summary: route[2], + ...(route[0] === "post" || route[0] === "put" ? { body: pluginLocalFolderRequestSchema } : {}), + }); +} + // ─── Spec builder ───────────────────────────────────────────────────────────── // eslint-disable-next-line @typescript-eslint/no-explicit-any diff --git a/ui/src/pages/Inbox.test.tsx b/ui/src/pages/Inbox.test.tsx index 1c4d5b61..5f7d5d74 100644 --- a/ui/src/pages/Inbox.test.tsx +++ b/ui/src/pages/Inbox.test.tsx @@ -313,12 +313,11 @@ describe("Inbox toolbar", () => { , ); }); - await act(async () => { - await Promise.resolve(); + await vi.waitFor(() => { + expect(container.querySelectorAll("[data-inbox-item]").length).toBeGreaterThanOrEqual(2); }); const rows = container.querySelectorAll("[data-inbox-item]"); - expect(rows.length).toBeGreaterThanOrEqual(2); const linkOf = (row: Element): HTMLAnchorElement | null => row.querySelector("a[data-inbox-issue-link]");