Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 <n>` 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 <n>`: 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
Expand Down
18 changes: 18 additions & 0 deletions docs/command-research/calls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` |
Expand Down Expand Up @@ -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.

Expand Down
8 changes: 4 additions & 4 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down Expand Up @@ -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."
},
Expand Down
22 changes: 22 additions & 0 deletions src/base-command.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
import {
resolveCommandEnvironment,
resolveListenerEnvironment,
resolveTeamSettingEnvironment,
type TeamSettingEnvironment,
type ResolvedEnvironment,
} from './context/environment.js'
import {findProjectLink} from './config/project-link.js'
Expand Down Expand Up @@ -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<TeamSettingEnvironment> {
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,
Expand Down
23 changes: 23 additions & 0 deletions src/commands/calls/retention/get.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const {flags} = await this.parse(CallsRetentionGet)
const result = await viewCallRetention(
createCallRetentionClient(await this.teamSettingEnvironment(flags)),
)
this.writeValue(result, renderCallRetention(result.data), flags)
}
}
69 changes: 69 additions & 0 deletions src/commands/calls/retention/set.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
84 changes: 84 additions & 0 deletions src/context/environment.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<TeamSettingEnvironment> {
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 {
Expand Down
Loading