diff --git a/cli/src/__tests__/token.test.ts b/cli/src/__tests__/token.test.ts index 5225a856b9e..918d33f5501 100644 --- a/cli/src/__tests__/token.test.ts +++ b/cli/src/__tests__/token.test.ts @@ -1,5 +1,6 @@ import { Command } from "commander"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { BOARD_API_KEY_SCOPE_PRESETS } from "@paperclipai/shared"; import { registerTokenCommands } from "../commands/client/token.js"; const COMPANY_ID = "22222222-2222-4222-8222-222222222222"; @@ -326,4 +327,36 @@ describe("token commands", () => { expect(fetchMock.mock.calls[1]?.[0]).toBe("http://localhost:3100/api/board-api-keys/board-key-1"); expect(fetchMock.mock.calls[1]?.[1]?.method).toBe("DELETE"); }); + + it("recognizes a preset scope when its valid permissions use a different order", async () => { + const permissions = [...BOARD_API_KEY_SCOPE_PRESETS.read_only.permissions].reverse(); + const fetchMock = vi.fn().mockResolvedValueOnce(new Response(JSON.stringify([{ + id: "board-key-1", + name: "reader", + tokenPrefix: "pcp_board_123456789abc", + scopeConfig: { + version: 1, + kind: "scoped", + companyIds: [COMPANY_ID], + permissions, + instanceCapabilities: [], + }, + legacyUnrestricted: false, + status: "active", + createdAt: "2026-05-23T00:00:00.000Z", + lastUsedAt: null, + expiresAt: "2026-06-22T00:00:00.000Z", + revokedAt: null, + }]), { status: 200 })); + vi.stubGlobal("fetch", fetchMock); + const log = vi.spyOn(console, "log").mockImplementation(() => {}); + + await createProgram().parseAsync([ + "token", "board", "list", + "--api-base", "http://localhost:3100", + "--api-key", "board-token", + ], { from: "user" }); + + expect(String(log.mock.calls[0]?.[0])).toContain("scope=read_only:1_companies"); + }); }); diff --git a/cli/src/commands/client/token.ts b/cli/src/commands/client/token.ts index 04c0690a49b..205cbf3730f 100644 --- a/cli/src/commands/client/token.ts +++ b/cli/src/commands/client/token.ts @@ -340,10 +340,10 @@ function summarizeBoardKeyScope(scopeConfig: BoardApiKeyScopeConfig | null): str [BoardApiKeyScopePreset, (typeof BOARD_API_KEY_SCOPE_PRESETS)[BoardApiKeyScopePreset]] >).find(([, preset]) => preset.permissions.length === scopeConfig.permissions.length - && preset.permissions.every((permission, index) => permission === scopeConfig.permissions[index]) + && preset.permissions.every((permission) => scopeConfig.permissions.includes(permission)) && preset.instanceCapabilities.length === scopeConfig.instanceCapabilities.length && preset.instanceCapabilities.every( - (capability, index) => capability === scopeConfig.instanceCapabilities[index], + (capability) => scopeConfig.instanceCapabilities.includes(capability), )); const label = matchingPreset?.[0] ?? "custom"; return `${label}:${scopeConfig.companyIds.length}_companies`; diff --git a/doc/CLI.md b/doc/CLI.md index f01291b2994..8ddc38bd185 100644 --- a/doc/CLI.md +++ b/doc/CLI.md @@ -508,11 +508,28 @@ npx paperclipai token agent list --company-id --agent --agent ``` -Named board API keys use the board authorization model, support revocation and expiration metadata, and are audited server-side. +Named board API keys use the board authorization model. Each new key requires a +strict scope and pins at least one company. The default preset is +`company_automation`. Select `read_only` or `full_instance_admin` with +`--scope-preset`. Add more company pins by repeating `--scope-company-id`. + +Use `--scope-config-json` for a custom scope. The JSON must pass the same strict +shared validator as the REST API. It cannot be combined with a preset or an +additional company flag, and it must include the `--company-id` value. + +Keys expire after 30 days by default. Use `--ttl-days` or `--expires-at` for a +different finite expiry. Use `--never-expires` only when a non-expiring key is +required. These three expiry options are mutually exclusive. The service returns +the plaintext token only from the successful create response. List output contains +safe metadata only, and revoke targets only a key owned by the current board user. ```sh npx paperclipai token board create --company-id --name external-admin -npx paperclipai token board create --name short-lived --ttl-days 7 +npx paperclipai token board create --company-id --name reader --scope-preset read_only +npx paperclipai token board create --company-id --name multi-company --scope-company-id +npx paperclipai token board create --company-id --name short-lived --ttl-days 7 +npx paperclipai token board create --company-id --name long-lived --never-expires +npx paperclipai token board create --company-id --name custom --scope-config-json '' npx paperclipai token board list npx paperclipai token board revoke ```