From 431b29fd61caef24c66797198840d4c683bf9439 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 19 Sep 2026 18:39:24 +0200 Subject: [PATCH 1/9] feat(calls): read and change team call data retention Add `calls retention get` and `calls retention set` for GET/PUT /platform/call-retention. The pinned SDK has no call-retention resource, so a CLI adapter (createCallRetentionClient) sends the requests through the organization client's raw transport. `set` requires --expected-revision, reads the setting first, fails with state_conflict (exit 4) on a stale revision, and asks for confirmation (or --yes; exit 2 confirmation_required without a terminal) before a shorter period deletes stored call data. 403 and 409 responses get actionable messages. --- README.md | 22 +++ docs/command-research/calls.md | 15 ++ package.json | 3 + src/commands/calls/retention/get.ts | 23 +++ src/commands/calls/retention/set.ts | 69 +++++++ src/sdk/client.ts | 52 ++++++ src/services/calls.ts | 173 ++++++++++++++++++ test/commands/calls.test.ts | 257 +++++++++++++++++++++++++++ test/services/call-retention.test.ts | 128 +++++++++++++ 9 files changed, 742 insertions(+) create mode 100644 src/commands/calls/retention/get.ts create mode 100644 src/commands/calls/retention/set.ts create mode 100644 test/services/call-retention.test.ts diff --git a/README.md b/README.md index 3d206bde..8105677b 100644 --- a/README.md +++ b/README.md @@ -466,6 +466,10 @@ polymorfa calls settings set --session support --calls-enabled=false \ --expected-revision 3 polymorfa calls settings set --session support \ --inbound-route sip_trunk --sip-trunk 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 + +polymorfa calls retention get +polymorfa calls retention set --policy compliance --expected-revision 0 +polymorfa calls retention set --policy custom --days 14 --expected-revision 1 --yes ``` Every inbound call rings until a participant answers or declines it. The CLI @@ -512,6 +516,24 @@ never answers or declines on its own. change only if the settings still have that revision; if someone changed them first, the command exits with code 4 (`state_conflict`) and changes nothing. +- **Call data retention.** `calls retention` reads and changes how long + Polymorfa keeps your team's call data. It uses the Platform API; client + tokens fail with the Platform credential error. `--policy short`, + `standard`, `extended`, or `compliance` keeps call data for 7, 30, 90, or + 365 days; `--policy custom --days ` keeps it for 1 to 2555 days. Teams + without a saved setting use `extended` (90 days) at revision `0`. Polymorfa + deletes call data within 24 hours after it becomes older than the period. + `calls retention get` prints the policy, period, the kinds of call data it + applies to, and the `revision`. `calls retention set` requires + `--expected-revision `: it reads the setting first and changes it only if + it still has that revision; otherwise it exits with code 4 + (`state_conflict`) and changes nothing, so read the setting again and retry. + A shorter period also applies to call data already stored, and deleted call + data cannot be recovered. The command asks you to type `yes` before it + shortens the period; `--yes` confirms without a prompt, and without a + terminal the command exits with code 2 (`confirmation_required`) unless you + pass `--yes`. Changing retention requires a team API key with the + `sessions:manage` scope; other keys exit with code 3. `calls watch` streams inbound calls and their progress: `incoming`, `claim`, `state`, `participant_joined`, `participant_state`, `participant_left`, and diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index 45f0844d..1769c648 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -50,6 +50,8 @@ calls | `calls invite` | `POST /messaging/voip/calls/{id}/participants` | `voip.addParticipant` | | `calls settings get` | `GET /platform/sessions/{session}/call-settings` | `voip.retrieveCallSettings` | | `calls settings set` | `PUT /platform/sessions/{session}/call-settings` | `voip.updateCallSettings` | +| `calls retention get` | `GET /platform/call-retention` | raw request (CLI adapter) | +| `calls retention set` | `PUT /platform/call-retention` | raw request (CLI adapter) | | `sip-trunk list` | `GET /platform/sip-trunks` | `sipTrunks.list` | | `sip-trunk view` | `GET /platform/sip-trunks/{id}` | `sipTrunks.retrieve` | | `sip-trunk create` | `POST /platform/sip-trunks` | `sipTrunks.create` | @@ -80,6 +82,19 @@ internal SDK module. Messaging client; the CLI builds that client from the Platform environment. `calls settings set --expected-revision` sends `expectedRevision`; a stale revision returns 409 `state_conflict`, which exits with code 4. +- `calls retention` resolves Platform credentials. The pinned SDK has no + call-retention resource, so `createCallRetentionClient` in `src/sdk/client.ts` + sends `GET` and `PUT /platform/call-retention` through the organization + client's raw transport; switch it to the SDK's `callRetention` methods once + a release that includes them is pinned. The API lets team API keys and + project tokens read the setting and only team API keys change it; the CLI + replaces the API's 403 text with that rule and keeps the API message. +- `calls retention set` requires `--expected-revision`. It reads the setting, + fails locally with `state_conflict` (exit 4) when the revision differs, asks + for confirmation when the new period is shorter (`--yes`, or exit 2 + `confirmation_required` without a terminal), then sends `policy`, + `retentionDays` (only when `--days` is given) and `expectedRevision`. A 409 + from the `PUT` also exits 4 and tells the user to read the setting again. - Sockets carry the credential in their first frame. The CLI requires an HTTPS base URL, or HTTP on a loopback host, before opening any Calls connection. diff --git a/package.json b/package.json index 55ae2b8b..b5bcb77b 100644 --- a/package.json +++ b/package.json @@ -101,6 +101,9 @@ "calls": { "description": "Place, answer, join, decline, leave, end, and watch calls, and carry call audio." }, + "calls:retention": { + "description": "Inspect and change how long Polymorfa keeps your team's call data." + }, "calls:settings": { "description": "Inspect and replace session call settings." }, diff --git a/src/commands/calls/retention/get.ts b/src/commands/calls/retention/get.ts new file mode 100644 index 00000000..3bf5d33d --- /dev/null +++ b/src/commands/calls/retention/get.ts @@ -0,0 +1,23 @@ +import {BaseCommand} from '../../../base-command.js' +import {createCallRetentionClient} from '../../../sdk/client.js' +import { + renderCallRetention, + viewCallRetention, +} from '../../../services/calls.js' + +export default class CallsRetentionGet extends BaseCommand { + static override description = + 'Read how long Polymorfa keeps your team’s call data from the Platform API. Polymorfa deletes call data within 24 hours after it becomes older than the retention period. Teams without a saved setting return the default (extended, 90 days) at revision 0. Pass the revision to `calls retention set --expected-revision` so a change cannot overwrite another one. Requires a server API key with the sessions:read scope; client tokens are refused.' + + static override examples = ['<%= config.bin %> calls retention get'] + + static override summary = 'Read team call data retention.' + + async run(): Promise { + const {flags} = await this.parse(CallsRetentionGet) + const result = await viewCallRetention( + createCallRetentionClient(await this.platformEnvironment(flags)), + ) + this.writeValue(result, renderCallRetention(result.data), flags) + } +} diff --git a/src/commands/calls/retention/set.ts b/src/commands/calls/retention/set.ts new file mode 100644 index 00000000..86cd4aaf --- /dev/null +++ b/src/commands/calls/retention/set.ts @@ -0,0 +1,69 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {confirmDangerous} from '../../../input/confirmation.js' +import { + createCallRetentionClient, + type CallRetentionPolicy, +} from '../../../sdk/client.js' +import { + CALL_RETENTION_POLICIES, + MAX_CALL_RETENTION_DAYS, + MIN_CALL_RETENTION_DAYS, + renderCallRetention, + updateCallRetention, +} from '../../../services/calls.js' + +export default class CallsRetentionSet extends BaseCommand { + static override description = `Change how long Polymorfa keeps your team’s call data through the Platform API. --policy short, standard, extended, or compliance keeps call data for 7, 30, 90, or 365 days; --policy custom keeps it for --days (${MIN_CALL_RETENTION_DAYS} to ${MAX_CALL_RETENTION_DAYS}). Polymorfa deletes call data within 24 hours after it becomes older than the period. A shorter period also applies to call data already stored, and deleted call data cannot be recovered, so the command asks you to confirm a shorter period; pass --yes to confirm without a prompt (required when standard input or error is not a terminal). --expected-revision is required: the command reads the setting first and changes it only if it still has that revision (see \`calls retention get\`); otherwise it fails with state_conflict (exit code 4) and changes nothing. Requires a team API key with the sessions:manage scope; project keys fail with exit code 3.` + + static override examples = [ + '<%= config.bin %> calls retention set --policy compliance --expected-revision 0', + '<%= config.bin %> calls retention set --policy custom --days 180 --expected-revision 2', + '<%= config.bin %> calls retention set --policy short --expected-revision 3 --yes', + ] + + static override flags = { + days: Flags.integer({ + description: `Days to keep call data (${MIN_CALL_RETENTION_DAYS} to ${MAX_CALL_RETENTION_DAYS}). Required with --policy custom; with a named policy, omit it or pass that policy’s period.`, + max: MAX_CALL_RETENTION_DAYS, + min: MIN_CALL_RETENTION_DAYS, + }), + 'expected-revision': Flags.integer({ + description: + 'Apply the change only if the setting still has this revision (0 while your team uses the default).', + min: 0, + required: true, + }), + policy: Flags.string({ + description: + 'Retention policy: short (7 days), standard (30), extended (90), compliance (365), or custom (--days).', + options: [...CALL_RETENTION_POLICIES], + required: true, + }), + yes: Flags.boolean({ + description: + 'Confirm a shorter period, which permanently deletes stored call data older than it, without prompting.', + }), + } + + static override summary = 'Change team call data retention.' + + async run(): Promise { + const {flags} = await this.parse(CallsRetentionSet) + const result = await updateCallRetention( + createCallRetentionClient(await this.platformEnvironment(flags)), + { + expectedRevision: flags['expected-revision'], + policy: flags.policy as CallRetentionPolicy, + retentionDays: flags.days, + }, + (current, retentionDays) => + confirmDangerous({ + prompt: `Shorten call data retention from ${current.retentionDays} to ${retentionDays} days? Polymorfa deletes stored call data older than ${retentionDays} days within 24 hours, and it cannot be recovered.`, + yes: flags.yes, + }), + ) + this.writeValue(result, renderCallRetention(result.data), flags) + } +} diff --git a/src/sdk/client.ts b/src/sdk/client.ts index 4cd16195..7daf2fe0 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -2,6 +2,7 @@ import { Client, MessagingClient, PolymorfaConfigurationError, + type ApiResponse, type ClientInstance, type RawClient, type SharedClientOptions, @@ -53,6 +54,57 @@ export function createCallSettingsClient( }) } +export type CallRetentionPolicy = + 'short' | 'standard' | 'extended' | 'compliance' | 'custom' + +/** Team call data retention, as `/platform/call-retention` returns it. */ +export interface CallRetention { + readonly appliesTo: readonly string[] + readonly policy: CallRetentionPolicy + readonly retentionDays: number + readonly revision: number + readonly updatedAt: string | null +} + +export interface UpdateCallRetentionInput { + readonly expectedRevision?: number + readonly policy: CallRetentionPolicy + readonly retentionDays?: number +} + +export interface CallRetentionClient { + retrieve(): Promise> + update( + input: UpdateCallRetentionInput, + ): Promise> +} + +/** + * Team call data retention on the Platform API. The pinned SDK has no typed + * resource for it yet, so this adapter sends the two documented requests + * through the organization client's raw transport. The SDK keeps its + * credential, retry, and error mapping; replace this with the SDK's + * `callRetention` resource once a release that includes it is pinned. + */ +export function createCallRetentionClient( + environment: ResolvedEnvironment, +): CallRetentionClient { + const raw = createPlatformClient(environment).raw + return { + retrieve: () => + raw.request<{readonly data: CallRetention}>({ + method: 'GET', + path: '/platform/call-retention', + }), + update: (input) => + raw.request<{readonly data: CallRetention}>({ + body: input, + method: 'PUT', + path: '/platform/call-retention', + }), + } +} + export function createPlatformClient( environment: ResolvedEnvironment, ): ClientInstance<'organization'> { diff --git a/src/services/calls.ts b/src/services/calls.ts index 991c6990..190b2c95 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -33,6 +33,12 @@ import { type VoipPlaceCallResult, } from '@polymorfa/sdk' +import type { + CallRetention, + CallRetentionClient, + CallRetentionPolicy, +} from '../sdk/client.js' + /** Unified Calls credential facts the commands need before any transport. */ export interface CallsCredential { readonly type: MessagingCredential['type'] @@ -320,6 +326,163 @@ function validateSession(session: string): string { return trimmed } +// ── Team call data retention ─────────────────────────────────────────────── + +/** The period each named policy sets; `custom` takes --days. */ +export const CALL_RETENTION_PERIODS: Readonly< + Record, number> +> = {short: 7, standard: 30, extended: 90, compliance: 365} + +export const CALL_RETENTION_POLICIES: readonly CallRetentionPolicy[] = [ + 'short', + 'standard', + 'extended', + 'compliance', + 'custom', +] + +export const MIN_CALL_RETENTION_DAYS = 1 +export const MAX_CALL_RETENTION_DAYS = 2555 + +export async function viewCallRetention( + client: CallRetentionClient, +): Promise> { + const response = await retentionRequest('read', () => client.retrieve()) + return result(response.data.data, response.metadata) +} + +export interface CallRetentionChange { + /** Apply only if the setting still has this revision. */ + readonly expectedRevision: number + readonly policy: CallRetentionPolicy + readonly retentionDays?: number | undefined +} + +/** + * Shortening the period deletes stored call data. The command decides how to + * confirm; this is called only when the new period is shorter. + */ +export type ConfirmShorterRetention = ( + current: CallRetention, + retentionDays: number, +) => Promise + +/** + * Change the team's call data retention. Reads the current setting first so + * a shorter period is confirmed before anything is deleted, and so a stale + * --expected-revision fails before the change is sent. + */ +export async function updateCallRetention( + client: CallRetentionClient, + change: CallRetentionChange, + confirmShorter: ConfirmShorterRetention, +): Promise> { + const retentionDays = callRetentionDays(change.policy, change.retentionDays) + const current = (await retentionRequest('read', () => client.retrieve())).data + .data + if (current.revision !== change.expectedRevision) { + throw new PolymorfaConflictError(retentionConflict, { + code: 'state_conflict', + details: { + currentRevision: current.revision, + expectedRevision: change.expectedRevision, + }, + status: 409, + }) + } + if ( + typeof current.retentionDays !== 'number' || + retentionDays < current.retentionDays + ) { + await confirmShorter(current, retentionDays) + } + const response = await retentionRequest('change', () => + client.update({ + expectedRevision: change.expectedRevision, + policy: change.policy, + ...(change.retentionDays === undefined + ? {} + : {retentionDays: change.retentionDays}), + }), + ) + return result(response.data.data, response.metadata) +} + +/** The period a change sets, checked the way the API checks it. */ +export function callRetentionDays( + policy: CallRetentionPolicy, + retentionDays: number | undefined, +): number { + if ( + retentionDays !== undefined && + (!Number.isInteger(retentionDays) || + retentionDays < MIN_CALL_RETENTION_DAYS || + retentionDays > MAX_CALL_RETENTION_DAYS) + ) { + throw new PolymorfaConfigurationError( + `--days must be a whole number from ${MIN_CALL_RETENTION_DAYS} to ${MAX_CALL_RETENTION_DAYS}.`, + 'days', + ) + } + if (policy === 'custom') { + if (retentionDays === undefined) { + throw new PolymorfaConfigurationError( + '--policy custom requires --days.', + 'days', + ) + } + return retentionDays + } + const period = CALL_RETENTION_PERIODS[policy] + if (retentionDays !== undefined && retentionDays !== period) { + throw new PolymorfaConfigurationError( + `--policy ${policy} keeps call data for ${period} days; omit --days or use --policy custom.`, + 'days', + ) + } + return period +} + +const retentionConflict = + 'The call retention setting changed (state_conflict); read it again with `polymorfa calls retention get` and retry with its revision.' + +const retentionForbidden = { + change: + 'Call retention is a team setting; use a team API key with the sessions:manage scope to change it.', + read: 'Call retention is a team setting; use a team API key or project token with the sessions:read scope to read it.', +} as const + +/** Replace the API's generic 403 and 409 text with what the user can do. */ +async function retentionRequest( + action: keyof typeof retentionForbidden, + run: () => Promise, +): Promise { + try { + return await run() + } catch (error) { + const message = + error instanceof PolymorfaConflictError && error.code === 'state_conflict' + ? retentionConflict + : error instanceof PolymorfaAuthorizationError + ? `${retentionForbidden[action]} The API said: ${error.message}` + : undefined + if (message === undefined || !(error instanceof PolymorfaError)) { + throw error + } + const options = { + cause: error, + details: error.details, + ...(error.code === undefined ? {} : {code: error.code}), + ...(error.status === undefined ? {} : {status: error.status}), + ...(error.requestId === undefined ? {} : {requestId: error.requestId}), + ...(error.metadata === undefined ? {} : {metadata: error.metadata}), + } + throw error instanceof PolymorfaConflictError + ? new PolymorfaConflictError(message, options) + : new PolymorfaAuthorizationError(message, options) + } +} + /** Replace the API's generic conflict text with the Calls meaning of its code. */ async function callRequest(run: () => Promise): Promise { try { @@ -408,6 +571,16 @@ export function renderCallSettings( ].join('\n') } +export function renderCallRetention(retention: CallRetention): string { + return [ + `Policy\t${retention.policy}`, + `Retention\t${retention.retentionDays} ${retention.retentionDays === 1 ? 'day' : 'days'}`, + `Applies to\t${retention.appliesTo.join(', ')}`, + `Revision\t${retention.revision}`, + `Updated\t${retention.updatedAt ?? 'never (defaults)'}`, + ].join('\n') +} + function yesNo(value: boolean): string { return value ? 'yes' : 'no' } diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index 9c955756..10d2b06d 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -13,6 +13,13 @@ const API_KEY = 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' const CLIENT_TOKEN = 'pmfa_ct_browser_token' const TRUNK_ID = '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10' +const DEFAULT_RETENTION = { + appliesTo: ['call_records', 'call_events', 'client_reports'], + policy: 'extended', + retentionDays: 90, + revision: 0, + updatedAt: null, +} describe('unified calls commands', () => { let server: TestHttpServer | undefined @@ -558,6 +565,256 @@ describe('unified calls commands', () => { expect(server.requests).toHaveLength(0) }) + test('reads team call retention in human and JSON output', async () => { + server = await startHttpServer({ + body: {data: DEFAULT_RETENTION, success: true}, + headers: {'request-id': 'req_retention'}, + }) + + const human = await apiKey(() => runCommand('calls retention get')) + const json = await apiKey(() => runCommand('calls retention get --json')) + + expect(human.error).toBeUndefined() + expect(human.stdout).toBe( + 'Policy\textended\nRetention\t90 days\nApplies to\tcall_records, call_events, client_reports\nRevision\t0\nUpdated\tnever (defaults)\n', + ) + expect(json.error).toBeUndefined() + expect(jsonData(json.stdout)).toEqual(DEFAULT_RETENTION) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/call-retention', + 'GET /platform/call-retention', + ]) + expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${API_KEY}`) + }) + + test('changes team call retention after checking the revision', async () => { + const saved = { + ...DEFAULT_RETENTION, + policy: 'compliance', + retentionDays: 365, + revision: 1, + updatedAt: '2026-09-19T10:00:00.000Z', + } + server = await startHttpSequenceServer([ + {body: {data: DEFAULT_RETENTION, success: true}}, + {body: {data: saved, success: true}}, + {body: {data: DEFAULT_RETENTION, success: true}}, + { + body: { + data: {...saved, policy: 'custom', retentionDays: 180}, + success: true, + }, + }, + ]) + + const named = await apiKey(() => + runCommand( + 'calls retention set --policy compliance --expected-revision 0', + ), + ) + const custom = await apiKey(() => + runCommand( + 'calls retention set --policy custom --days 180 --expected-revision 0 --json', + ), + ) + + expect(named.error).toBeUndefined() + expect(named.stdout).toBe( + 'Policy\tcompliance\nRetention\t365 days\nApplies to\tcall_records, call_events, client_reports\nRevision\t1\nUpdated\t2026-09-19T10:00:00.000Z\n', + ) + expect(custom.error).toBeUndefined() + expect(jsonData(custom.stdout)).toMatchObject({ + policy: 'custom', + retentionDays: 180, + }) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/call-retention', + 'PUT /platform/call-retention', + 'GET /platform/call-retention', + 'PUT /platform/call-retention', + ]) + expect(JSON.parse(server.requests[1]?.body ?? '')).toEqual({ + expectedRevision: 0, + policy: 'compliance', + }) + expect(JSON.parse(server.requests[3]?.body ?? '')).toEqual({ + expectedRevision: 0, + policy: 'custom', + retentionDays: 180, + }) + }) + + test('rejects invalid call retention changes before any request', async () => { + server = await startHttpServer({ + body: {data: DEFAULT_RETENTION, success: true}, + }) + + const customWithoutDays = await apiKey(() => + runCommand( + 'calls retention set --policy custom --expected-revision 0 --json', + ), + ) + const zero = await apiKey(() => + runCommand( + 'calls retention set --policy custom --days 0 --expected-revision 0', + ), + ) + const tooLong = await apiKey(() => + runCommand( + 'calls retention set --policy custom --days 2556 --expected-revision 0', + ), + ) + const mismatched = await apiKey(() => + runCommand( + 'calls retention set --policy standard --days 45 --expected-revision 0', + ), + ) + const noRevision = await apiKey(() => + runCommand('calls retention set --policy extended'), + ) + const unknownPolicy = await apiKey(() => + runCommand('calls retention set --policy forever --expected-revision 0'), + ) + const token = await clientToken(() => + runCommand('calls retention get --json'), + ) + + expect(exitCode(customWithoutDays.error)).toBe(2) + expect(JSON.parse(customWithoutDays.stderr.trim())).toMatchObject({ + error: {category: 'configuration'}, + }) + expect(customWithoutDays.stderr).toContain( + '--policy custom requires --days', + ) + for (const invalid of [ + zero, + tooLong, + mismatched, + noRevision, + unknownPolicy, + ]) { + expect(exitCode(invalid.error)).toBe(2) + } + expect(mismatched.stderr).toContain('omit --days or use --policy custom') + expect(exitCode(token.error)).toBe(2) + expect(token.stderr).toContain( + 'Platform API requests require POLYMORFA_API_KEY', + ) + expect(server.requests).toHaveLength(0) + }) + + test('requires --yes to shorten call retention without a terminal', async () => { + server = await startHttpSequenceServer([ + {body: {data: DEFAULT_RETENTION, success: true}}, + {body: {data: DEFAULT_RETENTION, success: true}}, + { + body: { + data: { + ...DEFAULT_RETENTION, + policy: 'short', + retentionDays: 7, + revision: 1, + }, + success: true, + }, + }, + ]) + + const refused = await apiKey(() => + runCommand( + 'calls retention set --policy short --expected-revision 0 --json', + ), + ) + const confirmed = await apiKey(() => + runCommand( + 'calls retention set --policy short --expected-revision 0 --yes', + ), + ) + + expect(exitCode(refused.error)).toBe(2) + expect(JSON.parse(refused.stderr.trim())).toMatchObject({ + error: {category: 'validation', code: 'confirmation_required'}, + }) + expect(refused.stderr).toContain('from 90 to 7 days') + expect(refused.stderr).toContain('--yes') + expect(confirmed.error).toBeUndefined() + expect(confirmed.stdout).toContain('Retention\t7 days') + expect(server.requests.map(({method}) => method)).toEqual([ + 'GET', + 'GET', + 'PUT', + ]) + expect(JSON.parse(server.requests[2]?.body ?? '')).toEqual({ + expectedRevision: 0, + policy: 'short', + }) + }) + + test('reports a stale call retention revision with exit code 4', async () => { + server = await startHttpSequenceServer([ + {body: {data: {...DEFAULT_RETENTION, revision: 2}, success: true}}, + {body: {data: {...DEFAULT_RETENTION, revision: 2}, success: true}}, + apiError( + 409, + 'state_conflict', + 'The call retention changed since revision 2.', + 'req_stale', + ), + ]) + + const local = await apiKey(() => + runCommand( + 'calls retention set --policy compliance --expected-revision 1 --json', + ), + ) + const remote = await apiKey(() => + runCommand( + 'calls retention set --policy compliance --expected-revision 2', + ), + ) + + expect(exitCode(local.error)).toBe(4) + expect(JSON.parse(local.stderr.trim())).toMatchObject({ + error: {category: 'conflict', code: 'state_conflict'}, + }) + expect(exitCode(remote.error)).toBe(4) + expect(remote.stderr).toContain( + 'The call retention setting changed (state_conflict); read it again', + ) + expect(remote.stderr).toContain('Request ID: req_stale') + expect(server.requests.map(({method}) => method)).toEqual([ + 'GET', + 'GET', + 'PUT', + ]) + }) + + test('explains that call retention needs a team API key', async () => { + server = await startHttpSequenceServer([ + {body: {data: DEFAULT_RETENTION, success: true}}, + apiError( + 403, + 'forbidden', + 'call retention is a team setting; use a team API key to change it', + ), + ]) + + const result = await apiKey(() => + runCommand( + 'calls retention set --policy compliance --expected-revision 0 --json', + ), + ) + + expect(exitCode(result.error)).toBe(3) + const envelope = JSON.parse(result.stderr.trim()) as { + error: {category: string; message: string} + } + expect(envelope.error.category).toBe('authorization') + expect(envelope.error.message).toContain( + 'Call retention is a team setting; use a team API key', + ) + }) + test('distinguishes the unified and session-route reject commands in help', async () => { const unified = await runCommand('calls reject --help') const legacy = await runCommand('call reject --help') diff --git a/test/services/call-retention.test.ts b/test/services/call-retention.test.ts new file mode 100644 index 00000000..48e79b79 --- /dev/null +++ b/test/services/call-retention.test.ts @@ -0,0 +1,128 @@ +import {PassThrough, Readable} from 'node:stream' + +import {describe, expect, test} from 'vitest' + +import {confirmDangerous} from '../../src/input/confirmation.js' +import type { + CallRetention, + CallRetentionClient, + UpdateCallRetentionInput, +} from '../../src/sdk/client.js' +import { + callRetentionDays, + updateCallRetention, +} from '../../src/services/calls.js' + +const CURRENT: CallRetention = { + appliesTo: ['call_records', 'call_events', 'client_reports'], + policy: 'extended', + retentionDays: 90, + revision: 0, + updatedAt: null, +} + +describe('call retention changes', () => { + test('checks periods the way the API does', () => { + expect(callRetentionDays('short', undefined)).toBe(7) + expect(callRetentionDays('standard', 30)).toBe(30) + expect(callRetentionDays('custom', 2555)).toBe(2555) + expect(() => callRetentionDays('custom', undefined)).toThrow('--days') + expect(() => callRetentionDays('custom', 0)).toThrow('1 to 2555') + expect(() => callRetentionDays('custom', 2556)).toThrow('1 to 2555') + expect(() => callRetentionDays('extended', 30)).toThrow('90 days') + }) + + test('sends a shorter period only after an interactive yes', async () => { + const {client, updates} = fakeClient() + + const result = await updateCallRetention( + client, + {expectedRevision: 0, policy: 'standard'}, + (current, days) => + confirmDangerous({ + ...terminal('yes\n'), + prompt: `Shorten from ${current.retentionDays} to ${days} days?`, + yes: false, + }), + ) + + expect(result.data.retentionDays).toBe(30) + expect(updates).toEqual([{expectedRevision: 0, policy: 'standard'}]) + }) + + test('changes nothing when the shorter period is declined', async () => { + const {client, updates} = fakeClient() + + await expect( + updateCallRetention( + client, + {expectedRevision: 0, policy: 'custom', retentionDays: 14}, + () => + confirmDangerous({ + ...terminal('no\n'), + prompt: 'Shorten?', + yes: false, + }), + ), + ).rejects.toMatchObject({code: 'confirmation_declined'}) + expect(updates).toEqual([]) + }) + + test('does not ask to confirm an equal or longer period', async () => { + const {client, updates} = fakeClient() + let asked = false + + await updateCallRetention( + client, + {expectedRevision: 0, policy: 'extended', retentionDays: 90}, + () => { + asked = true + return Promise.resolve() + }, + ) + + expect(asked).toBe(false) + expect(updates).toEqual([ + {expectedRevision: 0, policy: 'extended', retentionDays: 90}, + ]) + }) +}) + +function fakeClient(): { + client: CallRetentionClient + updates: UpdateCallRetentionInput[] +} { + const updates: UpdateCallRetentionInput[] = [] + const metadata = {attempts: 1, headers: {}, status: 200} + return { + updates, + client: { + retrieve: () => Promise.resolve({data: {data: CURRENT}, metadata}), + update: (input) => { + updates.push(input) + return Promise.resolve({ + data: { + data: { + ...CURRENT, + policy: input.policy, + retentionDays: callRetentionDays( + input.policy, + input.retentionDays, + ), + revision: 1, + }, + }, + metadata, + }) + }, + }, + } +} + +function terminal(answer: string) { + const input = Readable.from([answer]) as Readable & {isTTY?: boolean} + input.isTTY = true + const output = new PassThrough() as PassThrough & {isTTY?: boolean} + output.isTTY = true + return {input, output} +} From 5f18935a57ba9c27775f33071b45b498e22c3c2d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 12:32:41 +0200 Subject: [PATCH 2/9] fix(calls): let project tokens read call retention and describe pre-enablement truthfully calls retention get resolved Platform credentials, which accept only team API keys, so project tokens were refused locally although the API lets them read the team setting; calls retention set likewise refused them with exit 2 instead of reaching the API's 403 (exit 3). Both commands now resolve a team API key or a project token, from the environment or a profile, with no project context, and the API decides. Team keys keep the SDK raw transport. The pinned SDK confines project-token raw requests to /platform/projects/{id} and refuses a project token as an organization key, so project tokens use a narrow single-request transport that sends the SDK's headers, refuses non-loopback http and redirects, and maps failures to the SDK error classes. Remove it once a pinned SDK release serves this route (polymorfa/sdks#278). Help text and README now say deletion starts on a date Polymorfa announces, matching the API: the production purge is not enabled yet. --- README.md | 18 ++- docs/command-research/calls.md | 15 +- src/base-command.ts | 22 +++ src/commands/calls/retention/get.ts | 4 +- src/commands/calls/retention/set.ts | 6 +- src/context/environment.ts | 84 +++++++++++ src/sdk/client.ts | 45 +++++- src/sdk/project-token-request.ts | 191 +++++++++++++++++++++++++ test/commands/calls.test.ts | 54 ++++++- test/sdk/project-token-request.test.ts | 109 ++++++++++++++ 10 files changed, 521 insertions(+), 27 deletions(-) create mode 100644 src/sdk/project-token-request.ts create mode 100644 test/sdk/project-token-request.test.ts diff --git a/README.md b/README.md index 8105677b..141bd3d5 100644 --- a/README.md +++ b/README.md @@ -517,23 +517,27 @@ never answers or declines on its own. them first, the command exits with code 4 (`state_conflict`) and changes nothing. - **Call data retention.** `calls retention` reads and changes how long - Polymorfa keeps your team's call data. It uses the Platform API; client - tokens fail with the Platform credential error. `--policy short`, + Polymorfa keeps your team's call data. It uses the Platform API with a team + API key or a project token (`POLYMORFA_API_KEY`, `POLYMORFA_PROJECT_TOKEN`, + or a profile); client tokens are refused. `--policy short`, `standard`, `extended`, or `compliance` keeps call data for 7, 30, 90, or 365 days; `--policy custom --days ` keeps it for 1 to 2555 days. Teams - without a saved setting use `extended` (90 days) at revision `0`. Polymorfa - deletes call data within 24 hours after it becomes older than the period. + without a saved setting use `extended` (90 days) at revision `0`. Deletion + of call data older than the period starts on a date Polymorfa announces in + its changelog; until then the setting records a choice and nothing is + deleted. `calls retention get` prints the policy, period, the kinds of call data it applies to, and the `revision`. `calls retention set` requires `--expected-revision `: it reads the setting first and changes it only if it still has that revision; otherwise it exits with code 4 (`state_conflict`) and changes nothing, so read the setting again and retry. - A shorter period also applies to call data already stored, and deleted call - data cannot be recovered. The command asks you to type `yes` before it + Once deletion runs, a shorter period also applies to call data already + stored, and deleted call data cannot be recovered. The command asks you to type `yes` before it shortens the period; `--yes` confirms without a prompt, and without a terminal the command exits with code 2 (`confirmation_required`) unless you pass `--yes`. Changing retention requires a team API key with the - `sessions:manage` scope; other keys exit with code 3. + `sessions:manage` scope. Project tokens can read the setting; the API + refuses their changes, and the command exits with code 3. `calls watch` streams inbound calls and their progress: `incoming`, `claim`, `state`, `participant_joined`, `participant_state`, `participant_left`, and diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index 1769c648..384ac262 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -82,11 +82,16 @@ internal SDK module. Messaging client; the CLI builds that client from the Platform environment. `calls settings set --expected-revision` sends `expectedRevision`; a stale revision returns 409 `state_conflict`, which exits with code 4. -- `calls retention` resolves Platform credentials. The pinned SDK has no - call-retention resource, so `createCallRetentionClient` in `src/sdk/client.ts` - sends `GET` and `PUT /platform/call-retention` through the organization - client's raw transport; switch it to the SDK's `callRetention` methods once - a release that includes them is pinned. The API lets team API keys and +- `calls retention` resolves a team API key or a project token + (`resolveTeamSettingEnvironment`), from the environment or a profile, with + no project context. The pinned SDK has no call-retention resource, and its + project-token client confines raw paths to `/platform/projects/{id}`, so + `createCallRetentionClient` in `src/sdk/client.ts` sends `GET` and + `PUT /platform/call-retention` through the unconfined organization raw + transport for both principals (as the project lookup does); the API + identifies the principal from the bearer credential. Switch to the SDK's + `callRetention` methods (polymorfa/sdks#278, which serve both principals) + once a release that includes them is pinned. The API lets team API keys and project tokens read the setting and only team API keys change it; the CLI replaces the API's 403 text with that rule and keeps the API message. - `calls retention set` requires `--expected-revision`. It reads the setting, diff --git a/src/base-command.ts b/src/base-command.ts index 2a54b872..e9497a61 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -8,6 +8,8 @@ import { import { resolveCommandEnvironment, resolveListenerEnvironment, + resolveTeamSettingEnvironment, + type TeamSettingEnvironment, type ResolvedEnvironment, } from './context/environment.js' import {presentError} from './errors/present.js' @@ -160,6 +162,26 @@ export abstract class BaseCommand extends Command { return createPlatformClient(await this.platformEnvironment(flags)) } + /** + * Resolve credentials for a team-wide setting that project tokens may read + * (call data retention): team API keys or project tokens; the API decides. + */ + protected async teamSettingEnvironment( + flags: GlobalFlags, + ): Promise { + const environment = await resolveTeamSettingEnvironment({ + env: process.env, + surface: 'platform', + ...(flags.profile === undefined ? {} : {profile: flags.profile}), + ...(flags['api-version'] === undefined + ? {} + : {apiVersion: flags['api-version']}), + ...(flags.timeout === undefined ? {} : {timeoutMs: flags.timeout}), + }) + this.registerSecret(environment.credential) + return environment + } + /** Resolve Platform credentials: server API keys only. */ protected async platformEnvironment( flags: GlobalFlags, diff --git a/src/commands/calls/retention/get.ts b/src/commands/calls/retention/get.ts index 3bf5d33d..cb9036f4 100644 --- a/src/commands/calls/retention/get.ts +++ b/src/commands/calls/retention/get.ts @@ -7,7 +7,7 @@ import { export default class CallsRetentionGet extends BaseCommand { static override description = - 'Read how long Polymorfa keeps your team’s call data from the Platform API. Polymorfa deletes call data within 24 hours after it becomes older than the retention period. Teams without a saved setting return the default (extended, 90 days) at revision 0. Pass the revision to `calls retention set --expected-revision` so a change cannot overwrite another one. Requires a server API key with the sessions:read scope; client tokens are refused.' + 'Read how long Polymorfa keeps your team’s call data from the Platform API. Deletion of call data older than the period starts on a date Polymorfa announces in its changelog; until then the setting records a choice and nothing is deleted. Teams without a saved setting return the default (extended, 90 days) at revision 0. Pass the revision to `calls retention set --expected-revision` so a change cannot overwrite another one. Requires a team API key or a project token with the sessions:read scope; client tokens are refused.' static override examples = ['<%= config.bin %> calls retention get'] @@ -16,7 +16,7 @@ export default class CallsRetentionGet extends BaseCommand { async run(): Promise { const {flags} = await this.parse(CallsRetentionGet) const result = await viewCallRetention( - createCallRetentionClient(await this.platformEnvironment(flags)), + createCallRetentionClient(await this.teamSettingEnvironment(flags)), ) this.writeValue(result, renderCallRetention(result.data), flags) } diff --git a/src/commands/calls/retention/set.ts b/src/commands/calls/retention/set.ts index 86cd4aaf..c4e6c573 100644 --- a/src/commands/calls/retention/set.ts +++ b/src/commands/calls/retention/set.ts @@ -15,7 +15,7 @@ import { } from '../../../services/calls.js' export default class CallsRetentionSet extends BaseCommand { - static override description = `Change how long Polymorfa keeps your team’s call data through the Platform API. --policy short, standard, extended, or compliance keeps call data for 7, 30, 90, or 365 days; --policy custom keeps it for --days (${MIN_CALL_RETENTION_DAYS} to ${MAX_CALL_RETENTION_DAYS}). Polymorfa deletes call data within 24 hours after it becomes older than the period. A shorter period also applies to call data already stored, and deleted call data cannot be recovered, so the command asks you to confirm a shorter period; pass --yes to confirm without a prompt (required when standard input or error is not a terminal). --expected-revision is required: the command reads the setting first and changes it only if it still has that revision (see \`calls retention get\`); otherwise it fails with state_conflict (exit code 4) and changes nothing. Requires a team API key with the sessions:manage scope; project keys fail with exit code 3.` + static override description = `Change how long Polymorfa keeps your team’s call data through the Platform API. --policy short, standard, extended, or compliance keeps call data for 7, 30, 90, or 365 days; --policy custom keeps it for --days (${MIN_CALL_RETENTION_DAYS} to ${MAX_CALL_RETENTION_DAYS}). Deletion of call data older than the period starts on a date Polymorfa announces in its changelog; until then saving a period deletes nothing. Once deletion runs, a shorter period also applies to call data already stored, and deleted call data cannot be recovered, so the command asks you to confirm a shorter period; pass --yes to confirm without a prompt (required when standard input or error is not a terminal). --expected-revision is required: the command reads the setting first and changes it only if it still has that revision (see \`calls retention get\`); otherwise it fails with state_conflict (exit code 4) and changes nothing. Requires a team API key with the sessions:manage scope; the API refuses project tokens (exit code 3).` static override examples = [ '<%= config.bin %> calls retention set --policy compliance --expected-revision 0', @@ -52,7 +52,7 @@ export default class CallsRetentionSet extends BaseCommand { async run(): Promise { const {flags} = await this.parse(CallsRetentionSet) const result = await updateCallRetention( - createCallRetentionClient(await this.platformEnvironment(flags)), + createCallRetentionClient(await this.teamSettingEnvironment(flags)), { expectedRevision: flags['expected-revision'], policy: flags.policy as CallRetentionPolicy, @@ -60,7 +60,7 @@ export default class CallsRetentionSet extends BaseCommand { }, (current, retentionDays) => confirmDangerous({ - prompt: `Shorten call data retention from ${current.retentionDays} to ${retentionDays} days? Polymorfa deletes stored call data older than ${retentionDays} days within 24 hours, and it cannot be recovered.`, + prompt: `Shorten call data retention from ${current.retentionDays} to ${retentionDays} days? Once deletion runs, stored call data older than ${retentionDays} days is deleted and cannot be recovered.`, yes: flags.yes, }), ) diff --git a/src/context/environment.ts b/src/context/environment.ts index 1f1554b6..7ca7310d 100644 --- a/src/context/environment.ts +++ b/src/context/environment.ts @@ -424,6 +424,90 @@ export async function resolveCommandEnvironment( return {...resolved, profile: selected, source: 'profile'} } +export interface TeamSettingEnvironment { + readonly apiVersion?: string + readonly baseUrl: string + readonly credential: string + readonly principalType: 'organization_key' | 'project_token' + readonly timeoutMs?: number +} + +/** + * Credentials for a team-wide setting that project tokens may read, such as + * call data retention. Team API keys and project tokens both resolve, from the + * environment or a profile, with no project context: the API identifies the + * principal from the credential and decides whether it may read or change the + * setting (a project token's change fails with 403). Client tokens are refused. + */ +export async function resolveTeamSettingEnvironment( + input: ResolveCommandEnvironmentInput, + profiles: ProfileRepositoryLike = new ProfileRepository(), + credentials: CredentialStore = new SecureCredentialStore(), +): Promise { + const explicitProfile = nonEmpty(input.profile) + const baseUrl = nonEmpty(input.env.POLYMORFA_BASE_URL) + const optional = { + ...(input.timeoutMs === undefined ? {} : {timeoutMs: input.timeoutMs}), + } + if (explicitProfile === undefined) { + if ( + nonEmpty(input.env.POLYMORFA_CLIENT_TOKEN) !== undefined && + nonEmpty(input.env.POLYMORFA_API_KEY) === undefined && + nonEmpty(input.env.POLYMORFA_PROJECT_TOKEN) === undefined + ) { + throw new PolymorfaConfigurationError( + 'This command requires a team API key or a project token; browser client tokens are refused.', + 'credential', + ) + } + const environment = durableEnvironmentCredential(input.env) + if (environment !== undefined) { + return { + ...(input.apiVersion === undefined + ? {} + : {apiVersion: input.apiVersion}), + baseUrl: baseUrl ?? 'https://api.polymorfa.com', + credential: environment.value, + principalType: environment.principalType, + ...optional, + } + } + } + const config = await profiles.load() + const selected = explicitProfile ?? config.activeProfile + if (selected === undefined) { + throw new PolymorfaConfigurationError( + 'This command requires POLYMORFA_API_KEY, POLYMORFA_PROJECT_TOKEN, or an authentication profile.', + 'credential', + ) + } + const profile = config.profiles[selected] + if (profile === undefined) { + throw new PolymorfaConfigurationError( + `Profile ${selected} does not exist. Run polymorfa auth list to inspect configured profiles.`, + 'profile', + ) + } + const credential = await credentials.get(profile.credential.account) + if (credential === undefined) { + throw new PolymorfaConfigurationError( + `Profile ${selected} has no credential in the operating-system keyring.`, + 'keyring', + ) + } + const apiVersion = input.apiVersion ?? profile.apiVersion + return { + ...(apiVersion === undefined ? {} : {apiVersion}), + baseUrl: baseUrl ?? profile.baseUrl ?? 'https://api.polymorfa.com', + credential, + principalType: + profile.credential.kind === 'projectToken' + ? 'project_token' + : 'organization_key', + ...optional, + } +} + export function resolveEnvironment( input: ResolveEnvironmentInput, ): ResolvedEnvironment { diff --git a/src/sdk/client.ts b/src/sdk/client.ts index 7daf2fe0..ba6f0716 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -8,7 +8,11 @@ import { type SharedClientOptions, } from '@polymorfa/sdk' -import type {ResolvedEnvironment} from '../context/environment.js' +import type { + ResolvedEnvironment, + TeamSettingEnvironment, +} from '../context/environment.js' +import {projectTokenRequest} from './project-token-request.js' export function createRawClient(environment: ResolvedEnvironment): RawClient { return environment.surface === 'platform' @@ -81,15 +85,42 @@ export interface CallRetentionClient { /** * Team call data retention on the Platform API. The pinned SDK has no typed - * resource for it yet, so this adapter sends the two documented requests - * through the organization client's raw transport. The SDK keeps its - * credential, retry, and error mapping; replace this with the SDK's - * `callRetention` resource once a release that includes it is pinned. + * resource for it yet. Team API keys use the SDK organization client's raw + * transport, which keeps its credential, retry, and error mapping. Project + * tokens may read this team-wide route, but the pinned SDK confines a + * project-token client to `/platform/projects/{id}`, so they use + * `projectTokenRequest`; the API decides what each principal may do. Replace + * both with the SDK's `callRetention` resource once a release that includes + * it is pinned (polymorfa/sdks#278). */ export function createCallRetentionClient( - environment: ResolvedEnvironment, + environment: TeamSettingEnvironment, ): CallRetentionClient { - const raw = createPlatformClient(environment).raw + if (environment.principalType === 'project_token') { + return { + retrieve: () => + projectTokenRequest<{readonly data: CallRetention}>(environment, { + method: 'GET', + path: '/platform/call-retention', + }), + update: (input) => + projectTokenRequest<{readonly data: CallRetention}>(environment, { + body: input, + method: 'PUT', + path: '/platform/call-retention', + }), + } + } + const raw = new Client({ + baseUrl: environment.baseUrl, + credential: {type: 'organizationApiKey', value: environment.credential}, + ...(environment.apiVersion === undefined + ? {} + : {apiVersion: environment.apiVersion}), + ...(environment.timeoutMs === undefined + ? {} + : {timeoutMs: environment.timeoutMs}), + }).raw return { retrieve: () => raw.request<{readonly data: CallRetention}>({ diff --git a/src/sdk/project-token-request.ts b/src/sdk/project-token-request.ts new file mode 100644 index 00000000..927d2412 --- /dev/null +++ b/src/sdk/project-token-request.ts @@ -0,0 +1,191 @@ +import { + PolymorfaAuthenticationError, + PolymorfaAuthorizationError, + PolymorfaConfigurationError, + PolymorfaConflictError, + PolymorfaConnectionError, + PolymorfaError, + PolymorfaNotFoundError, + PolymorfaPaymentRequiredError, + PolymorfaRateLimitError, + PolymorfaServerError, + PolymorfaTimeoutError, + PolymorfaValidationError, + type ApiResponse, + type PolymorfaErrorOptions, + type ResponseMetadata, +} from '@polymorfa/sdk' + +import type {TeamSettingEnvironment} from '../context/environment.js' + +const DEFAULT_TIMEOUT_MS = 30_000 +const SAFE_RESPONSE_HEADERS = [ + 'content-type', + 'x-request-id', + 'polymorfa-version', + 'retry-after', +] as const + +/** + * One request to a team-wide Platform route with a project token. + * + * The pinned SDK confines a project-token client's raw requests to + * `/platform/projects/{id}`, and it refuses a project token as an organization + * key, so it cannot reach a team-wide route such as `/platform/call-retention` + * that the API lets project tokens read. This sends exactly one request with + * the SDK's headers and maps failures to the SDK's error classes, so exit + * codes and redaction match SDK-backed commands. It does not retry. Remove it + * once a pinned SDK release serves the route for project tokens + * (polymorfa/sdks#278). + */ +export async function projectTokenRequest( + environment: TeamSettingEnvironment, + request: { + readonly body?: unknown + readonly method: 'GET' | 'PUT' + readonly path: string + }, + fetchImpl: typeof globalThis.fetch = globalThis.fetch, +): Promise> { + const url = requestUrl(environment.baseUrl, request.path) + const headers: Record = { + accept: 'application/json', + authorization: `Bearer ${environment.credential}`, + } + if (environment.apiVersion !== undefined) { + headers['polymorfa-version'] = environment.apiVersion + } + if (request.body !== undefined) headers['content-type'] = 'application/json' + + let response: Response + try { + response = await fetchImpl(url, { + ...(request.body === undefined + ? {} + : {body: JSON.stringify(request.body)}), + headers, + method: request.method, + redirect: 'error', + signal: AbortSignal.timeout(environment.timeoutMs ?? DEFAULT_TIMEOUT_MS), + }) + } catch (error) { + if (error instanceof DOMException && error.name === 'TimeoutError') { + throw new PolymorfaTimeoutError('The Polymorfa API request timed out.', { + cause: error, + }) + } + throw new PolymorfaConnectionError('Could not reach the Polymorfa API.', { + cause: error, + }) + } + + const metadata = responseMetadata(response) + const body = await readBody(response) + if (!response.ok) throw apiError(response.status, body, metadata) + if (typeof body !== 'object' || body === null) { + throw new PolymorfaServerError( + 'The Polymorfa API returned a response that is not JSON.', + {metadata, status: response.status}, + ) + } + return {data: body as T, metadata} +} + +function requestUrl(baseUrl: string, path: string): URL { + let url: URL + try { + url = new URL(path, baseUrl) + } catch { + throw new PolymorfaConfigurationError( + 'POLYMORFA_BASE_URL is not a valid URL.', + 'POLYMORFA_BASE_URL', + ) + } + const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) + if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { + throw new PolymorfaConfigurationError( + 'POLYMORFA_BASE_URL must use https (plain http is allowed only on loopback).', + 'POLYMORFA_BASE_URL', + ) + } + return url +} + +async function readBody(response: Response): Promise { + const text = await response.text() + const contentType = response.headers.get('content-type') ?? '' + if (!contentType.includes('application/json')) return text + try { + return JSON.parse(text) as unknown + } catch { + return text + } +} + +function responseMetadata(response: Response): ResponseMetadata { + const headerRecord: Record = {} + for (const key of SAFE_RESPONSE_HEADERS) { + const value = response.headers.get(key) + if (value !== null) headerRecord[key] = value + } + const requestId = + response.headers.get('x-request-id') ?? + response.headers.get('request-id') ?? + undefined + const apiVersion = response.headers.get('polymorfa-version') ?? undefined + return Object.freeze({ + attempts: 1, + headers: Object.freeze(headerRecord), + status: response.status, + ...(requestId === undefined ? {} : {requestId}), + ...(apiVersion === undefined ? {} : {apiVersion}), + }) +} + +function apiError( + status: number, + body: unknown, + metadata: ResponseMetadata, +): PolymorfaError { + const record = + typeof body === 'object' && body !== null + ? (body as Record) + : {} + const nested = + typeof record.error === 'object' && record.error !== null + ? (record.error as Record) + : {} + const code = + typeof nested.code === 'string' + ? nested.code + : typeof record.code === 'string' + ? record.code + : undefined + const message = + typeof nested.message === 'string' + ? nested.message + : typeof record.error === 'string' + ? record.error + : typeof record.message === 'string' + ? record.message + : `Polymorfa API request failed with status ${status}.` + const options: PolymorfaErrorOptions = { + details: body, + metadata, + status, + ...(code === undefined ? {} : {code}), + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + } + if (status === 400 || status === 422) + return new PolymorfaValidationError(message, options) + if (status === 401) return new PolymorfaAuthenticationError(message, options) + if (status === 402) return new PolymorfaPaymentRequiredError(message, options) + if (status === 403) return new PolymorfaAuthorizationError(message, options) + if (status === 404) return new PolymorfaNotFoundError(message, options) + if (status === 409) return new PolymorfaConflictError(message, options) + if (status === 429) return new PolymorfaRateLimitError(message, options) + if (status >= 500) return new PolymorfaServerError(message, options) + return new PolymorfaError(message, options) +} diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index 10d2b06d..32a3444d 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -12,6 +12,8 @@ import { const API_KEY = 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' const CLIENT_TOKEN = 'pmfa_ct_browser_token' +const PROJECT_TOKEN = + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' const TRUNK_ID = '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10' const DEFAULT_RETENTION = { appliesTo: ['call_records', 'call_events', 'client_reports'], @@ -697,9 +699,7 @@ describe('unified calls commands', () => { } expect(mismatched.stderr).toContain('omit --days or use --policy custom') expect(exitCode(token.error)).toBe(2) - expect(token.stderr).toContain( - 'Platform API requests require POLYMORFA_API_KEY', - ) + expect(token.stderr).toContain('browser client tokens are refused') expect(server.requests).toHaveLength(0) }) @@ -815,6 +815,41 @@ describe('unified calls commands', () => { ) }) + test('reads team call retention with a project token and lets the API refuse its changes', async () => { + server = await startHttpSequenceServer([ + {body: {data: DEFAULT_RETENTION, success: true}}, + {body: {data: DEFAULT_RETENTION, success: true}}, + apiError( + 403, + 'forbidden', + 'call retention is a team setting; use a team API key to change it', + ), + ]) + + const read = await projectToken(() => + runCommand('calls retention get --json'), + ) + const change = await projectToken(() => + runCommand( + 'calls retention set --policy compliance --expected-revision 0 --json', + ), + ) + + expect(read.stderr).toBe('') + expect(read.error).toBeUndefined() + expect(jsonData(read.stdout)).toEqual(DEFAULT_RETENTION) + // The change reaches the API, which decides; no local credential refusal. + expect(exitCode(change.error)).toBe(3) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/call-retention', + 'GET /platform/call-retention', + 'PUT /platform/call-retention', + ]) + for (const request of server.requests) { + expect(request.headers.authorization).toBe(`Bearer ${PROJECT_TOKEN}`) + } + }) + test('distinguishes the unified and session-route reject commands in help', async () => { const unified = await runCommand('calls reject --help') const legacy = await runCommand('call reject --help') @@ -835,6 +870,19 @@ describe('unified calls commands', () => { ) } + async function projectToken(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: PROJECT_TOKEN, + }, + run, + ) + } + async function clientToken(run: () => Promise): Promise { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( diff --git a/test/sdk/project-token-request.test.ts b/test/sdk/project-token-request.test.ts new file mode 100644 index 00000000..a30680cf --- /dev/null +++ b/test/sdk/project-token-request.test.ts @@ -0,0 +1,109 @@ +import { + PolymorfaAuthorizationError, + PolymorfaConfigurationError, + PolymorfaConflictError, + PolymorfaServerError, +} from '@polymorfa/sdk' +import {describe, expect, test, vi} from 'vitest' + +import type {TeamSettingEnvironment} from '../../src/context/environment.js' +import {projectTokenRequest} from '../../src/sdk/project-token-request.js' + +const TOKEN = 'pmfa_pt_example' +const environment = (baseUrl: string): TeamSettingEnvironment => ({ + apiVersion: '2026-09-01', + baseUrl, + credential: TOKEN, + principalType: 'project_token', +}) +const json = ( + status: number, + body: unknown, + headers: Record = {}, +) => + new Response(JSON.stringify(body), { + headers: {'content-type': 'application/json', ...headers}, + status, + }) + +describe('projectTokenRequest', () => { + test('sends the token, version and body and returns data with metadata', async () => { + const fetch = vi.fn(() => + Promise.resolve( + json( + 200, + {data: {policy: 'short'}, success: true}, + {'x-request-id': 'req_1'}, + ), + ), + ) + const result = await projectTokenRequest( + environment('https://api.example.com'), + { + body: {policy: 'short'}, + method: 'PUT', + path: '/platform/call-retention', + }, + fetch as unknown as typeof globalThis.fetch, + ) + expect(result.data).toEqual({data: {policy: 'short'}, success: true}) + expect(result.metadata).toMatchObject({ + attempts: 1, + requestId: 'req_1', + status: 200, + }) + const [url, init] = fetch.mock.calls[0] as unknown as [URL, RequestInit] + expect(String(url)).toBe('https://api.example.com/platform/call-retention') + expect(init).toMatchObject({ + body: JSON.stringify({policy: 'short'}), + method: 'PUT', + redirect: 'error', + }) + expect(init.headers).toMatchObject({ + authorization: `Bearer ${TOKEN}`, + 'content-type': 'application/json', + 'polymorfa-version': '2026-09-01', + }) + }) + + test('maps API failures to the SDK error classes', async () => { + const call = (response: Response) => + projectTokenRequest( + environment('https://api.example.com'), + {method: 'GET', path: '/platform/call-retention'}, + () => Promise.resolve(response), + ) + await expect( + call( + json(403, {error: {code: 'forbidden', message: 'use a team API key'}}), + ), + ).rejects.toMatchObject({ + code: 'forbidden', + constructor: PolymorfaAuthorizationError, + message: 'use a team API key', + }) + await expect( + call(json(409, {error: {code: 'state_conflict', message: 'changed'}})), + ).rejects.toBeInstanceOf(PolymorfaConflictError) + await expect( + call( + new Response('bad gateway', { + headers: {'content-type': 'text/html'}, + status: 200, + }), + ), + ).rejects.toBeInstanceOf(PolymorfaServerError) + }) + + test('refuses plain http except on loopback before sending the token', async () => { + const fetch = vi.fn() + await expect( + projectTokenRequest( + environment('http://api.example.com'), + {method: 'GET', path: '/platform/call-retention'}, + fetch as unknown as typeof globalThis.fetch, + ), + ).rejects.toBeInstanceOf(PolymorfaConfigurationError) + expect(fetch).not.toHaveBeenCalled() + }) +}) From cacda64acf42a5e72591af65147de3cd82bb85ba Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 12:38:28 +0200 Subject: [PATCH 3/9] docs(calls): describe both call retention transports accurately --- docs/command-research/calls.md | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index 384ac262..d6791993 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -84,12 +84,15 @@ internal SDK module. revision returns 409 `state_conflict`, which exits with code 4. - `calls retention` resolves a team API key or a project token (`resolveTeamSettingEnvironment`), from the environment or a profile, with - no project context. The pinned SDK has no call-retention resource, and its - project-token client confines raw paths to `/platform/projects/{id}`, so - `createCallRetentionClient` in `src/sdk/client.ts` sends `GET` and - `PUT /platform/call-retention` through the unconfined organization raw - transport for both principals (as the project lookup does); the API - identifies the principal from the bearer credential. Switch to the SDK's + no project context, and the API decides what each principal may do. The + pinned SDK has no call-retention resource. `createCallRetentionClient` in + `src/sdk/client.ts` sends `GET` and `PUT /platform/call-retention` through + the SDK organization client's raw transport for team API keys. The pinned + SDK confines a project-token client's raw paths to `/platform/projects/{id}` + and refuses a project token as an organization key, so project tokens use + `projectTokenRequest` (`src/sdk/project-token-request.ts`): one request with + the SDK's headers, no redirects, https except on loopback, and failures + mapped to the SDK error classes. Switch to the SDK's `callRetention` methods (polymorfa/sdks#278, which serve both principals) once a release that includes them is pinned. The API lets team API keys and project tokens read the setting and only team API keys change it; the CLI From 5fea391958c64543c792f5f69ca91aa7a8b7e931 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 14:25:35 +0200 Subject: [PATCH 4/9] fix(calls): state that retention set needs sessions:read and sessions:manage calls retention set reads the setting before it changes it, and the API does not let sessions:manage imply sessions:read. The help text, README and command research now name both scopes, and a 403 on the preliminary read gets the change message instead of the read-only one. --- README.md | 3 ++- docs/command-research/calls.md | 4 +++- src/commands/calls/retention/set.ts | 2 +- src/services/calls.ts | 6 +++--- test/commands/calls.test.ts | 25 +++++++++++++++++++++++++ 5 files changed, 34 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index 02825820..a15fae84 100644 --- a/README.md +++ b/README.md @@ -662,7 +662,8 @@ never answers or declines on its own. shortens the period; `--yes` confirms without a prompt, and without a terminal the command exits with code 2 (`confirmation_required`) unless you pass `--yes`. Changing retention requires a team API key with the - `sessions:manage` scope. Project tokens can read the setting; the API + `sessions:read` and `sessions:manage` scopes, because the command reads the + setting before it changes it. Project tokens can read the setting; the API refuses their changes, and the command exits with code 3. `calls watch` streams inbound calls and their progress: `incoming`, `claim`, diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index d6791993..af53edbc 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -97,7 +97,9 @@ internal SDK module. once a release that includes them is pinned. The API lets team API keys and project tokens read the setting and only team API keys change it; the CLI replaces the API's 403 text with that rule and keeps the API message. -- `calls retention set` requires `--expected-revision`. It reads the setting, +- `calls retention set` requires `--expected-revision`. It reads the setting + (`GET` needs `sessions:read`, which `sessions:manage` does not imply, so the + command needs both scopes; a 403 on either request gets the change message), fails locally with `state_conflict` (exit 4) when the revision differs, asks for confirmation when the new period is shorter (`--yes`, or exit 2 `confirmation_required` without a terminal), then sends `policy`, diff --git a/src/commands/calls/retention/set.ts b/src/commands/calls/retention/set.ts index c4e6c573..525e661a 100644 --- a/src/commands/calls/retention/set.ts +++ b/src/commands/calls/retention/set.ts @@ -15,7 +15,7 @@ import { } from '../../../services/calls.js' export default class CallsRetentionSet extends BaseCommand { - static override description = `Change how long Polymorfa keeps your team’s call data through the Platform API. --policy short, standard, extended, or compliance keeps call data for 7, 30, 90, or 365 days; --policy custom keeps it for --days (${MIN_CALL_RETENTION_DAYS} to ${MAX_CALL_RETENTION_DAYS}). Deletion of call data older than the period starts on a date Polymorfa announces in its changelog; until then saving a period deletes nothing. Once deletion runs, a shorter period also applies to call data already stored, and deleted call data cannot be recovered, so the command asks you to confirm a shorter period; pass --yes to confirm without a prompt (required when standard input or error is not a terminal). --expected-revision is required: the command reads the setting first and changes it only if it still has that revision (see \`calls retention get\`); otherwise it fails with state_conflict (exit code 4) and changes nothing. Requires a team API key with the sessions:manage scope; the API refuses project tokens (exit code 3).` + static override description = `Change how long Polymorfa keeps your team’s call data through the Platform API. --policy short, standard, extended, or compliance keeps call data for 7, 30, 90, or 365 days; --policy custom keeps it for --days (${MIN_CALL_RETENTION_DAYS} to ${MAX_CALL_RETENTION_DAYS}). Deletion of call data older than the period starts on a date Polymorfa announces in its changelog; until then saving a period deletes nothing. Once deletion runs, a shorter period also applies to call data already stored, and deleted call data cannot be recovered, so the command asks you to confirm a shorter period; pass --yes to confirm without a prompt (required when standard input or error is not a terminal). --expected-revision is required: the command reads the setting first and changes it only if it still has that revision (see \`calls retention get\`); otherwise it fails with state_conflict (exit code 4) and changes nothing. Requires a team API key with the sessions:read and sessions:manage scopes, because the command reads the setting before it changes it; the API refuses project tokens (exit code 3).` static override examples = [ '<%= config.bin %> calls retention set --policy compliance --expected-revision 0', diff --git a/src/services/calls.ts b/src/services/calls.ts index 190b2c95..a5da1d54 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -378,8 +378,8 @@ export async function updateCallRetention( confirmShorter: ConfirmShorterRetention, ): Promise> { const retentionDays = callRetentionDays(change.policy, change.retentionDays) - const current = (await retentionRequest('read', () => client.retrieve())).data - .data + const current = (await retentionRequest('change', () => client.retrieve())) + .data.data if (current.revision !== change.expectedRevision) { throw new PolymorfaConflictError(retentionConflict, { code: 'state_conflict', @@ -448,7 +448,7 @@ const retentionConflict = const retentionForbidden = { change: - 'Call retention is a team setting; use a team API key with the sessions:manage scope to change it.', + 'Call retention is a team setting; use a team API key with the sessions:read and sessions:manage scopes to change it (the command reads the setting before it changes it).', read: 'Call retention is a team setting; use a team API key or project token with the sessions:read scope to read it.', } as const diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index 32a3444d..f32dcc2e 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -815,6 +815,31 @@ describe('unified calls commands', () => { ) }) + test('explains that changing call retention also needs sessions:read for the first read', async () => { + server = await startHttpSequenceServer([ + apiError(403, 'missing_scope', 'This action requires sessions:read.'), + ]) + + const result = await apiKey(() => + runCommand( + 'calls retention set --policy compliance --expected-revision 0 --json', + ), + ) + + expect(exitCode(result.error)).toBe(3) + const envelope = JSON.parse(result.stderr.trim()) as { + error: {category: string; message: string} + } + expect(envelope.error.category).toBe('authorization') + expect(envelope.error.message).toContain( + 'use a team API key with the sessions:read and sessions:manage scopes to change it', + ) + expect(envelope.error.message).toContain( + 'This action requires sessions:read.', + ) + expect(server.requests.map(({method}) => method)).toEqual(['GET']) + }) + test('reads team call retention with a project token and lets the API refuse its changes', async () => { server = await startHttpSequenceServer([ {body: {data: DEFAULT_RETENTION, success: true}}, From 88ba36f282f3ff533261f9954ebfd318aeff83d2 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 13:06:39 +0200 Subject: [PATCH 5/9] Use typed SDK retention for team API keys --- src/sdk/client.ts | 69 +++++++++++----------------- src/services/calls.ts | 6 +-- test/commands/calls.test.ts | 16 +++++++ test/services/call-retention.test.ts | 15 ++---- 4 files changed, 52 insertions(+), 54 deletions(-) diff --git a/src/sdk/client.ts b/src/sdk/client.ts index ba6f0716..057e52ee 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -3,9 +3,12 @@ import { MessagingClient, PolymorfaConfigurationError, type ApiResponse, + type CallRetention, + type CallRetentionPolicy, type ClientInstance, type RawClient, type SharedClientOptions, + type UpdateCallRetentionRequest, } from '@polymorfa/sdk' import type { @@ -58,17 +61,7 @@ export function createCallSettingsClient( }) } -export type CallRetentionPolicy = - 'short' | 'standard' | 'extended' | 'compliance' | 'custom' - -/** Team call data retention, as `/platform/call-retention` returns it. */ -export interface CallRetention { - readonly appliesTo: readonly string[] - readonly policy: CallRetentionPolicy - readonly retentionDays: number - readonly revision: number - readonly updatedAt: string | null -} +export type {CallRetention, CallRetentionPolicy} export interface UpdateCallRetentionInput { readonly expectedRevision?: number @@ -77,41 +70,43 @@ export interface UpdateCallRetentionInput { } export interface CallRetentionClient { - retrieve(): Promise> - update( - input: UpdateCallRetentionInput, - ): Promise> + retrieve(): Promise> + update(input: UpdateCallRetentionInput): Promise> } /** - * Team call data retention on the Platform API. The pinned SDK has no typed - * resource for it yet. Team API keys use the SDK organization client's raw - * transport, which keeps its credential, retry, and error mapping. Project - * tokens may read this team-wide route, but the pinned SDK confines a - * project-token client to `/platform/projects/{id}`, so they use - * `projectTokenRequest`; the API decides what each principal may do. Replace - * both with the SDK's `callRetention` resource once a release that includes - * it is pinned (polymorfa/sdks#278). + * Team API keys use the typed SDK resource. Project tokens can read this + * team-wide route without a project ID, but the SDK's project-token client + * currently requires one; keep the narrow request bridge until the SDK has + * an explicit team-wide project-token entrypoint. */ export function createCallRetentionClient( environment: TeamSettingEnvironment, ): CallRetentionClient { if (environment.principalType === 'project_token') { return { - retrieve: () => - projectTokenRequest<{readonly data: CallRetention}>(environment, { + retrieve: async () => { + const response = await projectTokenRequest<{ + readonly data: CallRetention + }>(environment, { method: 'GET', path: '/platform/call-retention', - }), - update: (input) => - projectTokenRequest<{readonly data: CallRetention}>(environment, { + }) + return {data: response.data.data, metadata: response.metadata} + }, + update: async (input) => { + const response = await projectTokenRequest<{ + readonly data: CallRetention + }>(environment, { body: input, method: 'PUT', path: '/platform/call-retention', - }), + }) + return {data: response.data.data, metadata: response.metadata} + }, } } - const raw = new Client({ + const callRetention = new Client({ baseUrl: environment.baseUrl, credential: {type: 'organizationApiKey', value: environment.credential}, ...(environment.apiVersion === undefined @@ -120,19 +115,11 @@ export function createCallRetentionClient( ...(environment.timeoutMs === undefined ? {} : {timeoutMs: environment.timeoutMs}), - }).raw + }).callRetention return { - retrieve: () => - raw.request<{readonly data: CallRetention}>({ - method: 'GET', - path: '/platform/call-retention', - }), + retrieve: () => callRetention.retrieve(), update: (input) => - raw.request<{readonly data: CallRetention}>({ - body: input, - method: 'PUT', - path: '/platform/call-retention', - }), + callRetention.update(input as UpdateCallRetentionRequest), } } diff --git a/src/services/calls.ts b/src/services/calls.ts index a5da1d54..34d72b33 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -348,7 +348,7 @@ export async function viewCallRetention( client: CallRetentionClient, ): Promise> { const response = await retentionRequest('read', () => client.retrieve()) - return result(response.data.data, response.metadata) + return result(response.data, response.metadata) } export interface CallRetentionChange { @@ -379,7 +379,7 @@ export async function updateCallRetention( ): Promise> { const retentionDays = callRetentionDays(change.policy, change.retentionDays) const current = (await retentionRequest('change', () => client.retrieve())) - .data.data + .data if (current.revision !== change.expectedRevision) { throw new PolymorfaConflictError(retentionConflict, { code: 'state_conflict', @@ -405,7 +405,7 @@ export async function updateCallRetention( : {retentionDays: change.retentionDays}), }), ) - return result(response.data.data, response.metadata) + return result(response.data, response.metadata) } /** The period a change sets, checked the way the API checks it. */ diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index f32dcc2e..04e7d96f 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -589,6 +589,22 @@ describe('unified calls commands', () => { expect(server.requests[0]?.headers.authorization).toBe(`Bearer ${API_KEY}`) }) + test('rejects a malformed retention envelope without printing a setting', async () => { + server = await startHttpServer({ + body: {success: true}, + headers: {'x-request-id': 'req_bad_retention'}, + }) + + const result = await apiKey(() => runCommand('calls retention get --json')) + + expect(exitCode(result.error)).not.toBe(0) + expect(JSON.parse(result.stderr.trim())).toMatchObject({ + error: {code: 'invalid_response'}, + }) + expect(result.stdout).toBe('') + expect(server.requests).toHaveLength(1) + }) + test('changes team call retention after checking the revision', async () => { const saved = { ...DEFAULT_RETENTION, diff --git a/test/services/call-retention.test.ts b/test/services/call-retention.test.ts index 48e79b79..d992bf4b 100644 --- a/test/services/call-retention.test.ts +++ b/test/services/call-retention.test.ts @@ -97,20 +97,15 @@ function fakeClient(): { return { updates, client: { - retrieve: () => Promise.resolve({data: {data: CURRENT}, metadata}), + retrieve: () => Promise.resolve({data: CURRENT, metadata}), update: (input) => { updates.push(input) return Promise.resolve({ data: { - data: { - ...CURRENT, - policy: input.policy, - retentionDays: callRetentionDays( - input.policy, - input.retentionDays, - ), - revision: 1, - }, + ...CURRENT, + policy: input.policy, + retentionDays: callRetentionDays(input.policy, input.retentionDays), + revision: 1, }, metadata, }) From b6356a76517074e217bb93dcda9164497758dbb6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 13:11:05 +0200 Subject: [PATCH 6/9] Use typed SDK retention for project tokens --- src/sdk/client.ts | 43 ++---- src/sdk/project-token-request.ts | 191 ------------------------- test/sdk/project-token-request.test.ts | 109 -------------- 3 files changed, 12 insertions(+), 331 deletions(-) delete mode 100644 src/sdk/project-token-request.ts delete mode 100644 test/sdk/project-token-request.test.ts diff --git a/src/sdk/client.ts b/src/sdk/client.ts index 057e52ee..e727749f 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -2,6 +2,7 @@ import { Client, MessagingClient, PolymorfaConfigurationError, + createTeamCallRetentionClient, type ApiResponse, type CallRetention, type CallRetentionPolicy, @@ -15,7 +16,6 @@ import type { ResolvedEnvironment, TeamSettingEnvironment, } from '../context/environment.js' -import {projectTokenRequest} from './project-token-request.js' export function createRawClient(environment: ResolvedEnvironment): RawClient { return environment.surface === 'platform' @@ -75,47 +75,28 @@ export interface CallRetentionClient { } /** - * Team API keys use the typed SDK resource. Project tokens can read this - * team-wide route without a project ID, but the SDK's project-token client - * currently requires one; keep the narrow request bridge until the SDK has - * an explicit team-wide project-token entrypoint. + * Both team API keys and project tokens use the SDK's team-wide retention + * resource. The API decides which credential may change the setting. */ export function createCallRetentionClient( environment: TeamSettingEnvironment, ): CallRetentionClient { - if (environment.principalType === 'project_token') { - return { - retrieve: async () => { - const response = await projectTokenRequest<{ - readonly data: CallRetention - }>(environment, { - method: 'GET', - path: '/platform/call-retention', - }) - return {data: response.data.data, metadata: response.metadata} - }, - update: async (input) => { - const response = await projectTokenRequest<{ - readonly data: CallRetention - }>(environment, { - body: input, - method: 'PUT', - path: '/platform/call-retention', - }) - return {data: response.data.data, metadata: response.metadata} - }, - } - } - const callRetention = new Client({ + const callRetention = createTeamCallRetentionClient({ baseUrl: environment.baseUrl, - credential: {type: 'organizationApiKey', value: environment.credential}, + credential: { + type: + environment.principalType === 'project_token' + ? 'projectToken' + : 'organizationApiKey', + value: environment.credential, + }, ...(environment.apiVersion === undefined ? {} : {apiVersion: environment.apiVersion}), ...(environment.timeoutMs === undefined ? {} : {timeoutMs: environment.timeoutMs}), - }).callRetention + }) return { retrieve: () => callRetention.retrieve(), update: (input) => diff --git a/src/sdk/project-token-request.ts b/src/sdk/project-token-request.ts deleted file mode 100644 index 927d2412..00000000 --- a/src/sdk/project-token-request.ts +++ /dev/null @@ -1,191 +0,0 @@ -import { - PolymorfaAuthenticationError, - PolymorfaAuthorizationError, - PolymorfaConfigurationError, - PolymorfaConflictError, - PolymorfaConnectionError, - PolymorfaError, - PolymorfaNotFoundError, - PolymorfaPaymentRequiredError, - PolymorfaRateLimitError, - PolymorfaServerError, - PolymorfaTimeoutError, - PolymorfaValidationError, - type ApiResponse, - type PolymorfaErrorOptions, - type ResponseMetadata, -} from '@polymorfa/sdk' - -import type {TeamSettingEnvironment} from '../context/environment.js' - -const DEFAULT_TIMEOUT_MS = 30_000 -const SAFE_RESPONSE_HEADERS = [ - 'content-type', - 'x-request-id', - 'polymorfa-version', - 'retry-after', -] as const - -/** - * One request to a team-wide Platform route with a project token. - * - * The pinned SDK confines a project-token client's raw requests to - * `/platform/projects/{id}`, and it refuses a project token as an organization - * key, so it cannot reach a team-wide route such as `/platform/call-retention` - * that the API lets project tokens read. This sends exactly one request with - * the SDK's headers and maps failures to the SDK's error classes, so exit - * codes and redaction match SDK-backed commands. It does not retry. Remove it - * once a pinned SDK release serves the route for project tokens - * (polymorfa/sdks#278). - */ -export async function projectTokenRequest( - environment: TeamSettingEnvironment, - request: { - readonly body?: unknown - readonly method: 'GET' | 'PUT' - readonly path: string - }, - fetchImpl: typeof globalThis.fetch = globalThis.fetch, -): Promise> { - const url = requestUrl(environment.baseUrl, request.path) - const headers: Record = { - accept: 'application/json', - authorization: `Bearer ${environment.credential}`, - } - if (environment.apiVersion !== undefined) { - headers['polymorfa-version'] = environment.apiVersion - } - if (request.body !== undefined) headers['content-type'] = 'application/json' - - let response: Response - try { - response = await fetchImpl(url, { - ...(request.body === undefined - ? {} - : {body: JSON.stringify(request.body)}), - headers, - method: request.method, - redirect: 'error', - signal: AbortSignal.timeout(environment.timeoutMs ?? DEFAULT_TIMEOUT_MS), - }) - } catch (error) { - if (error instanceof DOMException && error.name === 'TimeoutError') { - throw new PolymorfaTimeoutError('The Polymorfa API request timed out.', { - cause: error, - }) - } - throw new PolymorfaConnectionError('Could not reach the Polymorfa API.', { - cause: error, - }) - } - - const metadata = responseMetadata(response) - const body = await readBody(response) - if (!response.ok) throw apiError(response.status, body, metadata) - if (typeof body !== 'object' || body === null) { - throw new PolymorfaServerError( - 'The Polymorfa API returned a response that is not JSON.', - {metadata, status: response.status}, - ) - } - return {data: body as T, metadata} -} - -function requestUrl(baseUrl: string, path: string): URL { - let url: URL - try { - url = new URL(path, baseUrl) - } catch { - throw new PolymorfaConfigurationError( - 'POLYMORFA_BASE_URL is not a valid URL.', - 'POLYMORFA_BASE_URL', - ) - } - const loopback = ['localhost', '127.0.0.1', '[::1]'].includes(url.hostname) - if (url.protocol !== 'https:' && !(url.protocol === 'http:' && loopback)) { - throw new PolymorfaConfigurationError( - 'POLYMORFA_BASE_URL must use https (plain http is allowed only on loopback).', - 'POLYMORFA_BASE_URL', - ) - } - return url -} - -async function readBody(response: Response): Promise { - const text = await response.text() - const contentType = response.headers.get('content-type') ?? '' - if (!contentType.includes('application/json')) return text - try { - return JSON.parse(text) as unknown - } catch { - return text - } -} - -function responseMetadata(response: Response): ResponseMetadata { - const headerRecord: Record = {} - for (const key of SAFE_RESPONSE_HEADERS) { - const value = response.headers.get(key) - if (value !== null) headerRecord[key] = value - } - const requestId = - response.headers.get('x-request-id') ?? - response.headers.get('request-id') ?? - undefined - const apiVersion = response.headers.get('polymorfa-version') ?? undefined - return Object.freeze({ - attempts: 1, - headers: Object.freeze(headerRecord), - status: response.status, - ...(requestId === undefined ? {} : {requestId}), - ...(apiVersion === undefined ? {} : {apiVersion}), - }) -} - -function apiError( - status: number, - body: unknown, - metadata: ResponseMetadata, -): PolymorfaError { - const record = - typeof body === 'object' && body !== null - ? (body as Record) - : {} - const nested = - typeof record.error === 'object' && record.error !== null - ? (record.error as Record) - : {} - const code = - typeof nested.code === 'string' - ? nested.code - : typeof record.code === 'string' - ? record.code - : undefined - const message = - typeof nested.message === 'string' - ? nested.message - : typeof record.error === 'string' - ? record.error - : typeof record.message === 'string' - ? record.message - : `Polymorfa API request failed with status ${status}.` - const options: PolymorfaErrorOptions = { - details: body, - metadata, - status, - ...(code === undefined ? {} : {code}), - ...(metadata.requestId === undefined - ? {} - : {requestId: metadata.requestId}), - } - if (status === 400 || status === 422) - return new PolymorfaValidationError(message, options) - if (status === 401) return new PolymorfaAuthenticationError(message, options) - if (status === 402) return new PolymorfaPaymentRequiredError(message, options) - if (status === 403) return new PolymorfaAuthorizationError(message, options) - if (status === 404) return new PolymorfaNotFoundError(message, options) - if (status === 409) return new PolymorfaConflictError(message, options) - if (status === 429) return new PolymorfaRateLimitError(message, options) - if (status >= 500) return new PolymorfaServerError(message, options) - return new PolymorfaError(message, options) -} diff --git a/test/sdk/project-token-request.test.ts b/test/sdk/project-token-request.test.ts deleted file mode 100644 index a30680cf..00000000 --- a/test/sdk/project-token-request.test.ts +++ /dev/null @@ -1,109 +0,0 @@ -import { - PolymorfaAuthorizationError, - PolymorfaConfigurationError, - PolymorfaConflictError, - PolymorfaServerError, -} from '@polymorfa/sdk' -import {describe, expect, test, vi} from 'vitest' - -import type {TeamSettingEnvironment} from '../../src/context/environment.js' -import {projectTokenRequest} from '../../src/sdk/project-token-request.js' - -const TOKEN = 'pmfa_pt_example' -const environment = (baseUrl: string): TeamSettingEnvironment => ({ - apiVersion: '2026-09-01', - baseUrl, - credential: TOKEN, - principalType: 'project_token', -}) -const json = ( - status: number, - body: unknown, - headers: Record = {}, -) => - new Response(JSON.stringify(body), { - headers: {'content-type': 'application/json', ...headers}, - status, - }) - -describe('projectTokenRequest', () => { - test('sends the token, version and body and returns data with metadata', async () => { - const fetch = vi.fn(() => - Promise.resolve( - json( - 200, - {data: {policy: 'short'}, success: true}, - {'x-request-id': 'req_1'}, - ), - ), - ) - const result = await projectTokenRequest( - environment('https://api.example.com'), - { - body: {policy: 'short'}, - method: 'PUT', - path: '/platform/call-retention', - }, - fetch as unknown as typeof globalThis.fetch, - ) - expect(result.data).toEqual({data: {policy: 'short'}, success: true}) - expect(result.metadata).toMatchObject({ - attempts: 1, - requestId: 'req_1', - status: 200, - }) - const [url, init] = fetch.mock.calls[0] as unknown as [URL, RequestInit] - expect(String(url)).toBe('https://api.example.com/platform/call-retention') - expect(init).toMatchObject({ - body: JSON.stringify({policy: 'short'}), - method: 'PUT', - redirect: 'error', - }) - expect(init.headers).toMatchObject({ - authorization: `Bearer ${TOKEN}`, - 'content-type': 'application/json', - 'polymorfa-version': '2026-09-01', - }) - }) - - test('maps API failures to the SDK error classes', async () => { - const call = (response: Response) => - projectTokenRequest( - environment('https://api.example.com'), - {method: 'GET', path: '/platform/call-retention'}, - () => Promise.resolve(response), - ) - await expect( - call( - json(403, {error: {code: 'forbidden', message: 'use a team API key'}}), - ), - ).rejects.toMatchObject({ - code: 'forbidden', - constructor: PolymorfaAuthorizationError, - message: 'use a team API key', - }) - await expect( - call(json(409, {error: {code: 'state_conflict', message: 'changed'}})), - ).rejects.toBeInstanceOf(PolymorfaConflictError) - await expect( - call( - new Response('bad gateway', { - headers: {'content-type': 'text/html'}, - status: 200, - }), - ), - ).rejects.toBeInstanceOf(PolymorfaServerError) - }) - - test('refuses plain http except on loopback before sending the token', async () => { - const fetch = vi.fn() - await expect( - projectTokenRequest( - environment('http://api.example.com'), - {method: 'GET', path: '/platform/call-retention'}, - fetch as unknown as typeof globalThis.fetch, - ), - ).rejects.toBeInstanceOf(PolymorfaConfigurationError) - expect(fetch).not.toHaveBeenCalled() - }) -}) From a98c4a4174425ea60fefa58c1628d0ea1fb7bd2a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 13:30:20 +0200 Subject: [PATCH 7/9] Pin published SDK retention prerelease --- package-lock.json | 9 +++++---- package.json | 2 +- 2 files changed, 6 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index 31d2ef0f..6f7fdb3b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260923093259", + "@polymorfa/sdk": "0.1.0-dev.20260923112126", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,9 +997,10 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20260923093259", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923093259.tgz", - "integrity": "sha512-rmSvbH1Tp6gGEPC0asXGaZnX8swxHniDzcK9XaQLTVkMciqjRxDDP+DQeA5jVI6cI3U08Paev15ek43ziCywFQ==", + "version": "0.1.0-dev.20260923112126", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923112126.tgz", + "integrity": "sha512-9kEsgqv3kJHvb4L5fGnbGaFa0QiCU/R/aQb4xA6H2wrQI/nlTNQAKYzukHXRnia+AjCLpEfRCSDhKUk97U05Dw==", + "license": "MIT", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index 536ec79a..7afb00b8 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260923093259", + "@polymorfa/sdk": "0.1.0-dev.20260923112126", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, From 9cf78c8036a3b02f5366e02aa92831d3770293bc Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 13:48:41 +0200 Subject: [PATCH 8/9] docs(cli): describe typed retention SDK adapter --- docs/command-research/calls.md | 23 ++++++++--------------- 1 file changed, 8 insertions(+), 15 deletions(-) diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index 10903905..e4f47d17 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -54,8 +54,8 @@ calls | `calls invite` | `POST /messaging/voip/calls/{id}/participants` | `voip.addParticipant` | | `calls settings get` | `GET /platform/sessions/{session}/call-settings` | `voip.retrieveCallSettings` | | `calls settings set` | `PUT /platform/sessions/{session}/call-settings` | `voip.updateCallSettings` | -| `calls retention get` | `GET /platform/call-retention` | raw request (CLI adapter) | -| `calls retention set` | `PUT /platform/call-retention` | raw request (CLI adapter) | +| `calls retention get` | `GET /platform/call-retention` | `callRetention.retrieve` | +| `calls retention set` | `PUT /platform/call-retention` | `callRetention.update` | | `sip-trunk endpoint` | `GET /platform/sip/endpoint` | `sipTrunks.endpoint` | | `sip-trunk list` | `GET /platform/sip-trunks` | `sipTrunks.list` | | `sip-trunk view` | `GET /platform/sip-trunks/{id}` | `sipTrunks.retrieve` | @@ -97,19 +97,12 @@ internal SDK module. revision returns 409 `state_conflict`, which exits with code 4. - `calls retention` resolves a team API key or a project token (`resolveTeamSettingEnvironment`), from the environment or a profile, with - no project context, and the API decides what each principal may do. The - pinned SDK has no call-retention resource. `createCallRetentionClient` in - `src/sdk/client.ts` sends `GET` and `PUT /platform/call-retention` through - the SDK organization client's raw transport for team API keys. The pinned - SDK confines a project-token client's raw paths to `/platform/projects/{id}` - and refuses a project token as an organization key, so project tokens use - `projectTokenRequest` (`src/sdk/project-token-request.ts`): one request with - the SDK's headers, no redirects, https except on loopback, and failures - mapped to the SDK error classes. Switch to the SDK's - `callRetention` methods (polymorfa/sdks#278, which serve both principals) - once a release that includes them is pinned. The API lets team API keys and - project tokens read the setting and only team API keys change it; the CLI - replaces the API's 403 text with that rule and keeps the API message. + no project context, and the API decides what each principal may do. + `createCallRetentionClient` in `src/sdk/client.ts` uses the pinned SDK's + `createTeamCallRetentionClient` for both credential types. The API lets team + API keys and project tokens read the setting and only team API keys change + it; the CLI replaces the API's 403 text with that rule and keeps the API + message. - `calls retention set` requires `--expected-revision`. It reads the setting (`GET` needs `sessions:read`, which `sessions:manage` does not imply, so the command needs both scopes; a 403 on either request gets the change message), From df89c75c0191806baa02a68dfdae38c077b5ab80 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 14:04:56 +0200 Subject: [PATCH 9/9] fix(cli): retain API diagnostic links in retention errors --- src/services/calls.ts | 4 ++++ test/services/call-retention.test.ts | 22 ++++++++++++++++++++++ 2 files changed, 26 insertions(+) diff --git a/src/services/calls.ts b/src/services/calls.ts index 34d72b33..68ade6ef 100644 --- a/src/services/calls.ts +++ b/src/services/calls.ts @@ -475,6 +475,10 @@ async function retentionRequest( ...(error.code === undefined ? {} : {code: error.code}), ...(error.status === undefined ? {} : {status: error.status}), ...(error.requestId === undefined ? {} : {requestId: error.requestId}), + ...(error.requestLogUrl === undefined + ? {} + : {requestLogUrl: error.requestLogUrl}), + ...(error.docUrl === undefined ? {} : {docUrl: error.docUrl}), ...(error.metadata === undefined ? {} : {metadata: error.metadata}), } throw error instanceof PolymorfaConflictError diff --git a/test/services/call-retention.test.ts b/test/services/call-retention.test.ts index d992bf4b..dfe71638 100644 --- a/test/services/call-retention.test.ts +++ b/test/services/call-retention.test.ts @@ -1,5 +1,6 @@ import {PassThrough, Readable} from 'node:stream' +import {PolymorfaAuthorizationError} from '@polymorfa/sdk' import {describe, expect, test} from 'vitest' import {confirmDangerous} from '../../src/input/confirmation.js' @@ -11,6 +12,7 @@ import type { import { callRetentionDays, updateCallRetention, + viewCallRetention, } from '../../src/services/calls.js' const CURRENT: CallRetention = { @@ -22,6 +24,26 @@ const CURRENT: CallRetention = { } describe('call retention changes', () => { + test('preserves API diagnostic links when explaining a forbidden read', async () => { + const {client} = fakeClient() + client.retrieve = () => + Promise.reject( + new PolymorfaAuthorizationError('Missing sessions:read', { + code: 'forbidden', + status: 403, + requestId: 'req_retention', + requestLogUrl: 'https://console.polymorfa.com/requests/req_retention', + docUrl: 'https://docs.polymorfa.com/api/errors', + }), + ) + + await expect(viewCallRetention(client)).rejects.toMatchObject({ + requestId: 'req_retention', + requestLogUrl: 'https://console.polymorfa.com/requests/req_retention', + docUrl: 'https://docs.polymorfa.com/api/errors', + }) + }) + test('checks periods the way the API does', () => { expect(callRetentionDays('short', undefined)).toBe(7) expect(callRetentionDays('standard', 30)).toBe(30)