diff --git a/README.md b/README.md index 8b8c1e14..af11602a 100644 --- a/README.md +++ b/README.md @@ -661,6 +661,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 @@ -714,6 +718,29 @@ 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 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`. 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. + 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: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`, `state`, `participant_joined`, `participant_state`, `participant_left`, and diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index 91ce75f7..e4f47d17 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -54,6 +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` | `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` | @@ -93,6 +95,22 @@ 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 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. + `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), + 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-lock.json b/package-lock.json index ff07fcae..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.20260923101121", + "@polymorfa/sdk": "0.1.0-dev.20260923112126", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,9 +997,9 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20260923101121", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923101121.tgz", - "integrity": "sha512-Oi40GMCvOJhhnR5OTw5SAFLbcFo62zMl8Zv3b11jEh5hY6L572VMDVnJQASz/LkQ9mVV694NjyXdIsywH2v/fA==", + "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 f1358aa3..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.20260923101121", + "@polymorfa/sdk": "0.1.0-dev.20260923112126", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -102,6 +102,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/base-command.ts b/src/base-command.ts index 6f2b2cb9..b59510fb 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 {findProjectLink} from './config/project-link.js' @@ -180,6 +182,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 new file mode 100644 index 00000000..cb9036f4 --- /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. 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'] + + static override summary = 'Read team call data retention.' + + async run(): Promise { + const {flags} = await this.parse(CallsRetentionGet) + const result = await viewCallRetention( + 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 new file mode 100644 index 00000000..525e661a --- /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}). 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', + '<%= 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.teamSettingEnvironment(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? Once deletion runs, stored call data older than ${retentionDays} days is deleted and cannot be recovered.`, + yes: flags.yes, + }), + ) + this.writeValue(result, renderCallRetention(result.data), flags) + } +} diff --git a/src/context/environment.ts b/src/context/environment.ts index 86345d07..ece2b08d 100644 --- a/src/context/environment.ts +++ b/src/context/environment.ts @@ -445,6 +445,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 4cd16195..e727749f 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -2,12 +2,20 @@ import { Client, MessagingClient, PolymorfaConfigurationError, + createTeamCallRetentionClient, + type ApiResponse, + type CallRetention, + type CallRetentionPolicy, type ClientInstance, type RawClient, type SharedClientOptions, + type UpdateCallRetentionRequest, } from '@polymorfa/sdk' -import type {ResolvedEnvironment} from '../context/environment.js' +import type { + ResolvedEnvironment, + TeamSettingEnvironment, +} from '../context/environment.js' export function createRawClient(environment: ResolvedEnvironment): RawClient { return environment.surface === 'platform' @@ -53,6 +61,49 @@ export function createCallSettingsClient( }) } +export type {CallRetention, CallRetentionPolicy} + +export interface UpdateCallRetentionInput { + readonly expectedRevision?: number + readonly policy: CallRetentionPolicy + readonly retentionDays?: number +} + +export interface CallRetentionClient { + retrieve(): Promise> + update(input: UpdateCallRetentionInput): Promise> +} + +/** + * 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 { + const callRetention = createTeamCallRetentionClient({ + baseUrl: environment.baseUrl, + credential: { + type: + environment.principalType === 'project_token' + ? 'projectToken' + : 'organizationApiKey', + value: environment.credential, + }, + ...(environment.apiVersion === undefined + ? {} + : {apiVersion: environment.apiVersion}), + ...(environment.timeoutMs === undefined + ? {} + : {timeoutMs: environment.timeoutMs}), + }) + return { + retrieve: () => callRetention.retrieve(), + update: (input) => + callRetention.update(input as UpdateCallRetentionRequest), + } +} + export function createPlatformClient( environment: ResolvedEnvironment, ): ClientInstance<'organization'> { diff --git a/src/services/calls.ts b/src/services/calls.ts index 991c6990..68ade6ef 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,167 @@ 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, 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('change', () => client.retrieve())) + .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, 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: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 + +/** 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.requestLogUrl === undefined + ? {} + : {requestLogUrl: error.requestLogUrl}), + ...(error.docUrl === undefined ? {} : {docUrl: error.docUrl}), + ...(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 +575,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 c1fd0090..3fc4dc96 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -12,7 +12,16 @@ 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'], + policy: 'extended', + retentionDays: 90, + revision: 0, + updatedAt: null, +} describe('unified calls commands', () => { let server: TestHttpServer | undefined @@ -595,6 +604,330 @@ 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('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, + 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('browser client tokens are refused') + 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('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}}, + {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') @@ -615,6 +948,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/services/call-retention.test.ts b/test/services/call-retention.test.ts new file mode 100644 index 00000000..dfe71638 --- /dev/null +++ b/test/services/call-retention.test.ts @@ -0,0 +1,145 @@ +import {PassThrough, Readable} from 'node:stream' + +import {PolymorfaAuthorizationError} from '@polymorfa/sdk' +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, + viewCallRetention, +} 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('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) + 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: CURRENT, metadata}), + update: (input) => { + updates.push(input) + return Promise.resolve({ + 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} +}