From 0819cac4c6b65680b742713a6b9a79ed78a737b9 Mon Sep 17 00:00:00 2001 From: Austin Date: Thu, 13 Aug 2026 18:43:43 -0400 Subject: [PATCH] feat(secrets): add agent-readable /secrets/catalog endpoint (#9530) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents can be configured with env bindings that reference company secrets — they specify which secret by UUID in `adapterConfig.env` > - But there is no API endpoint agents can call to look up a secret UUID by name — `GET /companies/:companyId/secrets` is board-only, and the internal `secrets.resolve` handler only accepts UUIDs > - So when an agent needs to wire a new secret (e.g. an API key for a new skill), it has no way to discover the UUID from a known name like `HOMEBOX_API_KEY` — the user must find it by inspecting browser network traffic > - The fix is a read-only catalog endpoint that agents can call to get the `id`/`name`/`key`/`status` mapping — no values, no provider config — just enough to resolve a name to a UUID > - This PR adds `GET /companies/:companyId/secrets/catalog`, guarded by `assertBoardOrAgent` + `assertCompanyAccess`, so agents can discover the UUID they need without board-level access and without any secret value being exposed ## Linked Issues or Issue Description No pre-existing public issue. Describing inline per the feature request template: **Subsystem affected:** `server/` — REST API & orchestration services **Problem or motivation:** Agents that configure env bindings must reference secrets by UUID (`secretId`). There is no agent-accessible API to resolve a secret name to its UUID. `GET /companies/:companyId/secrets` requires board access; the internal `secrets.resolve` handler rejects anything that is not already a UUID. Agents and their operators are forced to find UUIDs by inspecting browser network requests, which is friction that should not exist. **Proposed solution:** Add a read-only catalog endpoint — `GET /companies/:companyId/secrets/catalog` — that agents can call. It returns only non-sensitive metadata (`id`, `name`, `key`, `status`) for each active company secret, stripped of values, provider configuration, and version history. Board callers get the same response. The existing full-detail list endpoint (`GET /companies/:companyId/secrets`) remains board-only and is unchanged. **Alternatives considered:** - Allow agents to call the existing `/secrets` list — rejected because it returns full rows including provider metadata; narrowing the response is safer. - Add a name-to-UUID lookup by query param — simpler but less useful; a full catalog means the agent can do the resolution locally without a second round-trip. **Roadmap alignment:** Does not duplicate anything in `ROADMAP.md`. ## What Changed - `server/src/routes/secrets.ts` — new `GET /companies/:companyId/secrets/catalog` route registered before the board-only `GET /companies/:companyId/secrets` route. Uses `assertBoardOrAgent` + `assertCompanyAccess`. Calls `svc.list()` then projects each row to `{ id, name, key, status }` before responding. - `server/src/__tests__/secrets-routes.test.ts` — adds `list` to the shared mock service object (it was missing); adds a `describe` block with four test cases: board caller receives stripped metadata, agent caller in the same company receives stripped metadata, unauthenticated request gets 401, agent from a different company gets 403. ## Verification **Automated:** ```bash pnpm --filter @paperclipai/server test --run secrets-routes ``` All four new test cases (board access, agent access, unauthed rejection, cross-company rejection) should pass. **Manual:** 1. Start the Paperclip server locally. 2. Create a company and a secret via the UI. 3. Call the endpoint as a board user: ```bash curl http://localhost:3100/api/companies//secrets/catalog \ -H "Authorization: Bearer " ``` Expect a JSON array with `id`, `name`, `key`, `status` fields — no `provider`, no `referenceCount`, no version data. 4. Call the same endpoint with an agent API key: ```bash curl http://localhost:3100/api/companies//secrets/catalog \ -H "Authorization: Bearer " ``` Expect the same response. 5. Call with an agent API key scoped to a *different* company — expect 403. ## Risks Low risk. This is a purely additive, read-only endpoint. No existing behavior changes. The only new capability is that agents can discover the UUIDs of secrets in their own company — metadata they already need to do their job. Secret values are never returned. Authorization reuses the existing `assertBoardOrAgent` and `assertCompanyAccess` guards already used throughout the codebase. ## Model Used Claude Sonnet 4.6 (`claude-sonnet-4-6`) — Anthropic, extended context, tool use enabled. The entire change (route, tests, PR description) was produced by the model operating as a Paperclip CEO agent assigned to the task. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Austin Pilz Co-authored-by: root Co-authored-by: Internet Historian Co-authored-by: Andrew Aymeloglu --- .../__tests__/invite-rate-limit-route.test.ts | 92 +++++++++++-------- server/src/__tests__/secrets-routes.test.ts | 74 +++++++++++++++ server/src/routes/openapi.ts | 9 ++ server/src/routes/secrets.ts | 13 ++- 4 files changed, 147 insertions(+), 41 deletions(-) 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;