feat(secrets): add agent-readable /secrets/catalog endpoint (#9530)
## 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/<companyId>/secrets/catalog \
-H "Authorization: Bearer <board-session-token>"
```
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/<companyId>/secrets/catalog \
-H "Authorization: Bearer <agent-api-key>"
```
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 <austinpilz@users.noreply.github.com>
Co-authored-by: root <root@paperclip.pilz.dev>
Co-authored-by: Internet Historian <agent@paperclip.internal>
Co-authored-by: Andrew Aymeloglu <aaymeloglu@gmail.com>
This commit is contained in:
parent
eabecc6f77
commit
0819cac4c6
|
|
@ -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,
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue