diff --git a/server/src/__tests__/openapi-routes.test.ts b/server/src/__tests__/openapi-routes.test.ts index 98d8ad9d8e3..5ab9bd45bfd 100644 --- a/server/src/__tests__/openapi-routes.test.ts +++ b/server/src/__tests__/openapi-routes.test.ts @@ -421,6 +421,31 @@ describe("openapi routes", () => { expect(create).toContain("/{prefix}/issues/{identifier}"); }); + it("publishes grant-gated access operations as board_or_agent and keeps member edits board-only", () => { + const spec = buildOpenApiSpec() as any; + for (const [path, method, grant] of [ + ["/api/companies/{companyId}/invites", "post", "users:invite"], + ["/api/companies/{companyId}/invites", "get", "users:invite"], + ["/api/companies/{companyId}/join-requests", "get", "joins:approve"], + ["/api/companies/{companyId}/join-requests/{requestId}/approve", "post", "joins:approve"], + ["/api/companies/{companyId}/join-requests/{requestId}/reject", "post", "joins:approve"], + ["/api/companies/{companyId}/members", "get", "users:manage_permissions"], + ] as const) { + const operation = spec.paths[path][method]; + expect(operation["x-paperclip-authorization"], `${method} ${path}`).toEqual({ actor: "board_or_agent" }); + expect(operation.description).toContain(grant); + } + expect(spec.paths["/api/companies/{companyId}/invites"].post.description).toContain("inviteUrl"); + for (const path of [ + "/api/companies/{companyId}/members/{memberId}/permissions", + "/api/companies/{companyId}/members/{memberId}/role-and-grants", + ]) { + expect(spec.paths[path].patch["x-paperclip-authorization"]).toEqual({ actor: "board" }); + expect(spec.paths[path].patch.description).toContain("//company/settings/members"); + } + expect(spec.paths["/api/companies/{companyId}/members"].get.description).toContain("//company/settings/members"); + }); + it("documents owned conversation reads and exact revision access separately", () => { const spec = buildOpenApiSpec() as any; const boardSecurity = [{ BoardSessionAuth: [] }, { BoardApiKeyAuth: [] }]; diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 4ff52281987..6beaf9a6171 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -1486,13 +1486,7 @@ const BOARD_ONLY_OPERATIONS = new Set([ "GET /api/companies/import/jobs/{jobId}", "POST /api/board-claim/{token}/claim", "GET /api/cli-auth/me", - "POST /api/companies/{companyId}/invites", - "GET /api/companies/{companyId}/invites", "POST /api/companies/{companyId}/openclaw/invite-prompt", - "GET /api/companies/{companyId}/join-requests", - "POST /api/companies/{companyId}/join-requests/{requestId}/approve", - "POST /api/companies/{companyId}/join-requests/{requestId}/reject", - "GET /api/companies/{companyId}/members", "PATCH /api/companies/{companyId}/members/{memberId}", "PATCH /api/companies/{companyId}/members/{memberId}/role-and-grants", "POST /api/companies/{companyId}/members/{memberId}/archive", @@ -7276,6 +7270,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/invites", tags: ["access"], summary: "List company invites", + description: "Board members or agents holding users:invite in this company. Lists invite records; it never returns invite tokens.", request: { params: z.object({ companyId: z.string() }) }, responses: { 200: r.ok(), 401: r.unauthorized }, }); @@ -7285,6 +7280,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/invites", tags: ["access"], summary: "Create a company invite", + description: "Board members or agents holding users:invite in this company. Creates a shareable join link (inviteUrl) that the person who should join opens; it does not send email and has no email field. Write the returned inviteUrl as a Markdown link in your reply. Membership starts only after the person accepts the link (and a join request is approved when required). humanRole defaults to operator.", request: { params: z.object({ companyId: z.string() }), body: jsonBody(createCompanyInviteSchema), @@ -7297,6 +7293,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/join-requests", tags: ["access"], summary: "List company join requests", + description: "Board members or agents holding joins:approve in this company.", request: { params: z.object({ companyId: z.string() }) }, responses: { 200: r.ok(), 401: r.unauthorized }, }); @@ -7306,6 +7303,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/join-requests/{requestId}/approve", tags: ["access"], summary: "Approve a company join request", + description: "Board members or agents holding joins:approve in this company.", request: { params: z.object({ companyId: z.string(), requestId: z.string() }), }, @@ -7317,6 +7315,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/join-requests/{requestId}/reject", tags: ["access"], summary: "Reject a company join request", + description: "Board members or agents holding joins:approve in this company.", request: { params: z.object({ companyId: z.string(), requestId: z.string() }), }, @@ -7363,6 +7362,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/members", tags: ["access"], summary: "List company members", + description: "Board members or agents holding users:manage_permissions in this company. Returns each member's role, status and explicit grants. Changing a member's role, grants or status stays board-only: A person makes this change on the Members page at //company/settings/members (prefix = the company's issue prefix); link that page as a Markdown link.", request: { params: z.object({ companyId: z.string() }) }, responses: { 200: r.ok(), 401: r.unauthorized }, }); @@ -7372,6 +7372,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/members/{memberId}", tags: ["access"], summary: "Update a company member status or role", + description: "Board-only, even for agents holding users:manage_permissions. A person makes this change on the Members page at //company/settings/members (prefix = the company's issue prefix); link that page as a Markdown link.", request: { params: z.object({ companyId: z.string(), memberId: z.string() }), body: jsonBody(updateCompanyMemberSchema), @@ -7389,6 +7390,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/members/{memberId}/role-and-grants", tags: ["access"], summary: "Update a company member role and explicit grants", + description: "Board-only, even for agents holding users:manage_permissions. A person makes this change on the Members page at //company/settings/members (prefix = the company's issue prefix); link that page as a Markdown link.", request: { params: z.object({ companyId: z.string(), memberId: z.string() }), body: jsonBody(updateCompanyMemberWithPermissionsSchema), @@ -7406,6 +7408,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/members/{memberId}/archive", tags: ["access"], summary: "Archive a company member", + description: "Board-only, even for agents holding users:manage_permissions. A person makes this change on the Members page at //company/settings/members (prefix = the company's issue prefix); link that page as a Markdown link.", request: { params: z.object({ companyId: z.string(), memberId: z.string() }), body: jsonBody(archiveCompanyMemberSchema), @@ -7423,6 +7426,7 @@ registry.registerPath({ path: "/api/companies/{companyId}/members/{memberId}/permissions", tags: ["access"], summary: "Update explicit company member permissions", + description: "Board-only, even for agents holding users:manage_permissions. A person makes this change on the Members page at //company/settings/members (prefix = the company's issue prefix); link that page as a Markdown link.", request: { params: z.object({ companyId: z.string(), memberId: z.string() }), body: jsonBody(updateMemberPermissionsSchema),