mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 05:11:49 +01:00
fix(tool-gateway): let agents create gateway tokens without receiving the value
Agents keep delegated authority (#17737) to create and revoke gateway client tokens. An agent-created token is saved with pendingReveal and no usable value; the create response carries only metadata, the Tokens page path and a reveal_credential required user action. The responsible person reveals the value once on the Tokens page (new person-only POST /api/tool-gateway/gateway-tokens/:tokenId/reveal), which generates it at that moment. Person callers keep getting the value from create. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
98ce93dd4e
commit
0abe017e31
15 files changed
+534
-107
No files matched your search
@@ -220,11 +220,20 @@ Review: `operation-reviewed`.
|
||||
|
||||
API: `present` — `POST /api/tool-gateway/gateways/{gatewayId}/tokens`; JSON REST.
|
||||
Agent access: `available` — agent REST and default native `search_api` / `call_api`.
|
||||
Policy: `tools:manage_connections (responsible user: tools:admin)`; [shared management policy](../../doc/plans/2026-10-13-connection-api-parity.md#authorization-contract). Resolved resources and every affected target are checked; personal ownership remains the responsible user.
|
||||
Policy: `tools:manage_connections (responsible user: tools:admin)`; [shared management policy](../../doc/plans/2026-10-13-connection-api-parity.md#authorization-contract). Resolved resources and every affected target are checked; personal ownership remains the responsible user. Agent callers receive token metadata and the Tokens page path only; the token value is generated when the responsible person reveals it.
|
||||
Evidence: `verified` — [route](../../server/src/routes/tool-gateway.ts); reviewed in the working tree based on the source commit above. [Authenticated fixtures](../../server/src/__tests__/connection-management.integration.test.ts) passed with database persistence and actual bearer/session middleware.
|
||||
Verification: `passed` — deterministic provider fixtures / local assembled server; no live provider qualification.
|
||||
Gaps: Provider fixtures do not qualify live provider setup or availability.
|
||||
|
||||
### `post-tool-gateway-gateway-tokens-reveal`
|
||||
|
||||
API: `present` — `POST /api/tool-gateway/gateway-tokens/{tokenId}/reveal`; JSON REST.
|
||||
Agent access: `operator-only` — person-only; agent callers receive 403 `gateway_token_requires_person`.
|
||||
Policy: board session with `tools:admin`; only the responsible person of an agent-created token can reveal it, once.
|
||||
Evidence: `verified` — [route](../../server/src/routes/tool-gateway.ts); [authenticated fixtures](../../server/src/__tests__/connection-management.integration.test.ts) cover agent denial, one-time reveal, a working revealed value and responsible-person enforcement.
|
||||
Verification: `passed` — local assembled server.
|
||||
Gaps: No live provider qualification.
|
||||
|
||||
### `post-tool-gateway-gateway-tokens-revoke`
|
||||
|
||||
API: `present` — `POST /api/tool-gateway/gateway-tokens/{tokenId}/revoke`; JSON REST.
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
ALTER TABLE "tool_mcp_gateway_tokens" ADD COLUMN "pending_reveal" boolean DEFAULT false NOT NULL;
|
||||
+162
-2
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"id": "2be53987-902a-41b6-8cb4-6e89a768f8cf",
|
||||
"prevId": "f98af245-4a2e-4c3d-b89e-6ed3e81a074c",
|
||||
"id": "1c0da987-ca1c-4dce-8858-1c7c6f80e8db",
|
||||
"prevId": "e070677d-cfb8-4438-847c-29c4da7ca8c5",
|
||||
"version": "7",
|
||||
"dialect": "postgresql",
|
||||
"tables": {
|
||||
@@ -10188,6 +10188,40 @@
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"chat_actions_company_issue_kind_idx": {
|
||||
"name": "chat_actions_company_issue_kind_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "(\"payload\"->>'issueId')",
|
||||
"asc": true,
|
||||
"isExpression": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "kind",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "status",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"chat_actions\".\"kind\" IN ('inbound_wakeup', 'failed_run_retry')",
|
||||
"concurrently": true,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"chat_actions_github_stale_head_idx": {
|
||||
"name": "chat_actions_github_stale_head_idx",
|
||||
"columns": [
|
||||
@@ -10955,6 +10989,28 @@
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"chat_deliveries_github_automatic_delivery_idx": {
|
||||
"name": "chat_deliveries_github_automatic_delivery_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "endpoint_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "(\"normalized_event\"->'githubAutomatic'->'context'->>'deliveryId')",
|
||||
"asc": true,
|
||||
"isExpression": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "(\"chat_deliveries\".\"normalized_event\"->'githubAutomatic'->'context'->>'deliveryId') is not null",
|
||||
"concurrently": true,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
@@ -12393,6 +12449,44 @@
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"chat_message_links_company_comment_idx": {
|
||||
"name": "chat_message_links_company_comment_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "comment_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"chat_message_links\".\"comment_id\" is not null",
|
||||
"concurrently": true,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"chat_message_links_delivery_idx": {
|
||||
"name": "chat_message_links_delivery_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "delivery_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"chat_message_links\".\"delivery_id\" is not null",
|
||||
"concurrently": true,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
@@ -29073,6 +29167,22 @@
|
||||
"concurrently": false,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"environment_leases_native_backup_scope_idx": {
|
||||
"name": "environment_leases_native_backup_scope_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "(\"metadata\"->'nativeHarnessBackup'->>'sessionScopeSha256')",
|
||||
"asc": true,
|
||||
"isExpression": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "(\"environment_leases\".\"metadata\"->'nativeHarnessBackup'->>'sessionScopeSha256') is not null",
|
||||
"concurrently": true,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
}
|
||||
},
|
||||
"foreignKeys": {
|
||||
@@ -35475,6 +35585,27 @@
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"heartbeat_runs_company_wake_comment_idx": {
|
||||
"name": "heartbeat_runs_company_wake_comment_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "(\"context_snapshot\"->>'wakeCommentId')",
|
||||
"asc": true,
|
||||
"isExpression": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"concurrently": true,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"heartbeat_runs_cost_accounting_pending_idx": {
|
||||
"name": "heartbeat_runs_cost_accounting_pending_idx",
|
||||
"columns": [
|
||||
@@ -35627,6 +35758,28 @@
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"heartbeat_runs_company_native_session_idx": {
|
||||
"name": "heartbeat_runs_company_native_session_idx",
|
||||
"columns": [
|
||||
{
|
||||
"expression": "company_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
},
|
||||
{
|
||||
"expression": "native_session_id",
|
||||
"isExpression": false,
|
||||
"asc": true,
|
||||
"nulls": "last"
|
||||
}
|
||||
],
|
||||
"isUnique": false,
|
||||
"where": "\"heartbeat_runs\".\"native_session_id\" is not null",
|
||||
"concurrently": true,
|
||||
"method": "btree",
|
||||
"with": {}
|
||||
},
|
||||
"heartbeat_runs_company_issue_created_idx": {
|
||||
"name": "heartbeat_runs_company_issue_created_idx",
|
||||
"columns": [
|
||||
@@ -59319,6 +59472,13 @@
|
||||
"notNull": true,
|
||||
"default": "''"
|
||||
},
|
||||
"pending_reveal": {
|
||||
"name": "pending_reveal",
|
||||
"type": "boolean",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"default": false
|
||||
},
|
||||
"allowed_actions": {
|
||||
"name": "allowed_actions",
|
||||
"type": "jsonb",
|
||||
+7
@@ -3207,6 +3207,13 @@
|
||||
"when": 1794329161946,
|
||||
"tag": "0459_environment_lease_backup_scope_index",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 460,
|
||||
"version": "7",
|
||||
"when": 1794332809027,
|
||||
"tag": "0460_gateway_token_pending_reveal",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -571,6 +571,9 @@ export const toolMcpGatewayTokens = pgTable(
|
||||
// Null distinguishes board/legacy clients from delegated agent clients.
|
||||
responsibleUserId: text("responsible_user_id"),
|
||||
ownerNote: text("owner_note").notNull().default(""),
|
||||
// Agent-created client tokens have no usable value until the responsible
|
||||
// person reveals one on the gateway's Tokens page; agents never receive it.
|
||||
pendingReveal: boolean("pending_reveal").notNull().default(false),
|
||||
allowedActions: jsonb("allowed_actions").$type<ToolMcpGatewayTokenAction[]>().notNull().default(["tools/list", "tools/call"]),
|
||||
expiresAt: timestamp("expires_at", { withTimezone: true }),
|
||||
expiryOverrideReason: text("expiry_override_reason"),
|
||||
|
||||
@@ -5,10 +5,10 @@ import type { RemoteMcpConnectorId } from "../remote-mcp-connectors.js";
|
||||
|
||||
/** A saved setup's next human step. This is not an authorization grant. */
|
||||
export interface ConnectionRequiredUserAction {
|
||||
kind: "authenticate" | "consent" | "enter_credentials" | "confirm_identity";
|
||||
kind: "authenticate" | "consent" | "enter_credentials" | "confirm_identity" | "reveal_credential";
|
||||
userId: string;
|
||||
reason: string;
|
||||
resource: { type: "connection" | "chat_endpoint" | "ai_login"; id: string; companyId: string };
|
||||
resource: { type: "connection" | "chat_endpoint" | "ai_login" | "gateway_token"; id: string; companyId: string };
|
||||
setupPath: string;
|
||||
expiresAt?: string;
|
||||
continuation?: string;
|
||||
|
||||
@@ -754,6 +754,8 @@ export interface ToolMcpGatewayToken {
|
||||
createdByUserId: string | null;
|
||||
/** Agent client delegation. Null with an issuing agent identifies an unbound legacy client. */
|
||||
responsibleUserId?: string | null;
|
||||
/** True for an agent-created client token whose value has not yet been revealed to its responsible person. */
|
||||
pendingReveal?: boolean;
|
||||
createdAt: Date | string;
|
||||
updatedAt: Date | string;
|
||||
}
|
||||
|
||||
@@ -1546,27 +1546,79 @@ describe("connection management with production default agents", () => {
|
||||
return { status: 201, body: { ...row, token } };
|
||||
}
|
||||
|
||||
it("never returns gateway client token plaintext to agent callers", async () => {
|
||||
it("lets agents create and revoke gateway client tokens without ever receiving a token value", async () => {
|
||||
const f = await fixture();
|
||||
const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "gateway-profile", name: "Gateway profile", entries: [] });
|
||||
expect(profile, JSON.stringify(profile)).toMatchObject({ status: 201 });
|
||||
const gateway = await f.request("POST", `/companies/${f.companyId}/tools/gateways`, { name: "Delegated gateway", slug: "delegated", profileId: profile.body.id });
|
||||
expect(gateway, JSON.stringify(gateway)).toMatchObject({ status: 201 });
|
||||
const body = { companyId: f.companyId, name: "Fixture client", clientLabel: "fixture", expiresAt: new Date(Date.now() + 3600000).toISOString() };
|
||||
const call = { tool: "call_api", callId: "gateway-client-secret", arguments: {
|
||||
operationId: "POST /api/tool-gateway/gateways/{gatewayId}/tokens", pathParams: { gatewayId: gateway.body.id }, body,
|
||||
} };
|
||||
const call = (callId: string, operationId: string, pathParams: Record<string, string>, payload: unknown) =>
|
||||
f.authority.execute({ tool: "call_api", callId, arguments: { operationId, pathParams, body: payload } }) as Promise<{ status: number; data: Record<string, unknown> }>;
|
||||
const tokenValue = /pcgw_[0-9a-f-]{36}\.[A-Za-z0-9_-]+/;
|
||||
const [company] = await server.db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, f.companyId));
|
||||
const tokensPath = `/${company.issuePrefix}/apps/gateways/${gateway.body.id}/tokens`;
|
||||
const denied = await f.authority.execute(call) as { status: number; data: Record<string, unknown> };
|
||||
expect(denied, JSON.stringify(denied)).toMatchObject({ status: 403, data: { reasonCode: "gateway_token_requires_person", tokensPath } });
|
||||
expect(JSON.stringify(denied)).not.toMatch(/pcgw_/);
|
||||
expect(await f.request("POST", `/tool-gateway/gateways/${gateway.body.id}/tokens`, body)).toMatchObject({ status: 403, body: { reasonCode: "gateway_token_requires_person" } });
|
||||
expect(await server.db.select().from(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.gatewayId, gateway.body.id))).toHaveLength(0);
|
||||
|
||||
const created = await call("gateway-client-create", "POST /api/tool-gateway/gateways/{gatewayId}/tokens", { gatewayId: gateway.body.id }, body);
|
||||
expect(created, JSON.stringify(created)).toMatchObject({ status: 201, data: { name: "Fixture client", pendingReveal: true, tokensPath,
|
||||
tokenPrefix: expect.stringMatching(/^pcgw_[0-9a-f]{8}$/), createdByAgentId: f.agentId, responsibleUserId: f.userId,
|
||||
requiredUserAction: { kind: "reveal_credential", userId: f.userId, setupPath: tokensPath, resource: { type: "gateway_token", id: created.data.id, companyId: f.companyId } } } });
|
||||
expect(created.data).not.toHaveProperty("token");
|
||||
expect(JSON.stringify(created)).not.toMatch(tokenValue);
|
||||
const [pending] = await server.db.select().from(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.id, created.data.id as string));
|
||||
expect(pending).toMatchObject({ pendingReveal: true, createdByAgentId: f.agentId, responsibleUserId: f.userId, subjectType: "gateway_client", revokedAt: null });
|
||||
// Agents cannot reveal any value.
|
||||
const agentReveal = await call("gateway-client-reveal", "POST /api/tool-gateway/gateway-tokens/{tokenId}/reveal", { tokenId: created.data.id as string }, { companyId: f.companyId });
|
||||
expect(agentReveal, JSON.stringify(agentReveal)).toMatchObject({ status: 403, data: { reasonCode: "gateway_token_requires_person" } });
|
||||
expect(JSON.stringify(agentReveal)).not.toMatch(tokenValue);
|
||||
expect(await f.request("POST", `/tool-gateway/gateway-tokens/${created.data.id}/reveal`, { companyId: f.companyId })).toMatchObject({ status: 403 });
|
||||
|
||||
// The responsible person reveals the value exactly once, and it works.
|
||||
const cookie = await server.humanSession(f.userId);
|
||||
const reveal = () => fetch(`${server.apiUrl}/api/tool-gateway/gateway-tokens/${created.data.id}/reveal`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify({ companyId: f.companyId }) });
|
||||
const revealed = await reveal();
|
||||
expect(revealed.status).toBe(200);
|
||||
const value = await revealed.json() as { token: string; pendingReveal: boolean; id: string };
|
||||
expect(value).toMatchObject({ id: created.data.id, pendingReveal: false, token: expect.stringMatching(new RegExp(`^pcgw_${created.data.id}\\.`)) });
|
||||
expect(await reveal()).toMatchObject({ status: 409 });
|
||||
const use = () => f.request("POST", `/mcp/gateways/${gateway.body.gatewayPublicId}`, { jsonrpc: "2.0", id: 1, method: "tools/list" }, value.token);
|
||||
expect(await use()).toMatchObject({ status: 200 });
|
||||
|
||||
// Agents keep delegated revoke.
|
||||
const revoked = await call("gateway-client-revoke", "POST /api/tool-gateway/gateway-tokens/{tokenId}/revoke", { tokenId: created.data.id as string }, { companyId: f.companyId });
|
||||
expect(revoked, JSON.stringify(revoked)).toMatchObject({ status: 200, data: { id: created.data.id, revokedAt: expect.any(String) } });
|
||||
expect((await use()).status).toBeGreaterThanOrEqual(400);
|
||||
|
||||
// A token revoked before reveal never gets a value.
|
||||
const unrevealed = await call("gateway-client-create-2", "POST /api/tool-gateway/gateways/{gatewayId}/tokens", { gatewayId: gateway.body.id }, { ...body, name: "Never revealed" });
|
||||
expect(unrevealed).toMatchObject({ status: 201 });
|
||||
expect(await call("gateway-client-revoke-2", "POST /api/tool-gateway/gateway-tokens/{tokenId}/revoke", { tokenId: unrevealed.data.id as string }, { companyId: f.companyId })).toMatchObject({ status: 200 });
|
||||
expect(await fetch(`${server.apiUrl}/api/tool-gateway/gateway-tokens/${unrevealed.data.id}/reveal`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify({ companyId: f.companyId }) }))
|
||||
.toMatchObject({ status: 409 });
|
||||
|
||||
// Person callers keep receiving the value from create.
|
||||
const personal = await fetch(`${server.apiUrl}/api/tool-gateway/gateways/${gateway.body.id}/tokens`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
expect(personal.status).toBe(201);
|
||||
expect(await personal.json()).toMatchObject({ token: expect.stringMatching(/^pcgw_/), createdByUserId: f.userId, createdByAgentId: null });
|
||||
expect(await personal.json()).toMatchObject({ token: expect.stringMatching(/^pcgw_/), createdByUserId: f.userId, createdByAgentId: null, pendingReveal: false });
|
||||
});
|
||||
|
||||
it("reveals an agent-created gateway token value only to its responsible person", async () => {
|
||||
const f = await fixture();
|
||||
const profile = await f.request("POST", `/companies/${f.companyId}/tools/profiles`, { profileKey: "gateway-profile", name: "Gateway profile", entries: [] });
|
||||
const gateway = await f.request("POST", `/companies/${f.companyId}/tools/gateways`, { name: "Delegated gateway", slug: "delegated", profileId: profile.body.id });
|
||||
const created = await f.request("POST", `/tool-gateway/gateways/${gateway.body.id}/tokens`, { companyId: f.companyId, name: "Fixture client", clientLabel: "fixture" });
|
||||
expect(created, JSON.stringify(created)).toMatchObject({ status: 201, body: { pendingReveal: true } });
|
||||
const otherId = `other-admin-${randomUUID()}`;
|
||||
await server.db.insert(authUsers).values({ id: otherId, name: "Other admin", email: `${otherId}@fixture.invalid`, emailVerified: true, createdAt: new Date(), updatedAt: new Date() });
|
||||
const access = accessService(server.db);
|
||||
await access.ensureMembership(f.companyId, "user", otherId, "owner", "active");
|
||||
await access.ensureRoleDefaultGrants(f.companyId, otherId, "owner", otherId);
|
||||
const cookie = await server.humanSession(otherId);
|
||||
const reveal = await fetch(`${server.apiUrl}/api/tool-gateway/gateway-tokens/${created.body.id}/reveal`, { method: "POST", headers: { cookie, origin: server.apiUrl, "Content-Type": "application/json" }, body: JSON.stringify({ companyId: f.companyId }) });
|
||||
expect(reveal.status).toBe(403);
|
||||
expect(await reveal.json()).toMatchObject({ reasonCode: "gateway_token_reveal_requires_responsible_person" });
|
||||
const [still] = await server.db.select().from(toolMcpGatewayTokens).where(eq(toolMcpGatewayTokens.id, created.body.id));
|
||||
expect(still).toMatchObject({ pendingReveal: true });
|
||||
});
|
||||
|
||||
it("keeps durable agent-issued gateway client tokens bound to the issuer and responsible user", async () => {
|
||||
|
||||
@@ -12906,7 +12906,7 @@ registerCurrentRoute({
|
||||
path: "/api/tool-gateway/gateways/{gatewayId}/tokens",
|
||||
tags: ["tool-gateway"],
|
||||
summary: "Create a named MCP gateway token",
|
||||
description: "Person-only. Returns the new token's plaintext exactly once to the creator. Agent callers receive 403 gateway_token_requires_person with the gateway's Tokens page path, where a person creates the token and hands it to the client.",
|
||||
description: "Creates a client token for an external MCP client. A person caller receives the token value once in the response. An agent with delegated connection management may create the token for its responsible person, but receives only metadata (id, tokenPrefix, expiresAt, pendingReveal: true) plus tokensPath, the gateway's Tokens page. The value does not exist until that person reveals it there; send the person to tokensPath as a Markdown link, and never ask for, accept, or repeat token values in chat. To rotate, create the replacement, then revoke the old token after the person confirms the client works with the new one.",
|
||||
body: toolGatewayCompanyBodySchema,
|
||||
responses: {
|
||||
201: r.ok(),
|
||||
@@ -12918,6 +12918,24 @@ registerCurrentRoute({
|
||||
},
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
"x-paperclip-transport": "rest",
|
||||
method: "post",
|
||||
path: "/api/tool-gateway/gateway-tokens/{tokenId}/reveal",
|
||||
tags: ["tool-gateway"],
|
||||
summary: "Reveal an agent-created MCP gateway token value",
|
||||
description: "Person-only. Generates the value of an agent-created client token that is pending reveal and returns it once. Only the token's responsible person can reveal it; a second reveal returns 409. Agent callers receive 403 gateway_token_requires_person.",
|
||||
body: toolGatewayCompanyQuerySchema.required({ companyId: true }),
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
400: r.badRequest,
|
||||
401: r.unauthorized,
|
||||
403: r.forbidden,
|
||||
404: r.notFound,
|
||||
409: r.conflict,
|
||||
},
|
||||
});
|
||||
|
||||
registerCurrentRoute({
|
||||
"x-paperclip-transport": "rest",
|
||||
"x-paperclip-connection-management": "connections:gateway",
|
||||
@@ -12925,6 +12943,7 @@ registerCurrentRoute({
|
||||
path: "/api/tool-gateway/gateway-tokens/{tokenId}/revoke",
|
||||
tags: ["tool-gateway"],
|
||||
summary: "Revoke a named MCP gateway token",
|
||||
description: "Revokes a gateway client token immediately; clients using it stop working. Agents with delegated connection management may revoke. When rotating, revoke the old token only after the replacement works.",
|
||||
body: toolGatewayCompanyQuerySchema.required({ companyId: true }),
|
||||
responses: {
|
||||
200: r.ok(),
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { filterConnectionManagementRows, connectionManagementGuard, connectionRequestContext, connectionRequestUserId } from "./connection-management-authz.js";
|
||||
import { filterConnectionManagementRows, connectionManagementGuard, connectionRequestContext, connectionRequestUserId, requiredConnectionUserAction } from "./connection-management-authz.js";
|
||||
import { Router, type Request, type Response } from "express";
|
||||
import { and, desc, eq, gte, ilike, inArray, lt, or, sql } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
@@ -362,6 +362,13 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
|
||||
throw forbidden(`Missing permission: ${permissionKey}`);
|
||||
}
|
||||
|
||||
/** The person-facing Tokens page of a gateway, where token values are revealed. */
|
||||
async function gatewayTokensPath(companyId: string, gatewayId: string) {
|
||||
const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId)).limit(1);
|
||||
if (!company) throw new ToolGatewayHttpError(404, "Company not found", "company_not_found");
|
||||
return `/${company.issuePrefix}/apps/gateways/${gatewayId}/tokens`;
|
||||
}
|
||||
|
||||
function assertBoardMutationAccess(req: import("express").Request, companyId: string) {
|
||||
assertBoard(req);
|
||||
assertCompanyAccess(req, companyId);
|
||||
@@ -437,32 +444,59 @@ export function toolGatewayRoutes(db: Db, toolGateway: ToolGatewayService) {
|
||||
return;
|
||||
}
|
||||
await assertBoardPermission(req, companyId, "tools:admin");
|
||||
if (req.actor.type === "agent") {
|
||||
// The plaintext is shown exactly once. An agent caller would receive it
|
||||
// in its tool result and could repeat it in chat, so only a person
|
||||
// creates client tokens, from the gateway's Tokens page.
|
||||
const [company] = await db.select({ issuePrefix: companies.issuePrefix }).from(companies).where(eq(companies.id, companyId)).limit(1);
|
||||
const tokensPath = company ? `/${company.issuePrefix}/apps/gateways/${req.params.gatewayId as string}/tokens` : null;
|
||||
throw new ToolGatewayHttpError(403,
|
||||
"Final: agents cannot create gateway client tokens, because the token is shown only once to whoever creates it. "
|
||||
+ `A person with gateway access must create the replacement on the gateway's Tokens page${tokensPath ? ` (${tokensPath})` : ""}, give it to the client, and revoke the old token after the client works. `
|
||||
+ "Do not ask for or repeat token values in chat.",
|
||||
"gateway_token_requires_person",
|
||||
tokensPath ? { tokensPath } : {});
|
||||
}
|
||||
const parsed = createToolMcpGatewayTokenSchema.safeParse(req.body ?? {});
|
||||
if (!parsed.success) {
|
||||
res.status(422).json({ error: "Invalid gateway token payload", issues: parsed.error.issues });
|
||||
return;
|
||||
}
|
||||
const actor = getActorInfo(req);
|
||||
res.status(201).json(await toolGateway.createNamedGatewayToken({
|
||||
const input = {
|
||||
companyId,
|
||||
gatewayId: (req.params.gatewayId as string),
|
||||
body: parsed.data,
|
||||
actor: { agentId: actor.agentId, userId: req.actor.type === "board" ? req.actor.userId : null },
|
||||
managementContext: connectionRequestContext(req),
|
||||
}));
|
||||
};
|
||||
if (req.actor.type !== "agent") {
|
||||
res.status(201).json(await toolGateway.createNamedGatewayToken(input));
|
||||
return;
|
||||
}
|
||||
// A token value is shown once, to whoever receives the create response.
|
||||
// An agent could repeat it in chat, so an agent-created client token has
|
||||
// no value until its responsible person reveals one on the Tokens page.
|
||||
// The validator admits only gateway_client subjects on this route.
|
||||
const tokensPath = await gatewayTokensPath(companyId, input.gatewayId);
|
||||
const created = await toolGateway.createPendingRevealGatewayToken(input);
|
||||
res.status(201).json({
|
||||
...created,
|
||||
tokensPath,
|
||||
...requiredConnectionUserAction(req, { type: "gateway_token", id: created.id, companyId }, tokensPath,
|
||||
`Reveal and copy the value of "${created.name}" on the gateway's Tokens page, then give it to the client.`, "reveal_credential"),
|
||||
nextStep: `Created "${created.name}" without a value. Its value is generated only when the person reveals it on the gateway's Tokens page (${tokensPath}); `
|
||||
+ "link that page as a Markdown link. Never ask for, accept, or repeat token values in chat. "
|
||||
+ "Revoke an old token only after the person confirms the client works with the new one.",
|
||||
});
|
||||
} catch (err) {
|
||||
sendGatewayError(res, err);
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/tool-gateway/gateway-tokens/:tokenId/reveal", async (req, res) => {
|
||||
try {
|
||||
assertBoardOrAgent(req);
|
||||
const companyId = typeof req.body?.companyId === "string" ? req.body.companyId : null;
|
||||
if (!companyId) {
|
||||
res.status(400).json({ error: "companyId is required" });
|
||||
return;
|
||||
}
|
||||
if (req.actor.type !== "board" || !req.actor.userId) {
|
||||
assertCompanyAccess(req, companyId);
|
||||
throw new ToolGatewayHttpError(403,
|
||||
"Final: only a person can reveal a gateway client token value, on the gateway's Tokens page. Do not ask for or repeat token values in chat.",
|
||||
"gateway_token_requires_person");
|
||||
}
|
||||
await assertBoardPermission(req, companyId, "tools:admin");
|
||||
res.json(await toolGateway.revealNamedGatewayToken({ companyId, tokenId: req.params.tokenId as string, userId: req.actor.userId }));
|
||||
} catch (err) {
|
||||
sendGatewayError(res, err);
|
||||
}
|
||||
|
||||
@@ -680,6 +680,14 @@ function generateGatewayToken(sessionId: string) {
|
||||
return `pcgt_${sessionId}.${randomBytes(32).toString("base64url")}`;
|
||||
}
|
||||
|
||||
interface NamedGatewayTokenInput {
|
||||
companyId: string;
|
||||
gatewayId: string;
|
||||
body: CreateToolMcpGatewayToken;
|
||||
actor?: { agentId?: string | null; userId?: string | null };
|
||||
managementContext?: ConnectionManagementContext;
|
||||
}
|
||||
|
||||
function generateNamedGatewayToken(tokenId: string) {
|
||||
return `pcgw_${tokenId}.${randomBytes(32).toString("base64url")}`;
|
||||
}
|
||||
@@ -7000,6 +7008,85 @@ export function createToolGatewayService(
|
||||
};
|
||||
}
|
||||
|
||||
async function insertNamedGatewayToken(input: NamedGatewayTokenInput, pendingReveal: boolean) {
|
||||
await input.managementContext?.reauthorize();
|
||||
const [gateway] = await db
|
||||
.select()
|
||||
.from(toolMcpGateways)
|
||||
.where(
|
||||
and(
|
||||
eq(toolMcpGateways.companyId, input.companyId),
|
||||
eq(toolMcpGateways.id, input.gatewayId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!gateway)
|
||||
throw new ToolGatewayHttpError(
|
||||
404,
|
||||
"MCP gateway not found",
|
||||
"gateway_not_found",
|
||||
);
|
||||
if (input.actor?.agentId && !input.managementContext && input.body.subjectType !== "heartbeat_run") {
|
||||
throw new ToolGatewayHttpError(403, "Agent client tokens require delegated authority", "gateway_token_run_context_invalid");
|
||||
}
|
||||
if (input.managementContext?.actor.type === "agent" && input.body.subjectType === "heartbeat_run" &&
|
||||
(!input.managementContext.actor.runId || input.body.subjectId !== input.managementContext.actor.runId)) {
|
||||
throw new ToolGatewayHttpError(403, "Heartbeat tokens must bind the authenticated run", "gateway_token_run_context_invalid");
|
||||
}
|
||||
const tokenId = randomUUID();
|
||||
const token = generateNamedGatewayToken(tokenId);
|
||||
const tokenPrefix = `pcgw_${tokenId.slice(0, 8)}`;
|
||||
const now = new Date();
|
||||
const [row] = await db.transaction(async tx => {
|
||||
if (input.body.expiresAt) await notifyDeliveryWork(tx, DELIVERY_QUEUES.gatewayCredentialExpiry);
|
||||
return tx
|
||||
.insert(toolMcpGatewayTokens)
|
||||
.values({
|
||||
id: tokenId,
|
||||
companyId: input.companyId,
|
||||
gatewayId: input.gatewayId,
|
||||
name: input.body.name,
|
||||
// A pending token's stored hash is of a value nobody receives.
|
||||
tokenHash: hashGatewayToken(token),
|
||||
tokenPrefix,
|
||||
pendingReveal,
|
||||
subjectType: input.body.subjectType ?? "gateway_client",
|
||||
subjectId: input.body.subjectId ?? null,
|
||||
clientLabel: input.body.clientLabel,
|
||||
ownerNote: input.body.ownerNote,
|
||||
allowedActions: input.body.allowedActions ?? [
|
||||
"tools/list",
|
||||
"tools/call",
|
||||
"resources/list",
|
||||
"resources/read",
|
||||
"prompts/list",
|
||||
"prompts/get",
|
||||
],
|
||||
expiresAt: input.body.expiresAt ?? null,
|
||||
expiryOverrideReason: input.body.expiryOverrideReason ?? null,
|
||||
expiryOverrideByAgentId:
|
||||
input.actor?.agentId && input.body.expiryOverrideReason
|
||||
? input.actor.agentId
|
||||
: null,
|
||||
expiryOverrideByUserId:
|
||||
input.actor?.userId && input.body.expiryOverrideReason
|
||||
? input.actor.userId
|
||||
: null,
|
||||
expiryOverrideAt: input.body.expiryOverrideReason ? now : null,
|
||||
responsibleUserId: input.managementContext?.actor.type === "agent" ? input.managementContext.responsibleUserId : null,
|
||||
createdByAgentId: input.actor?.agentId ?? null,
|
||||
createdByUserId: input.actor?.userId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
});
|
||||
if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit,
|
||||
action: "tool_gateway.token_created", entityType: "tool_mcp_gateway", entityId: input.gatewayId,
|
||||
details: { tokenId: row.id, subjectType: row.subjectType, allowedActions: row.allowedActions, ...(pendingReveal ? { pendingReveal } : {}) } });
|
||||
return { row, token };
|
||||
}
|
||||
|
||||
function toGatewayToken(
|
||||
row: typeof toolMcpGatewayTokens.$inferSelect,
|
||||
): ToolMcpGatewayToken {
|
||||
@@ -7024,6 +7111,7 @@ export function createToolGatewayService(
|
||||
createdByAgentId: row.createdByAgentId,
|
||||
createdByUserId: row.createdByUserId,
|
||||
responsibleUserId: row.responsibleUserId,
|
||||
pendingReveal: row.pendingReveal,
|
||||
createdAt: row.createdAt,
|
||||
updatedAt: row.updatedAt,
|
||||
};
|
||||
@@ -7446,6 +7534,15 @@ export function createToolGatewayService(
|
||||
clientMetadata,
|
||||
});
|
||||
}
|
||||
if (row.token.pendingReveal) {
|
||||
await recordNamedGatewayAuthFailure({
|
||||
gatewayId: input.gatewayId,
|
||||
gatewayPublicId: input.gatewayPublicId,
|
||||
bearerToken,
|
||||
reasonCode: "gateway_token_invalid",
|
||||
clientMetadata,
|
||||
});
|
||||
}
|
||||
if (row.token.expiresAt && row.token.expiresAt.getTime() <= Date.now()) {
|
||||
await recordNamedGatewayAuthFailure({
|
||||
gatewayId: input.gatewayId,
|
||||
@@ -9341,89 +9438,57 @@ export function createToolGatewayService(
|
||||
return getGatewayWithTokens(input.companyId, updated.id);
|
||||
},
|
||||
|
||||
async createNamedGatewayToken(input: {
|
||||
async createNamedGatewayToken(input: NamedGatewayTokenInput): Promise<ToolMcpGatewayTokenCreated> {
|
||||
const { row, token } = await insertNamedGatewayToken(input, false);
|
||||
return { ...toGatewayToken(row), token };
|
||||
},
|
||||
|
||||
/**
|
||||
* Saves an agent-created client token without a usable value. The value is
|
||||
* generated only when the responsible person reveals it, so the creating
|
||||
* agent never receives it in a tool result.
|
||||
*/
|
||||
async createPendingRevealGatewayToken(input: NamedGatewayTokenInput): Promise<ToolMcpGatewayToken> {
|
||||
return toGatewayToken((await insertNamedGatewayToken(input, true)).row);
|
||||
},
|
||||
|
||||
async revealNamedGatewayToken(input: {
|
||||
companyId: string;
|
||||
gatewayId: string;
|
||||
body: CreateToolMcpGatewayToken;
|
||||
actor?: { agentId?: string | null; userId?: string | null };
|
||||
tokenId: string;
|
||||
userId: string;
|
||||
managementContext?: ConnectionManagementContext;
|
||||
}): Promise<ToolMcpGatewayTokenCreated> {
|
||||
await input.managementContext?.reauthorize();
|
||||
const [gateway] = await db
|
||||
.select()
|
||||
.from(toolMcpGateways)
|
||||
.where(
|
||||
and(
|
||||
eq(toolMcpGateways.companyId, input.companyId),
|
||||
eq(toolMcpGateways.id, input.gatewayId),
|
||||
),
|
||||
)
|
||||
.limit(1);
|
||||
if (!gateway)
|
||||
throw new ToolGatewayHttpError(
|
||||
404,
|
||||
"MCP gateway not found",
|
||||
"gateway_not_found",
|
||||
);
|
||||
if (input.actor?.agentId && !input.managementContext && input.body.subjectType !== "heartbeat_run") {
|
||||
throw new ToolGatewayHttpError(403, "Agent client tokens require delegated authority", "gateway_token_run_context_invalid");
|
||||
const [existing] = await db.select().from(toolMcpGatewayTokens).where(and(
|
||||
eq(toolMcpGatewayTokens.companyId, input.companyId),
|
||||
eq(toolMcpGatewayTokens.id, input.tokenId),
|
||||
)).limit(1);
|
||||
if (!existing) throw new ToolGatewayHttpError(404, "MCP gateway token not found", "gateway_token_not_found");
|
||||
if (!existing.pendingReveal) throw new ToolGatewayHttpError(409, "This token's value was already shown once. Issue a new token to get a new value.", "gateway_token_already_revealed");
|
||||
if (existing.revokedAt) throw new ToolGatewayHttpError(409, "This token was revoked before its value was revealed.", "gateway_token_revoked");
|
||||
if (existing.responsibleUserId && existing.responsibleUserId !== input.userId) {
|
||||
throw new ToolGatewayHttpError(403, "Only the person this agent-created token acts for can reveal its value.", "gateway_token_reveal_requires_responsible_person");
|
||||
}
|
||||
if (input.managementContext?.actor.type === "agent" && input.body.subjectType === "heartbeat_run" &&
|
||||
(!input.managementContext.actor.runId || input.body.subjectId !== input.managementContext.actor.runId)) {
|
||||
throw new ToolGatewayHttpError(403, "Heartbeat tokens must bind the authenticated run", "gateway_token_run_context_invalid");
|
||||
}
|
||||
const tokenId = randomUUID();
|
||||
const token = generateNamedGatewayToken(tokenId);
|
||||
const tokenPrefix = `pcgw_${tokenId.slice(0, 8)}`;
|
||||
const token = generateNamedGatewayToken(existing.id);
|
||||
const now = new Date();
|
||||
const [row] = await db.transaction(async tx => {
|
||||
if (input.body.expiresAt) await notifyDeliveryWork(tx, DELIVERY_QUEUES.gatewayCredentialExpiry);
|
||||
return tx
|
||||
.insert(toolMcpGatewayTokens)
|
||||
.values({
|
||||
id: tokenId,
|
||||
companyId: input.companyId,
|
||||
gatewayId: input.gatewayId,
|
||||
name: input.body.name,
|
||||
tokenHash: hashGatewayToken(token),
|
||||
tokenPrefix,
|
||||
subjectType: input.body.subjectType ?? "gateway_client",
|
||||
subjectId: input.body.subjectId ?? null,
|
||||
clientLabel: input.body.clientLabel,
|
||||
ownerNote: input.body.ownerNote,
|
||||
allowedActions: input.body.allowedActions ?? [
|
||||
"tools/list",
|
||||
"tools/call",
|
||||
"resources/list",
|
||||
"resources/read",
|
||||
"prompts/list",
|
||||
"prompts/get",
|
||||
],
|
||||
expiresAt: input.body.expiresAt ?? null,
|
||||
expiryOverrideReason: input.body.expiryOverrideReason ?? null,
|
||||
expiryOverrideByAgentId:
|
||||
input.actor?.agentId && input.body.expiryOverrideReason
|
||||
? input.actor.agentId
|
||||
: null,
|
||||
expiryOverrideByUserId:
|
||||
input.actor?.userId && input.body.expiryOverrideReason
|
||||
? input.actor.userId
|
||||
: null,
|
||||
expiryOverrideAt: input.body.expiryOverrideReason ? now : null,
|
||||
responsibleUserId: input.managementContext?.actor.type === "agent" ? input.managementContext.responsibleUserId : null,
|
||||
createdByAgentId: input.actor?.agentId ?? null,
|
||||
createdByUserId: input.actor?.userId ?? null,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
})
|
||||
.returning();
|
||||
});
|
||||
// Conditional on pendingReveal so concurrent reveals produce one value.
|
||||
const [row] = await db.update(toolMcpGatewayTokens)
|
||||
.set({ tokenHash: hashGatewayToken(token), pendingReveal: false, updatedAt: now })
|
||||
.where(and(
|
||||
eq(toolMcpGatewayTokens.companyId, input.companyId),
|
||||
eq(toolMcpGatewayTokens.id, input.tokenId),
|
||||
eq(toolMcpGatewayTokens.pendingReveal, true),
|
||||
isNull(toolMcpGatewayTokens.revokedAt),
|
||||
))
|
||||
.returning();
|
||||
if (!row) throw new ToolGatewayHttpError(409, "This token's value was already shown once. Issue a new token to get a new value.", "gateway_token_already_revealed");
|
||||
if (input.managementContext) await logActivity(db, { companyId: input.companyId, ...input.managementContext.audit,
|
||||
action: "tool_gateway.token_created", entityType: "tool_mcp_gateway", entityId: input.gatewayId,
|
||||
details: { tokenId: row.id, subjectType: row.subjectType, allowedActions: row.allowedActions } });
|
||||
action: "tool_gateway.token_revealed", entityType: "tool_mcp_gateway", entityId: row.gatewayId,
|
||||
details: { tokenId: row.id } });
|
||||
return { ...toGatewayToken(row), token };
|
||||
},
|
||||
|
||||
|
||||
async revokeNamedGatewayToken(input: {
|
||||
companyId: string;
|
||||
tokenId: string;
|
||||
|
||||
@@ -585,6 +585,8 @@ export const toolsApi = {
|
||||
api.patch<ToolMcpGatewayWithTokens>(`/tool-gateway/gateways/${gatewayId}`, { ...input, companyId }),
|
||||
createGatewayToken: (companyId: string, gatewayId: string, input: CreateGatewayTokenInput) =>
|
||||
api.post<ToolMcpGatewayTokenCreated>(`/tool-gateway/gateways/${gatewayId}/tokens`, { ...input, companyId }),
|
||||
revealGatewayToken: (companyId: string, tokenId: string) =>
|
||||
api.post<ToolMcpGatewayTokenCreated>(`/tool-gateway/gateway-tokens/${tokenId}/reveal`, { companyId }),
|
||||
revokeGatewayToken: (companyId: string, tokenId: string) =>
|
||||
api.post<ToolMcpGatewayToken>(`/tool-gateway/gateway-tokens/${tokenId}/revoke`, { companyId }),
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ import { humanizeConnectionDisplayName, isToolConnectionAttentionHealth } from "
|
||||
/** Days before expiry at which we surface a token as "Expiring". */
|
||||
export const TOKEN_EXPIRING_WINDOW_DAYS = 14;
|
||||
|
||||
export type TokenStatus = "active" | "expiring" | "expired" | "revoked";
|
||||
export type TokenStatus = "pending" | "active" | "expiring" | "expired" | "revoked";
|
||||
|
||||
export function toDate(value: Date | string | null | undefined): Date | null {
|
||||
if (!value) return null;
|
||||
@@ -25,10 +25,11 @@ export function maskedTokenLabel(token: Pick<ToolMcpGatewayToken, "tokenPrefix">
|
||||
}
|
||||
|
||||
export function tokenStatus(
|
||||
token: Pick<ToolMcpGatewayToken, "revokedAt" | "expiresAt">,
|
||||
token: Pick<ToolMcpGatewayToken, "revokedAt" | "expiresAt" | "pendingReveal">,
|
||||
now: number = Date.now(),
|
||||
): TokenStatus {
|
||||
if (token.revokedAt) return "revoked";
|
||||
if (token.pendingReveal) return "pending";
|
||||
const expires = toDate(token.expiresAt);
|
||||
if (!expires) return "active";
|
||||
const diffMs = expires.getTime() - now;
|
||||
@@ -38,6 +39,7 @@ export function tokenStatus(
|
||||
}
|
||||
|
||||
export const TOKEN_STATUS_LABEL: Record<TokenStatus, string> = {
|
||||
pending: "Not revealed",
|
||||
active: "Active",
|
||||
expiring: "Expiring",
|
||||
expired: "Expired",
|
||||
|
||||
@@ -10,12 +10,14 @@ import { TokensPanel } from "./TokensPanel";
|
||||
|
||||
const createGatewayTokenMock = vi.hoisted(() => vi.fn());
|
||||
const revokeGatewayTokenMock = vi.hoisted(() => vi.fn());
|
||||
const revealGatewayTokenMock = vi.hoisted(() => vi.fn());
|
||||
const pushToastMock = vi.hoisted(() => vi.fn());
|
||||
|
||||
vi.mock("@/api/tools", () => ({
|
||||
toolsApi: {
|
||||
createGatewayToken: (...args: unknown[]) => createGatewayTokenMock(...args),
|
||||
revokeGatewayToken: (...args: unknown[]) => revokeGatewayTokenMock(...args),
|
||||
revealGatewayToken: (...args: unknown[]) => revealGatewayTokenMock(...args),
|
||||
},
|
||||
}));
|
||||
|
||||
@@ -209,6 +211,22 @@ describe("TokensPanel", () => {
|
||||
expect(writeText).toHaveBeenCalledWith("pcgw_live_FULLSECRETVALUE");
|
||||
});
|
||||
|
||||
it("lets the person reveal an agent-created token value once, outside the folded history", async () => {
|
||||
const pending = token({ id: "agent-token", name: "Replacement client", createdByAgentId: "agent-1", createdByUserId: null, responsibleUserId: "user-1", pendingReveal: true });
|
||||
revealGatewayTokenMock.mockResolvedValue({ ...pending, pendingReveal: false, token: "pcgw_live_REVEALEDVALUE" });
|
||||
await render(<TokensPanel companyId="company-1" gateway={gateway({ tokens: [pending] })} />);
|
||||
|
||||
expect(container.textContent).toContain("Waiting for you to reveal");
|
||||
expect(container.textContent).toContain("Replacement client");
|
||||
expect(container.textContent).not.toContain("pcgw_live_REVEALEDVALUE");
|
||||
|
||||
await clickButton("Reveal token");
|
||||
await flushReact();
|
||||
expect(revealGatewayTokenMock).toHaveBeenCalledWith("company-1", "agent-token");
|
||||
expect(container.textContent).toContain("New token — copy now");
|
||||
expect(container.textContent).toContain("pcgw_live_REVEALEDVALUE");
|
||||
});
|
||||
|
||||
it("gates revoke behind a typed confirmation matching the token name", async () => {
|
||||
revokeGatewayTokenMock.mockResolvedValue(token({ revokedAt: "2026-06-16T12:00:00.000Z" }));
|
||||
await render(<TokensPanel companyId="company-1" gateway={gateway({ tokens: [token()] })} />);
|
||||
|
||||
@@ -38,6 +38,7 @@ function defaultExpiry(): string {
|
||||
}
|
||||
|
||||
const STATUS_CLASS: Record<TokenStatus, string> = {
|
||||
pending: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
active: "border-emerald-500/40 bg-emerald-500/10 text-emerald-700 dark:text-emerald-300",
|
||||
expiring: "border-amber-500/40 bg-amber-500/10 text-amber-700 dark:text-amber-300",
|
||||
expired: "border-border bg-muted text-muted-foreground",
|
||||
@@ -102,6 +103,7 @@ export function TokensPanel({
|
||||
() => [...gateway.tokens].sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()),
|
||||
[gateway.tokens],
|
||||
);
|
||||
const pendingTokens = useMemo(() => tokens.filter((token) => tokenStatus(token) === "pending"), [tokens]);
|
||||
const pageCount = Math.max(1, Math.ceil(tokens.length / TOKEN_PAGE_SIZE));
|
||||
const visibleTokens = tokens.slice((historyPage - 1) * TOKEN_PAGE_SIZE, historyPage * TOKEN_PAGE_SIZE);
|
||||
|
||||
@@ -141,6 +143,28 @@ export function TokensPanel({
|
||||
}),
|
||||
});
|
||||
|
||||
// Agent-created tokens get their value only when the person reveals them here.
|
||||
const revealMutation = useMutation({
|
||||
mutationFn: (tokenId: string) => toolsApi.revealGatewayToken(companyId, tokenId),
|
||||
onSuccess: async (token) => {
|
||||
setCreated(token);
|
||||
setRevealed(true);
|
||||
pushToast({
|
||||
title: "Token revealed",
|
||||
body: "Copy it now — you won’t see the full value again after leaving this page.",
|
||||
tone: "success",
|
||||
});
|
||||
onTokenCreated?.(token);
|
||||
await invalidate();
|
||||
},
|
||||
onError: (error) =>
|
||||
pushToast({
|
||||
title: "Token was not revealed",
|
||||
body: describeError(error).body,
|
||||
tone: "error",
|
||||
}),
|
||||
});
|
||||
|
||||
const revokeMutation = useMutation({
|
||||
mutationFn: (tokenId: string) => toolsApi.revokeGatewayToken(companyId, tokenId),
|
||||
onSuccess: async (token) => {
|
||||
@@ -237,6 +261,35 @@ export function TokensPanel({
|
||||
</form>
|
||||
) : null}
|
||||
|
||||
{pendingTokens.length > 0 ? (
|
||||
<section className="space-y-2 border-y border-border py-4" aria-label="Tokens waiting to be revealed">
|
||||
<div>
|
||||
<div className="text-sm font-semibold text-foreground">Waiting for you to reveal</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
An agent created these tokens for you. A value is generated when you reveal it and is shown only once.
|
||||
</div>
|
||||
</div>
|
||||
{pendingTokens.map((token) => (
|
||||
<div key={token.id} className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<div className="truncate font-medium text-foreground">{token.name}</div>
|
||||
<div className="text-xs text-muted-foreground">
|
||||
{maskedTokenLabel(token)} · <ExpiryValue token={token} />
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
size="sm"
|
||||
disabled={revealMutation.isPending}
|
||||
onClick={() => revealMutation.mutate(token.id)}
|
||||
aria-label={`Reveal token ${token.name}`}
|
||||
>
|
||||
Reveal token
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
{created ? (
|
||||
<div className="space-y-2 border-y border-border py-4">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
|
||||
Reference in new issue
Block a user