diff --git a/server/src/__tests__/invite-rate-limit-route.test.ts b/server/src/__tests__/invite-rate-limit-route.test.ts index da748bc45c..894034e8b2 100644 --- a/server/src/__tests__/invite-rate-limit-route.test.ts +++ b/server/src/__tests__/invite-rate-limit-route.test.ts @@ -69,52 +69,64 @@ describe("invite-token endpoint rate limiting", () => { vi.resetModules(); }); - it("returns 429 once the per-IP threshold is exceeded", async () => { - // No invite row -> route would 404, but the rate-limit middleware runs first - // and short-circuits on the second request. - const app = await createApp(createDbStub([], [], [], [], [])); + it( + "returns 429 once the per-IP threshold is exceeded", + async () => { + // No invite row -> route would 404, but the rate-limit middleware runs first + // and short-circuits on the second request. + const app = await createApp(createDbStub([], [], [], [], [])); - const first = await request(app).get( - "/api/invites/pcp_invite_aaaaaaaaaaaaaaaaaaaaaa", - ); - expect(first.status).toBe(404); + const first = await request(app).get( + "/api/invites/pcp_invite_aaaaaaaaaaaaaaaaaaaaaa", + ); + expect(first.status).toBe(404); - const limited = await request(app).get( - "/api/invites/pcp_invite_aaaaaaaaaaaaaaaaaaaaaa", - ); - expect(limited.status).toBe(429); - expect(limited.headers["retry-after"]).toBe("60"); - expect(limited.body).toMatchObject({ - error: "Too many invite requests", - details: { retryAfterSeconds: 60 }, - }); - }, 15_000); + const limited = await request(app).get( + "/api/invites/pcp_invite_aaaaaaaaaaaaaaaaaaaaaa", + ); + expect(limited.status).toBe(429); + expect(limited.headers["retry-after"]).toBe("60"); + expect(limited.body).toMatchObject({ + error: "Too many invite requests", + details: { retryAfterSeconds: 60 }, + }); + }, + 20_000, + ); - it("also rate-limits the accept sub-route", async () => { - const app = await createApp(createDbStub([], [], [], [], [])); + it( + "also rate-limits the accept sub-route", + async () => { + const app = await createApp(createDbStub([], [], [], [], [])); - await request(app).get("/api/invites/pcp_invite_bbbbbbbbbbbbbbbbbbbbbb"); + await request(app).get("/api/invites/pcp_invite_bbbbbbbbbbbbbbbbbbbbbb"); - const limited = await request(app) - .post("/api/invites/pcp_invite_bbbbbbbbbbbbbbbbbbbbbb/accept") - .send({}); - expect(limited.status).toBe(429); - }); + const limited = await request(app) + .post("/api/invites/pcp_invite_bbbbbbbbbbbbbbbbbbbbbb/accept") + .send({}); + expect(limited.status).toBe(429); + }, + 20_000, + ); - it("ignores client-supplied X-Forwarded-For — spoofed IPs do not reset the budget", async () => { - // `trust proxy` is unset here (Express default: trust nothing), so the - // rate-limit key is the socket's remote address. Rotating fake - // X-Forwarded-For values must NOT mint a fresh per-IP budget. - const app = await createApp(createDbStub([], [], [], [], [])); + it( + "ignores client-supplied X-Forwarded-For — spoofed IPs do not reset the budget", + async () => { + // `trust proxy` is unset here (Express default: trust nothing), so the + // rate-limit key is the socket's remote address. Rotating fake + // X-Forwarded-For values must NOT mint a fresh per-IP budget. + const app = await createApp(createDbStub([], [], [], [], [])); - const first = await request(app) - .get("/api/invites/pcp_invite_cccccccccccccccccccccc") - .set("x-forwarded-for", "1.1.1.1"); - expect(first.status).toBe(404); + const first = await request(app) + .get("/api/invites/pcp_invite_cccccccccccccccccccccc") + .set("x-forwarded-for", "1.1.1.1"); + expect(first.status).toBe(404); - const spoofed = await request(app) - .get("/api/invites/pcp_invite_cccccccccccccccccccccc") - .set("x-forwarded-for", "1.1.1.2"); - expect(spoofed.status).toBe(429); - }); + const spoofed = await request(app) + .get("/api/invites/pcp_invite_cccccccccccccccccccccc") + .set("x-forwarded-for", "1.1.1.2"); + expect(spoofed.status).toBe(429); + }, + 20_000, + ); }); diff --git a/server/src/__tests__/secrets-routes.test.ts b/server/src/__tests__/secrets-routes.test.ts index 11ab3fbd73..aed40e872f 100644 --- a/server/src/__tests__/secrets-routes.test.ts +++ b/server/src/__tests__/secrets-routes.test.ts @@ -18,6 +18,7 @@ const mockSecretService = vi.hoisted(() => ({ setDefaultProviderConfig: vi.fn(), checkProviderConfigHealth: vi.fn(), getById: vi.fn(), + list: vi.fn(), getByKey: vi.fn(), create: vi.fn(), rotate: vi.fn(), @@ -981,4 +982,77 @@ describe("secret routes", () => { }), ); }); + + describe("GET /companies/:companyId/secrets/catalog", () => { + const fullSecrets = [ + { + id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", + name: "MY_API_KEY", + key: "my_api_key", + status: "active", + companyId: "company-1", + provider: "local_encrypted", + providerMetadata: null, + referenceCount: 2, + }, + { + id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", + name: "DB_PASSWORD", + key: "db_password", + status: "active", + companyId: "company-1", + provider: "local_encrypted", + providerMetadata: null, + referenceCount: 0, + }, + ]; + + it("returns id/name/key/status only for board callers", async () => { + mockSecretService.list.mockResolvedValue(fullSecrets); + + const res = await request(createApp()).get("/api/companies/company-1/secrets/catalog"); + + expect(res.status).toBe(200); + expect(res.body).toEqual([ + { id: "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", name: "MY_API_KEY", key: "my_api_key", status: "active" }, + { id: "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb", name: "DB_PASSWORD", key: "db_password", status: "active" }, + ]); + expect(res.body[0]).not.toHaveProperty("provider"); + expect(res.body[0]).not.toHaveProperty("referenceCount"); + }); + + it("returns id/name/key/status only for agent callers in the same company", async () => { + mockSecretService.list.mockResolvedValue(fullSecrets); + + const agentApp = createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-1", + }); + const res = await request(agentApp).get("/api/companies/company-1/secrets/catalog"); + + expect(res.status).toBe(200); + expect(res.body).toHaveLength(2); + expect(res.body[0]).toMatchObject({ id: expect.any(String), name: "MY_API_KEY", key: "my_api_key", status: "active" }); + }); + + it("rejects unauthenticated requests", async () => { + const res = await request(createApp({ type: "none" })) + .get("/api/companies/company-1/secrets/catalog"); + + // assertBoardOrAgent throws forbidden (403) for all non-agent/non-board actors; + // it does not call assertAuthenticated first, so type:"none" gets 403, not 401. + expect(res.status).toBe(403); + }); + + it("rejects agents from a different company", async () => { + const res = await request(createApp({ + type: "agent", + agentId: "agent-1", + companyId: "company-2", + })).get("/api/companies/company-1/secrets/catalog"); + + expect(res.status).toBe(403); + }); + }); }); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 773d2c47bf..e43cd2fa4f 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -2932,6 +2932,15 @@ registry.registerPath({ responses: { 200: r.ok(), 401: r.unauthorized }, }); +registry.registerPath({ + method: "get", + path: "/api/companies/{companyId}/secrets/catalog", + tags: ["secrets"], + summary: "List secret metadata (id, name, key, status) — accessible to agents", + request: { params: z.object({ companyId: z.string() }) }, + responses: { 200: r.ok(), 401: r.unauthorized, 403: r.forbidden }, +}); + registry.registerPath({ method: "get", path: "/api/companies/{companyId}/secrets", diff --git a/server/src/routes/secrets.ts b/server/src/routes/secrets.ts index 6b50e4f334..2dfeedf071 100644 --- a/server/src/routes/secrets.ts +++ b/server/src/routes/secrets.ts @@ -16,7 +16,7 @@ import { updateUserSecretValueSchema, } from "@paperclipai/shared"; import { validate } from "../middleware/validate.js"; -import { assertBoard, assertCompanyAccess, getAccessibleResource } from "./authz.js"; +import { assertBoard, assertBoardOrAgent, assertCompanyAccess, getAccessibleResource } from "./authz.js"; import { logActivity, secretService } from "../services/index.js"; import { createSecretProposalsService } from "../services/secret-proposals.js"; import { getConfiguredSecretProvider } from "../secrets/configured-provider.js"; @@ -598,6 +598,17 @@ export function secretRoutes(db: Db, deps: SecretRoutesDeps = {}) { res.json(health); }); + // Agent-readable catalog: returns only non-sensitive metadata (id, name, key, status). + // Agents need this to resolve a secret name to its UUID when wiring env bindings, + // without requiring board access or exposing any secret values. + router.get("/companies/:companyId/secrets/catalog", async (req, res) => { + assertBoardOrAgent(req); + const companyId = req.params.companyId as string; + assertCompanyAccess(req, companyId); + const secrets = await svc.list(companyId); + res.json(secrets.map(({ id, name, key, status }) => ({ id, name, key, status }))); + }); + router.get("/companies/:companyId/secrets", async (req, res) => { assertBoard(req); const companyId = req.params.companyId as string;