From 030c93028e00a6a81402ce31f9ab1e782a058f40 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 19 Sep 2026 18:33:43 +0200 Subject: [PATCH 1/6] feat(sip-trunk): show the environment's SIP address Add `sip-trunk endpoint`, which prints the SIP host, each transport with its port and SRTP requirement, the RTP UDP range and allow-list guidance, and reports sip_not_hosted without printing an address. `create` and `rotate-credentials` print the address after new inbound credentials in human output; a failed lookup never hides the password. The pinned SDK lacks sipTrunks.endpoint, so the command reads GET /platform/sip/endpoint through the SDK raw client and validates the response. Switch to the SDK method once it is released and pinned. --- README.md | 11 ++ docs/command-research/calls.md | 6 + src/commands/sip-trunk/create.ts | 18 +- src/commands/sip-trunk/endpoint.ts | 20 ++ src/commands/sip-trunk/rotate-credentials.ts | 18 +- src/services/sip-trunks.ts | 156 ++++++++++++++++ test/commands/sip-trunks.test.ts | 186 +++++++++++++++++-- 7 files changed, 395 insertions(+), 20 deletions(-) create mode 100644 src/commands/sip-trunk/endpoint.ts diff --git a/README.md b/README.md index 3d206bde..63ddd3dc 100644 --- a/README.md +++ b/README.md @@ -552,6 +552,7 @@ SIP trunks connect a PBX to WhatsApp calls. They are part of Calls and need no enrollment. Commands require a server API key. ```shell +polymorfa sip-trunk endpoint polymorfa sip-trunk list --project 018f0000-0000-7000-8000-000000000002 polymorfa sip-trunk view 6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10 polymorfa sip-trunk create --project 018f0000-0000-7000-8000-000000000002 --input trunk.json @@ -569,6 +570,16 @@ passwords never appear in the command line. `create` refuses a body that names exits with code 4 when the trunk changed; `delete` exits with code 4 while a session routes calls to the trunk. +`endpoint` prints the host your PBX sends SIP to, each transport with its port +and whether it requires SRTP, and the UDP port range call audio uses. The +address is the same for every trunk. Allow SIP and RTP in both directions +between your PBX and the addresses the host resolves to. When the environment +does not host SIP, `endpoint` prints `This environment has no SIP address.` +and exits with code 0; `--json` returns the `sip_not_hosted` status with a null +host and RTP range and no transports. `create` and `rotate-credentials` also +print the SIP address after a new password in human output; if the address +cannot be read, they still print the password. + ## Session call rejection and stable identities `call reject` is a separate command for the Linked Device session route. It diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index 45f0844d..5dea7ad8 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -50,6 +50,7 @@ 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` | +| `sip-trunk endpoint` | `GET /platform/sip/endpoint` | `raw.request` (see below) | | `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` | @@ -59,6 +60,11 @@ calls | `calls watch` | Lifecycle socket | `CallsClient` events | | `calls media` | Place or accept, then the media socket | `CallsClient`, `Call`, `call.audio` | +The pinned SDK has no `sipTrunks.endpoint` yet, so `sip-trunk endpoint` reads +`GET /platform/sip/endpoint` through the SDK raw client and validates the +response itself. Switch it to `sipTrunks.endpoint` once an SDK release with that +method is pinned. + The CLI uses only the neutral public calling surface. It does not expose offers, candidates, session descriptions, or media framing, and it imports no internal SDK module. diff --git a/src/commands/sip-trunk/create.ts b/src/commands/sip-trunk/create.ts index f0eeb1d8..5da86931 100644 --- a/src/commands/sip-trunk/create.ts +++ b/src/commands/sip-trunk/create.ts @@ -6,11 +6,13 @@ import { createSipTrunk, renderSipTrunkCreated, requireSipTrunkProject, + trySipEndpoint, + withSipEndpoint, } from '../../services/sip-trunks.js' export default class SipTrunkCreate extends BaseCommand { static override description = - 'Create a SIP trunk from one JSON object with name, direction, and the outbound and inbound sections the direction needs. Keep passwords in the input file or stdin, not in arguments. A trunk that accepts calls from your PBX returns its password once. Requires a server API key.' + 'Create a SIP trunk from one JSON object with name, direction, and the outbound and inbound sections the direction needs. Keep passwords in the input file or stdin, not in arguments. A trunk that accepts calls from your PBX returns its password once, followed by the SIP address your PBX sends calls to. Requires a server API key.' static override examples = [ '<%= config.bin %> sip-trunk create --project 018f0000-0000-7000-8000-000000000002 --input trunk.json', @@ -27,11 +29,21 @@ export default class SipTrunkCreate extends BaseCommand { async run(): Promise { const {flags} = await this.parse(SipTrunkCreate) + const client = await this.platformClient(flags) const result = await createSipTrunk( - await this.platformClient(flags), + client, requireSipTrunkProject(flags.project), await readJsonInput(flags.input), ) - this.writeValue(result, renderSipTrunkCreated(result.data), flags) + const endpoint = + result.data.inboundCredentials !== undefined && + this.outputMode(flags) === 'human' + ? await trySipEndpoint(client) + : undefined + this.writeValue( + result, + withSipEndpoint(renderSipTrunkCreated(result.data), endpoint), + flags, + ) } } diff --git a/src/commands/sip-trunk/endpoint.ts b/src/commands/sip-trunk/endpoint.ts new file mode 100644 index 00000000..dc040a0a --- /dev/null +++ b/src/commands/sip-trunk/endpoint.ts @@ -0,0 +1,20 @@ +import {BaseCommand} from '../../base-command.js' +import {getSipEndpoint, renderSipEndpoint} from '../../services/sip-trunks.js' + +export default class SipTrunkEndpoint extends BaseCommand { + static override description = + 'Show the host and ports your PBX sends SIP to, whether each transport requires SRTP, and the UDP port range call audio uses. The address is the same for every trunk. When this environment does not host SIP, the command says so and prints no address. Requires a server API key.' + + static override examples = [ + '<%= config.bin %> sip-trunk endpoint', + '<%= config.bin %> sip-trunk endpoint --json', + ] + + static override summary = 'Show the SIP address for your PBX.' + + async run(): Promise { + const {flags} = await this.parse(SipTrunkEndpoint) + const result = await getSipEndpoint(await this.platformClient(flags)) + this.writeValue(result, renderSipEndpoint(result.data), flags) + } +} diff --git a/src/commands/sip-trunk/rotate-credentials.ts b/src/commands/sip-trunk/rotate-credentials.ts index d7148f2e..b2b42c88 100644 --- a/src/commands/sip-trunk/rotate-credentials.ts +++ b/src/commands/sip-trunk/rotate-credentials.ts @@ -4,6 +4,8 @@ import {BaseCommand} from '../../base-command.js' import { renderSipTrunkCredentials, rotateSipTrunkCredentials, + trySipEndpoint, + withSipEndpoint, } from '../../services/sip-trunks.js' export default class SipTrunkRotateCredentials extends BaseCommand { @@ -12,7 +14,7 @@ export default class SipTrunkRotateCredentials extends BaseCommand { } static override description = - 'Replace the password your PBX uses to place calls through the trunk and print it once. The previous password stops working within about a minute. Requires a server API key.' + 'Replace the password your PBX uses to place calls through the trunk and print it once with the SIP address your PBX sends calls to. The previous password stops working within about a minute. Requires a server API key.' static override flags = { yes: Flags.boolean({ @@ -25,10 +27,16 @@ export default class SipTrunkRotateCredentials extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(SipTrunkRotateCredentials) - const result = await rotateSipTrunkCredentials( - await this.platformClient(flags), - args.trunk, + const client = await this.platformClient(flags) + const result = await rotateSipTrunkCredentials(client, args.trunk) + const endpoint = + this.outputMode(flags) === 'human' + ? await trySipEndpoint(client) + : undefined + this.writeValue( + result, + withSipEndpoint(renderSipTrunkCredentials(result.data), endpoint), + flags, ) - this.writeValue(result, renderSipTrunkCredentials(result.data), flags) } } diff --git a/src/services/sip-trunks.ts b/src/services/sip-trunks.ts index df752da7..1603b0bf 100644 --- a/src/services/sip-trunks.ts +++ b/src/services/sip-trunks.ts @@ -1,5 +1,6 @@ import { PolymorfaConfigurationError, + PolymorfaServerError, PolymorfaValidationError, type Client as PlatformClient, type CreateSipTrunkInput, @@ -121,6 +122,64 @@ export async function rotateSipTrunkCredentials( return result(response.data, response.metadata) } +export type SipEndpointTransportName = 'udp' | 'tcp' | 'tls' + +export interface SipEndpointTransport { + readonly transport: SipEndpointTransportName + readonly port: number + readonly srtp: 'required' | 'not_supported' +} + +/** + * The SIP address of the environment, from `GET /platform/sip/endpoint`. + * `sip_not_hosted` is a normal answer: the environment has no SIP address, + * `host` and `rtp` are null and `transports` is empty. + */ +export interface SipEndpoint { + readonly status: 'hosted' | 'sip_not_hosted' + readonly host: string | null + readonly transports: readonly SipEndpointTransport[] + readonly rtp: { + readonly protocol: 'udp' + readonly portMin: number + readonly portMax: number + } | null +} + +/** + * The pinned SDK has no `sipTrunks.endpoint()` yet, so this reads the + * operation through the SDK's raw client (same transport, credential, retries + * and error mapping) and checks the envelope here. Switch to + * `client.sipTrunks.endpoint()` once the SDK release that adds it is pinned. + */ +export async function getSipEndpoint( + client: PlatformClient, +): Promise> { + const response = await client.raw.request({ + method: 'GET', + path: '/platform/sip/endpoint', + }) + return result( + decodeSipEndpoint(response.data, response.metadata), + response.metadata, + ) +} + +/** + * The SIP address to print next to new inbound credentials. A failure here + * must never hide a password that can be shown only once, so it yields + * undefined instead of throwing. + */ +export async function trySipEndpoint( + client: PlatformClient, +): Promise { + try { + return (await getSipEndpoint(client)).data + } catch { + return undefined + } +} + // ── Rendering ────────────────────────────────────────────────────────────── export function renderSipTrunks(trunks: readonly SipTrunk[]): string { @@ -193,6 +252,38 @@ export function renderSipTrunkCredentials( ].join('\n') } +export function renderSipEndpoint(endpoint: SipEndpoint): string { + if (endpoint.status !== 'hosted' || endpoint.host === null) { + return 'This environment has no SIP address.' + } + const lines = [`Host\t${endpoint.host}`] + for (const transport of endpoint.transports) { + lines.push( + `Transport\t${transport.transport.toUpperCase()} ${transport.port}, SRTP ${ + transport.srtp === 'required' ? 'required' : 'not supported' + }`, + ) + } + if (endpoint.rtp !== null) { + lines.push( + `RTP\t${endpoint.rtp.protocol.toUpperCase()} ${endpoint.rtp.portMin}-${endpoint.rtp.portMax}`, + ) + } + lines.push( + `Allow SIP and RTP in both directions between your PBX and the addresses ${endpoint.host} resolves to.`, + ) + return lines.join('\n') +} + +/** Credentials followed by the SIP address the PBX registers against, when known. */ +export function withSipEndpoint( + human: string, + endpoint: SipEndpoint | undefined, +): string { + if (endpoint === undefined) return human + return `${human}\n\nSIP address\n${renderSipEndpoint(endpoint)}` +} + export function renderSipTrunkDeleted(deleted: SipTrunkDeleted): string { return `Deleted SIP trunk ${deleted.id}.` } @@ -214,6 +305,71 @@ function validateTrunkId(trunkId: string): string { return trimmed } +const SIP_TRANSPORTS: ReadonlySet = new Set(['udp', 'tcp', 'tls']) + +function decodeSipEndpoint( + body: unknown, + metadata: ResponseMetadata, +): SipEndpoint { + const data = isRecord(body) ? body.data : undefined + if (isSipEndpoint(data)) return data + throw new PolymorfaServerError( + 'The Polymorfa API returned an invalid SIP address response.', + { + code: 'invalid_response', + details: body, + metadata, + status: metadata.status, + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + }, + ) +} + +function isSipEndpoint(value: unknown): value is SipEndpoint { + if (!isRecord(value) || !Array.isArray(value.transports)) return false + if (value.status === 'sip_not_hosted') { + return ( + value.host === null && value.rtp === null && value.transports.length === 0 + ) + } + if (value.status !== 'hosted') return false + if (typeof value.host !== 'string' || value.host.length === 0) return false + const rtp = value.rtp + if ( + rtp !== null && + !( + isRecord(rtp) && + rtp.protocol === 'udp' && + isPort(rtp.portMin) && + isPort(rtp.portMax) + ) + ) { + return false + } + return value.transports.every( + (transport) => + isRecord(transport) && + typeof transport.transport === 'string' && + SIP_TRANSPORTS.has(transport.transport) && + isPort(transport.port) && + (transport.srtp === 'required' || transport.srtp === 'not_supported'), + ) +} + +function isPort(value: unknown): value is number { + return ( + Number.isInteger(value) && + (value as number) > 0 && + (value as number) <= 65535 + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + function objectInput(input: unknown, label: string): Record { if (typeof input !== 'object' || input === null || Array.isArray(input)) { throw new PolymorfaValidationError( diff --git a/test/commands/sip-trunks.test.ts b/test/commands/sip-trunks.test.ts index 1a97db2b..4534435b 100644 --- a/test/commands/sip-trunks.test.ts +++ b/test/commands/sip-trunks.test.ts @@ -43,6 +43,24 @@ const trunk = { updatedAt: '2026-09-17T10:00:00.000Z', } +const hostedEndpoint = { + status: 'hosted', + host: 'sip.example.test', + transports: [ + {transport: 'udp', port: 5060, srtp: 'not_supported'}, + {transport: 'tcp', port: 5060, srtp: 'not_supported'}, + {transport: 'tls', port: 5061, srtp: 'required'}, + ], + rtp: {protocol: 'udp', portMin: 20000, portMax: 20999}, +} + +const notHostedEndpoint = { + status: 'sip_not_hosted', + host: null, + transports: [], + rtp: null, +} + describe('sip-trunk commands', () => { let server: TestHttpServer | undefined let directory: string | undefined @@ -83,20 +101,23 @@ describe('sip-trunk commands', () => { }) test('creates a trunk from a file and prints the one-time password', async () => { - server = await startHttpServer({ - body: { - data: { - inboundCredentials: { - password: 'one-time-secret', - realm: 'sip.example.test', - username: 'pmfa_abc', + server = await startHttpSequenceServer([ + { + body: { + data: { + inboundCredentials: { + password: 'one-time-secret', + realm: 'sip.example.test', + username: 'pmfa_abc', + }, + trunk, }, - trunk, + success: true, }, - success: true, + status: 201, }, - status: 201, - }) + {body: {data: hostedEndpoint, success: true}}, + ]) directory = await mkdtemp(join(tmpdir(), 'polymorfa-sip-')) const input = join(directory, 'trunk.json') const body = { @@ -126,12 +147,153 @@ describe('sip-trunk commands', () => { expect(created.error).toBeUndefined() expect(created.stdout).toContain('Password\tone-time-secret') expect(created.stdout).toContain('cannot be shown again') + expect(created.stdout).toContain( + 'SIP address\nHost\tsip.example.test\nTransport\tUDP 5060, SRTP not supported', + ) expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ ...body, projectId: PROJECT, }) expect(exitCode(refused.error)).toBe(2) - expect(server.requests).toHaveLength(1) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'POST /platform/sip-trunks', + 'GET /platform/sip/endpoint', + ]) + }) + + test('shows the SIP address of the environment', async () => { + server = await startHttpSequenceServer([ + {body: {data: hostedEndpoint, success: true}}, + {body: {data: hostedEndpoint, success: true}}, + {body: {data: notHostedEndpoint, success: true}}, + ]) + + const hosted = await apiKey(() => runCommand('sip-trunk endpoint')) + const json = await apiKey(() => runCommand('sip-trunk endpoint --json')) + const notHosted = await apiKey(() => runCommand('sip-trunk endpoint')) + + expect(hosted.error).toBeUndefined() + expect(hosted.stdout).toBe( + [ + 'Host\tsip.example.test', + 'Transport\tUDP 5060, SRTP not supported', + 'Transport\tTCP 5060, SRTP not supported', + 'Transport\tTLS 5061, SRTP required', + 'RTP\tUDP 20000-20999', + 'Allow SIP and RTP in both directions between your PBX and the addresses sip.example.test resolves to.', + '', + ].join('\n'), + ) + expect(json.error).toBeUndefined() + expect((JSON.parse(json.stdout) as {data: unknown}).data).toEqual( + hostedEndpoint, + ) + expect(notHosted.error).toBeUndefined() + expect(notHosted.stdout).toBe('This environment has no SIP address.\n') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/sip/endpoint', + 'GET /platform/sip/endpoint', + 'GET /platform/sip/endpoint', + ]) + }) + + test('reports sip_not_hosted as data in JSON output', async () => { + server = await startHttpServer({ + body: {data: notHostedEndpoint, success: true}, + }) + + const json = await apiKey(() => runCommand('sip-trunk endpoint --json')) + + expect(json.error).toBeUndefined() + expect((JSON.parse(json.stdout) as {data: unknown}).data).toEqual( + notHostedEndpoint, + ) + }) + + test('fails on an API error or an invalid SIP address response', async () => { + server = await startHttpSequenceServer([ + { + body: { + error: { + code: 'permission_denied', + message: 'The credential does not have access to this resource.', + type: 'permission_error', + }, + success: false, + }, + status: 403, + }, + { + body: { + data: {...notHostedEndpoint, host: 'made-up.example.test'}, + success: true, + }, + }, + ]) + + const denied = await apiKey(() => runCommand('sip-trunk endpoint')) + const invalid = await apiKey(() => runCommand('sip-trunk endpoint')) + + expect(exitCode(denied.error)).toBe(3) + expect(denied.stderr).toContain('does not have access') + expect(denied.stdout).toBe('') + expect(invalid.error).toBeDefined() + expect(invalid.stdout).not.toContain('made-up.example.test') + }) + + test('still prints rotated credentials when the SIP address is unavailable', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: { + password: 'rotated', + realm: 'sip.example.test', + username: 'pmfa_abc', + }, + success: true, + }, + }, + { + body: { + error: {code: 'permission_denied', message: 'Denied.'}, + success: false, + }, + status: 403, + }, + { + body: { + data: { + password: 'rotated-again', + realm: 'sip.example.test', + username: 'pmfa_abc', + }, + success: true, + }, + }, + {body: {data: notHostedEndpoint, success: true}}, + ]) + + const failed = await apiKey(() => + runCommand(`sip-trunk rotate-credentials ${TRUNK_ID} --yes`), + ) + const notHosted = await apiKey(() => + runCommand(`sip-trunk rotate-credentials ${TRUNK_ID} --yes`), + ) + + expect(failed.error).toBeUndefined() + expect(failed.stdout).toContain('Password\trotated\n') + expect(failed.stdout).not.toContain('SIP address') + expect(notHosted.error).toBeUndefined() + expect(notHosted.stdout).toContain('Password\trotated-again') + expect(notHosted.stdout).toContain( + 'SIP address\nThis environment has no SIP address.', + ) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `POST /platform/sip-trunks/${TRUNK_ID}/credentials`, + 'GET /platform/sip/endpoint', + `POST /platform/sip-trunks/${TRUNK_ID}/credentials`, + 'GET /platform/sip/endpoint', + ]) }) test('disables a trunk with a revision guard and reports conflicts', async () => { From d2ea77f530ada1308e7f8f881a49f289bb5d3b97 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 20 Sep 2026 08:40:13 +0200 Subject: [PATCH 2/6] fix(sip-trunk): split the SIP address type by status and refuse a partial address The API contract is now a discriminated union: a hosted address always has a host, at least one transport and an RTP range. The CLI type mirrors it and the decoder refuses a hosted response that lacks either. --- src/services/sip-trunks.ts | 60 +++++++++++++++++++------------- test/commands/sip-trunks.test.ts | 23 ++++++++++++ 2 files changed, 58 insertions(+), 25 deletions(-) diff --git a/src/services/sip-trunks.ts b/src/services/sip-trunks.ts index 1603b0bf..16f386c8 100644 --- a/src/services/sip-trunks.ts +++ b/src/services/sip-trunks.ts @@ -130,21 +130,34 @@ export interface SipEndpointTransport { readonly srtp: 'required' | 'not_supported' } +export interface SipEndpointRtp { + readonly protocol: 'udp' + readonly portMin: number + readonly portMax: number +} + +/** The environment accepts SIP at `host`. */ +export interface SipEndpointHosted { + readonly status: 'hosted' + readonly host: string + readonly transports: readonly SipEndpointTransport[] + readonly rtp: SipEndpointRtp +} + +/** The environment has no SIP address. */ +export interface SipEndpointNotHosted { + readonly status: 'sip_not_hosted' + readonly host: null + readonly transports: readonly [] + readonly rtp: null +} + /** * The SIP address of the environment, from `GET /platform/sip/endpoint`. - * `sip_not_hosted` is a normal answer: the environment has no SIP address, - * `host` and `rtp` are null and `transports` is empty. + * `sip_not_hosted` is a normal answer, not a failure. Narrow on `status` + * before reading `host`, `transports` or `rtp`. */ -export interface SipEndpoint { - readonly status: 'hosted' | 'sip_not_hosted' - readonly host: string | null - readonly transports: readonly SipEndpointTransport[] - readonly rtp: { - readonly protocol: 'udp' - readonly portMin: number - readonly portMax: number - } | null -} +export type SipEndpoint = SipEndpointHosted | SipEndpointNotHosted /** * The pinned SDK has no `sipTrunks.endpoint()` yet, so this reads the @@ -253,7 +266,7 @@ export function renderSipTrunkCredentials( } export function renderSipEndpoint(endpoint: SipEndpoint): string { - if (endpoint.status !== 'hosted' || endpoint.host === null) { + if (endpoint.status !== 'hosted') { return 'This environment has no SIP address.' } const lines = [`Host\t${endpoint.host}`] @@ -264,11 +277,9 @@ export function renderSipEndpoint(endpoint: SipEndpoint): string { }`, ) } - if (endpoint.rtp !== null) { - lines.push( - `RTP\t${endpoint.rtp.protocol.toUpperCase()} ${endpoint.rtp.portMin}-${endpoint.rtp.portMax}`, - ) - } + lines.push( + `RTP\t${endpoint.rtp.protocol.toUpperCase()} ${endpoint.rtp.portMin}-${endpoint.rtp.portMax}`, + ) lines.push( `Allow SIP and RTP in both directions between your PBX and the addresses ${endpoint.host} resolves to.`, ) @@ -338,16 +349,15 @@ function isSipEndpoint(value: unknown): value is SipEndpoint { if (typeof value.host !== 'string' || value.host.length === 0) return false const rtp = value.rtp if ( - rtp !== null && - !( - isRecord(rtp) && - rtp.protocol === 'udp' && - isPort(rtp.portMin) && - isPort(rtp.portMax) - ) + !isRecord(rtp) || + rtp.protocol !== 'udp' || + !isPort(rtp.portMin) || + !isPort(rtp.portMax) || + rtp.portMin > rtp.portMax ) { return false } + if (value.transports.length === 0) return false return value.transports.every( (transport) => isRecord(transport) && diff --git a/test/commands/sip-trunks.test.ts b/test/commands/sip-trunks.test.ts index 4534435b..4af3f18a 100644 --- a/test/commands/sip-trunks.test.ts +++ b/test/commands/sip-trunks.test.ts @@ -241,6 +241,29 @@ describe('sip-trunk commands', () => { expect(invalid.stdout).not.toContain('made-up.example.test') }) + test('refuses a hosted address without transports or an RTP range', async () => { + server = await startHttpSequenceServer([ + { + body: {data: {...hostedEndpoint, rtp: null}, success: true}, + status: 200, + }, + { + body: {data: {...hostedEndpoint, transports: []}, success: true}, + status: 200, + }, + ]) + + const withoutRtp = await apiKey(() => runCommand('sip-trunk endpoint')) + const withoutTransports = await apiKey(() => + runCommand('sip-trunk endpoint'), + ) + + expect(withoutRtp.error).toBeDefined() + expect(withoutRtp.stdout).not.toContain('sip.example.test') + expect(withoutTransports.error).toBeDefined() + expect(withoutTransports.stdout).not.toContain('sip.example.test') + }) + test('still prints rotated credentials when the SIP address is unavailable', async () => { server = await startHttpSequenceServer([ { From e5839d856bb419dbef5dbdd2b5854af9c6650948 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 12:22:33 +0200 Subject: [PATCH 3/6] fix(sip-trunk): print one-time credentials before looking up the SIP address create and rotate-credentials awaited the optional address lookup before writing anything, so a hung lookup or an interrupt lost the only copy of the password. They now print the credentials first and append the address when the lookup returns. A child-process test holds the lookup open and checks the password is already on stdout. --- src/commands/sip-trunk/create.ts | 18 ++-- src/commands/sip-trunk/rotate-credentials.ts | 18 ++-- src/services/sip-trunks.ts | 15 ++-- test/commands/sip-trunks.test.ts | 91 ++++++++++++++++++++ 4 files changed, 116 insertions(+), 26 deletions(-) diff --git a/src/commands/sip-trunk/create.ts b/src/commands/sip-trunk/create.ts index 5da86931..4c3fbd2e 100644 --- a/src/commands/sip-trunk/create.ts +++ b/src/commands/sip-trunk/create.ts @@ -6,8 +6,8 @@ import { createSipTrunk, renderSipTrunkCreated, requireSipTrunkProject, + sipEndpointAppendix, trySipEndpoint, - withSipEndpoint, } from '../../services/sip-trunks.js' export default class SipTrunkCreate extends BaseCommand { @@ -35,15 +35,15 @@ export default class SipTrunkCreate extends BaseCommand { requireSipTrunkProject(flags.project), await readJsonInput(flags.input), ) - const endpoint = + // Print the one-time password before anything else can fail or hang. + this.writeValue(result, renderSipTrunkCreated(result.data), flags) + if ( result.data.inboundCredentials !== undefined && this.outputMode(flags) === 'human' - ? await trySipEndpoint(client) - : undefined - this.writeValue( - result, - withSipEndpoint(renderSipTrunkCreated(result.data), endpoint), - flags, - ) + ) { + const endpoint = await trySipEndpoint(client) + if (endpoint !== undefined) + process.stdout.write(sipEndpointAppendix(endpoint)) + } } } diff --git a/src/commands/sip-trunk/rotate-credentials.ts b/src/commands/sip-trunk/rotate-credentials.ts index b2b42c88..42ecc564 100644 --- a/src/commands/sip-trunk/rotate-credentials.ts +++ b/src/commands/sip-trunk/rotate-credentials.ts @@ -4,8 +4,8 @@ import {BaseCommand} from '../../base-command.js' import { renderSipTrunkCredentials, rotateSipTrunkCredentials, + sipEndpointAppendix, trySipEndpoint, - withSipEndpoint, } from '../../services/sip-trunks.js' export default class SipTrunkRotateCredentials extends BaseCommand { @@ -29,14 +29,12 @@ export default class SipTrunkRotateCredentials extends BaseCommand { const {args, flags} = await this.parse(SipTrunkRotateCredentials) const client = await this.platformClient(flags) const result = await rotateSipTrunkCredentials(client, args.trunk) - const endpoint = - this.outputMode(flags) === 'human' - ? await trySipEndpoint(client) - : undefined - this.writeValue( - result, - withSipEndpoint(renderSipTrunkCredentials(result.data), endpoint), - flags, - ) + // Print the one-time password before anything else can fail or hang. + this.writeValue(result, renderSipTrunkCredentials(result.data), flags) + if (this.outputMode(flags) === 'human') { + const endpoint = await trySipEndpoint(client) + if (endpoint !== undefined) + process.stdout.write(sipEndpointAppendix(endpoint)) + } } } diff --git a/src/services/sip-trunks.ts b/src/services/sip-trunks.ts index 16f386c8..8288a6d2 100644 --- a/src/services/sip-trunks.ts +++ b/src/services/sip-trunks.ts @@ -286,13 +286,14 @@ export function renderSipEndpoint(endpoint: SipEndpoint): string { return lines.join('\n') } -/** Credentials followed by the SIP address the PBX registers against, when known. */ -export function withSipEndpoint( - human: string, - endpoint: SipEndpoint | undefined, -): string { - if (endpoint === undefined) return human - return `${human}\n\nSIP address\n${renderSipEndpoint(endpoint)}` +/** + * Human output that follows one-time credentials: a blank line and the SIP + * address the PBX sends calls to. Commands print the credentials first and + * only then look the address up, so a slow or interrupted lookup can never + * cost the only copy of a password. + */ +export function sipEndpointAppendix(endpoint: SipEndpoint): string { + return `\nSIP address\n${renderSipEndpoint(endpoint)}\n` } export function renderSipTrunkDeleted(deleted: SipTrunkDeleted): string { diff --git a/test/commands/sip-trunks.test.ts b/test/commands/sip-trunks.test.ts index 4af3f18a..df1fc8d6 100644 --- a/test/commands/sip-trunks.test.ts +++ b/test/commands/sip-trunks.test.ts @@ -1,7 +1,11 @@ import {runCommand} from '@oclif/test' +import {spawn} from 'node:child_process' import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {createServer} from 'node:http' +import type {AddressInfo} from 'node:net' import {tmpdir} from 'node:os' import {join} from 'node:path' +import {fileURLToPath} from 'node:url' import {afterEach, describe, expect, test} from 'vitest' import { @@ -264,6 +268,93 @@ describe('sip-trunk commands', () => { expect(withoutTransports.stdout).not.toContain('sip.example.test') }) + test.each([ + ['rotate-credentials', 'rotated-first'], + ['create', 'created-first'], + ])( + '%s prints the one-time password while the SIP address lookup still hangs', + async (command, password) => { + const credentials = { + password, + realm: 'sip.example.test', + username: 'pmfa_abc', + } + // Answers the trunk request, then never answers the address lookup. + const hanging = createServer((request, reply) => { + if (request.url?.startsWith('/platform/sip/endpoint')) return + reply.writeHead(command === 'create' ? 201 : 200, { + 'content-type': 'application/json', + }) + reply.end( + JSON.stringify({ + data: + command === 'create' + ? {trunk, inboundCredentials: credentials} + : credentials, + success: true, + }), + ) + }) + await new Promise((resolve) => + hanging.listen(0, '127.0.0.1', resolve), + ) + const {port} = hanging.address() as AddressInfo + const directory = await mkdtemp(join(tmpdir(), 'polymorfa-sip-order-')) + const input = join(directory, 'trunk.json') + await writeFile(input, JSON.stringify({name: 'PBX', direction: 'both'})) + const args = + command === 'create' + ? ['sip-trunk', 'create', '--project', PROJECT, '--input', input] + : ['sip-trunk', 'rotate-credentials', TRUNK_ID, '--yes'] + const child = spawn( + process.execPath, + [ + '--import', + 'tsx', + fileURLToPath(new URL('../../bin/dev.js', import.meta.url)), + ...args, + ], + { + env: { + ...process.env, + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: `http://127.0.0.1:${port}`, + POLYMORFA_CLIENT_TOKEN: '', + }, + stdio: ['ignore', 'pipe', 'pipe'], + }, + ) + let stdout = '' + let stderr = '' + child.stderr.on('data', (chunk: Buffer) => { + stderr += String(chunk) + }) + let timer: NodeJS.Timeout | undefined + try { + const printed = new Promise((resolve) => { + child.stdout.on('data', (chunk: Buffer) => { + stdout += String(chunk) + if (stdout.includes(password)) resolve(true) + }) + timer = setTimeout(() => resolve(false), 20_000) + }) + + // The password is on screen before the lookup ends, so interrupting + // the command now cannot lose it. + expect(await printed, stderr).toBe(true) + expect(child.exitCode).toBeNull() + expect(stdout).not.toContain('SIP address') + } finally { + clearTimeout(timer) + child.kill() + hanging.closeAllConnections() + await new Promise((resolve) => hanging.close(() => resolve())) + await rm(directory, {force: true, recursive: true}) + } + }, + 30_000, + ) + test('still prints rotated credentials when the SIP address is unavailable', async () => { server = await startHttpSequenceServer([ { From 0972a0ed51b47dad2685c7290ce19d6a52b836d6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 14:04:08 +0200 Subject: [PATCH 4/6] feat(sip-trunk): read the SIP address with sipTrunks.endpoint from @polymorfa/sdk 0.1.0-dev.20260922115749 --- docs/command-research/calls.md | 9 +++-- package-lock.json | 8 ++--- package.json | 2 +- src/services/sip-trunks.ts | 61 +++++++++------------------------- 4 files changed, 25 insertions(+), 55 deletions(-) diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index 935677ad..af16880d 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -51,7 +51,7 @@ 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` | -| `sip-trunk endpoint` | `GET /platform/sip/endpoint` | `raw.request` (see below) | +| `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` | | `sip-trunk create` | `POST /platform/sip-trunks` | `sipTrunks.create` | @@ -61,10 +61,9 @@ calls | `calls watch` | Lifecycle socket | `CallsClient` events | | `calls media` | Place or accept, then the media socket | `CallsClient`, `Call`, `call.audio` | -The pinned SDK has no `sipTrunks.endpoint` yet, so `sip-trunk endpoint` reads -`GET /platform/sip/endpoint` through the SDK raw client and validates the -response itself. Switch it to `sipTrunks.endpoint` once an SDK release with that -method is pinned. +`sip-trunk endpoint` reads the address with `sipTrunks.endpoint` and checks +the response shape before printing it, so a malformed answer fails instead of +printing a partial address. The CLI uses only the neutral public calling surface. It does not expose offers, candidates, session descriptions, or media framing, and it imports no diff --git a/package-lock.json b/package-lock.json index f71d41a6..df915eac 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.20260922102711", + "@polymorfa/sdk": "0.1.0-dev.20260922115749", "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.20260922102711", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260922102711.tgz", - "integrity": "sha512-m2cnvInBZr4HZKslZewikM48mQIcREHuHJ3oF+/My/DN7pscwquTTkN6RM0mIQfxV+848hzsJZYoIqWxTgvqQA==", + "version": "0.1.0-dev.20260922115749", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260922115749.tgz", + "integrity": "sha512-ezfryhK10r6W84CUR3OFNHz1riChzWraSjKQuOZEE/pn+Okx9qMHhc34BDimorSeMWQnUW3pQKew12b2xrWyCw==", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index b16ea713..d34b4732 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.20260922102711", + "@polymorfa/sdk": "0.1.0-dev.20260922115749", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, diff --git a/src/services/sip-trunks.ts b/src/services/sip-trunks.ts index 8288a6d2..27602c64 100644 --- a/src/services/sip-trunks.ts +++ b/src/services/sip-trunks.ts @@ -8,6 +8,8 @@ import { type SipTrunk, type SipTrunkCreated, type SipTrunkCredentials, + type SipEndpoint, + type SipEndpointTransport, type SipTrunkDeleted, } from '@polymorfa/sdk' @@ -122,56 +124,26 @@ export async function rotateSipTrunkCredentials( return result(response.data, response.metadata) } -export type SipEndpointTransportName = 'udp' | 'tcp' | 'tls' - -export interface SipEndpointTransport { - readonly transport: SipEndpointTransportName - readonly port: number - readonly srtp: 'required' | 'not_supported' -} - -export interface SipEndpointRtp { - readonly protocol: 'udp' - readonly portMin: number - readonly portMax: number -} - -/** The environment accepts SIP at `host`. */ -export interface SipEndpointHosted { - readonly status: 'hosted' - readonly host: string - readonly transports: readonly SipEndpointTransport[] - readonly rtp: SipEndpointRtp -} +export type { + SipEndpoint, + SipEndpointHosted, + SipEndpointNotHosted, + SipEndpointRtp, + SipEndpointTransport, +} from '@polymorfa/sdk' -/** The environment has no SIP address. */ -export interface SipEndpointNotHosted { - readonly status: 'sip_not_hosted' - readonly host: null - readonly transports: readonly [] - readonly rtp: null -} +export type SipEndpointTransportName = SipEndpointTransport['transport'] /** * The SIP address of the environment, from `GET /platform/sip/endpoint`. - * `sip_not_hosted` is a normal answer, not a failure. Narrow on `status` - * before reading `host`, `transports` or `rtp`. - */ -export type SipEndpoint = SipEndpointHosted | SipEndpointNotHosted - -/** - * The pinned SDK has no `sipTrunks.endpoint()` yet, so this reads the - * operation through the SDK's raw client (same transport, credential, retries - * and error mapping) and checks the envelope here. Switch to - * `client.sipTrunks.endpoint()` once the SDK release that adds it is pinned. + * `sip_not_hosted` is a normal answer, not a failure. The shape is checked + * before it is rendered, so a malformed answer fails instead of printing a + * partial address. */ export async function getSipEndpoint( client: PlatformClient, ): Promise> { - const response = await client.raw.request({ - method: 'GET', - path: '/platform/sip/endpoint', - }) + const response = await client.sipTrunks.endpoint() return result( decodeSipEndpoint(response.data, response.metadata), response.metadata, @@ -320,16 +292,15 @@ function validateTrunkId(trunkId: string): string { const SIP_TRANSPORTS: ReadonlySet = new Set(['udp', 'tcp', 'tls']) function decodeSipEndpoint( - body: unknown, + data: unknown, metadata: ResponseMetadata, ): SipEndpoint { - const data = isRecord(body) ? body.data : undefined if (isSipEndpoint(data)) return data throw new PolymorfaServerError( 'The Polymorfa API returned an invalid SIP address response.', { code: 'invalid_response', - details: body, + details: data, metadata, status: metadata.status, ...(metadata.requestId === undefined From e52545f8bd2de6ce9d4c22911cc6ab0253c94e1f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 14:50:03 +0200 Subject: [PATCH 5/6] fix(sip-trunk): bound the optional SIP address lookup --- README.md | 5 ++-- docs/command-research/calls.md | 13 +++++++--- src/services/sip-trunks.ts | 15 +++++++++-- test/commands/sip-trunks.test.ts | 43 +++++++++++++++++++++++++++++--- 4 files changed, 66 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index b38fd110..5217b72e 100644 --- a/README.md +++ b/README.md @@ -712,8 +712,9 @@ between your PBX and the addresses the host resolves to. When the environment does not host SIP, `endpoint` prints `This environment has no SIP address.` and exits with code 0; `--json` returns the `sip_not_hosted` status with a null host and RTP range and no transports. `create` and `rotate-credentials` also -print the SIP address after a new password in human output; if the address -cannot be read, they still print the password. +print the password before looking up the SIP address in human output. The +optional address lookup stops after five seconds without retrying. If it fails +or times out, the command keeps the password on screen and exits successfully. ## Session call rejection and stable identities diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index af16880d..ac721f2f 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -3,7 +3,10 @@ - **Status:** Implemented against Calls contract revision 1, unpublished - **Reviewed:** 2026-09-17 - **SDK baseline:** `@polymorfa/sdk` (including the `@polymorfa/sdk/calls` - subpath) `0.1.0-dev.20260922102711` from npm. The research was done against + subpath) `0.1.0-dev.20260922115749` from npm, published from `polymorfa/sdks` + commit `8bc00778b3b3aaf05b09369c3f7840eb24cf6a56` by + [workflow run 35724232746](https://github.com/polymorfa/sdks/actions/runs/35724232746). + The research was done against `polymorfa/sdks` commit `94bfd09825817f842434e206e6a77adb5cc0c414`; the npm build also reports a `claim` activity when the placed call is answered. @@ -63,7 +66,11 @@ calls `sip-trunk endpoint` reads the address with `sipTrunks.endpoint` and checks the response shape before printing it, so a malformed answer fails instead of -printing a partial address. +printing a partial address. The human output of `sip-trunk create` and +`sip-trunk rotate-credentials` prints the one-time credentials before looking up +this address. That optional lookup has a five-second deadline and no retries; +a timeout omits the appendix and leaves the successful credential operation's +exit status unchanged. JSON output does not perform the optional lookup. The CLI uses only the neutral public calling surface. It does not expose offers, candidates, session descriptions, or media framing, and it imports no @@ -137,5 +144,5 @@ internal SDK module. - `calls watch` does not report outbound calls placed by other participants. - `@polymorfa/sdk` (with its `@polymorfa/sdk/calls` subpath) is published to npm only as dev prereleases; the CLI pins exactly - `0.1.0-dev.20260922102711`. The CLI cannot release until a stable + `0.1.0-dev.20260922115749`. The CLI cannot release until a stable `@polymorfa/sdk` is published with a compatible semver range. diff --git a/src/services/sip-trunks.ts b/src/services/sip-trunks.ts index 27602c64..08ab563d 100644 --- a/src/services/sip-trunks.ts +++ b/src/services/sip-trunks.ts @@ -4,6 +4,7 @@ import { PolymorfaValidationError, type Client as PlatformClient, type CreateSipTrunkInput, + type RequestOptions, type ResponseMetadata, type SipTrunk, type SipTrunkCreated, @@ -142,8 +143,9 @@ export type SipEndpointTransportName = SipEndpointTransport['transport'] */ export async function getSipEndpoint( client: PlatformClient, + options: RequestOptions = {}, ): Promise> { - const response = await client.sipTrunks.endpoint() + const response = await client.sipTrunks.endpoint(options) return result( decodeSipEndpoint(response.data, response.metadata), response.metadata, @@ -159,12 +161,21 @@ export async function trySipEndpoint( client: PlatformClient, ): Promise { try { - return (await getSipEndpoint(client)).data + return ( + await getSipEndpoint(client, { + maxNetworkRetries: 0, + signal: AbortSignal.timeout(SIP_ENDPOINT_APPENDIX_TIMEOUT_MS), + timeoutMs: SIP_ENDPOINT_APPENDIX_TIMEOUT_MS, + }) + ).data } catch { return undefined } } +/** The appendix is optional, so its lookup never keeps the command waiting long. */ +const SIP_ENDPOINT_APPENDIX_TIMEOUT_MS = 5_000 + // ── Rendering ────────────────────────────────────────────────────────────── export function renderSipTrunks(trunks: readonly SipTrunk[]): string { diff --git a/test/commands/sip-trunks.test.ts b/test/commands/sip-trunks.test.ts index df1fc8d6..b3b88353 100644 --- a/test/commands/sip-trunks.test.ts +++ b/test/commands/sip-trunks.test.ts @@ -272,16 +272,22 @@ describe('sip-trunk commands', () => { ['rotate-credentials', 'rotated-first'], ['create', 'created-first'], ])( - '%s prints the one-time password while the SIP address lookup still hangs', + '%s prints credentials first and exits successfully after a bounded hanging address lookup', async (command, password) => { const credentials = { password, realm: 'sip.example.test', username: 'pmfa_abc', } + let endpointRequests = 0 + let endpointStartedAt = 0 // Answers the trunk request, then never answers the address lookup. const hanging = createServer((request, reply) => { - if (request.url?.startsWith('/platform/sip/endpoint')) return + if (request.url?.startsWith('/platform/sip/endpoint')) { + endpointRequests += 1 + endpointStartedAt ||= performance.now() + return + } reply.writeHead(command === 'create' ? 201 : 200, { 'content-type': 'application/json', }) @@ -324,12 +330,20 @@ describe('sip-trunk commands', () => { stdio: ['ignore', 'pipe', 'pipe'], }, ) + const exited = new Promise<{ + code: number | null + signal: NodeJS.Signals | null + }>((resolve, reject) => { + child.once('error', reject) + child.once('exit', (code, signal) => resolve({code, signal})) + }) let stdout = '' let stderr = '' child.stderr.on('data', (chunk: Buffer) => { stderr += String(chunk) }) let timer: NodeJS.Timeout | undefined + let exitTimer: NodeJS.Timeout | undefined try { const printed = new Promise((resolve) => { child.stdout.on('data', (chunk: Buffer) => { @@ -344,9 +358,32 @@ describe('sip-trunk commands', () => { expect(await printed, stderr).toBe(true) expect(child.exitCode).toBeNull() expect(stdout).not.toContain('SIP address') + const outcome = await Promise.race([ + exited, + new Promise((_, reject) => { + exitTimer = setTimeout( + () => + reject( + new Error( + 'Optional address lookup did not finish within eight seconds', + ), + ), + 8_000, + ) + }), + ]) + expect(outcome, stderr).toEqual({code: 0, signal: null}) + expect(endpointRequests).toBe(1) + const lookupMs = performance.now() - endpointStartedAt + expect(lookupMs).toBeGreaterThanOrEqual(4_000) + expect(lookupMs).toBeLessThan(8_000) + expect(stdout).toContain(password) + expect(stdout).not.toContain('SIP address') } finally { clearTimeout(timer) - child.kill() + clearTimeout(exitTimer) + if (child.exitCode === null && child.signalCode === null) child.kill() + await exited hanging.closeAllConnections() await new Promise((resolve) => hanging.close(() => resolve())) await rm(directory, {force: true, recursive: true}) From 649c0392b091bf7e8448904c4770b1b6d5a10904 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 15:11:19 +0200 Subject: [PATCH 6/6] fix(calls): respect shorter SIP appendix timeout --- README.md | 3 ++- docs/command-research/calls.md | 3 +++ src/services/sip-trunks.ts | 1 - test/commands/sip-trunks.test.ts | 19 ++++++++++++------- 4 files changed, 17 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 5217b72e..154651c9 100644 --- a/README.md +++ b/README.md @@ -713,7 +713,8 @@ does not host SIP, `endpoint` prints `This environment has no SIP address.` and exits with code 0; `--json` returns the `sip_not_hosted` status with a null host and RTP range and no transports. `create` and `rotate-credentials` also print the password before looking up the SIP address in human output. The -optional address lookup stops after five seconds without retrying. If it fails +optional address lookup stops after five seconds without retrying and respects +a shorter `--timeout`. If it fails or times out, the command keeps the password on screen and exits successfully. ## Session call rejection and stable identities diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index ac721f2f..91ce75f7 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -146,3 +146,6 @@ internal SDK module. npm only as dev prereleases; the CLI pins exactly `0.1.0-dev.20260922115749`. The CLI cannot release until a stable `@polymorfa/sdk` is published with a compatible semver range. + +The optional SIP address appendix respects a shorter global `--timeout`; its +five-second abort signal is an upper bound, not a per-request timeout override. diff --git a/src/services/sip-trunks.ts b/src/services/sip-trunks.ts index 08ab563d..6cee3c09 100644 --- a/src/services/sip-trunks.ts +++ b/src/services/sip-trunks.ts @@ -165,7 +165,6 @@ export async function trySipEndpoint( await getSipEndpoint(client, { maxNetworkRetries: 0, signal: AbortSignal.timeout(SIP_ENDPOINT_APPENDIX_TIMEOUT_MS), - timeoutMs: SIP_ENDPOINT_APPENDIX_TIMEOUT_MS, }) ).data } catch { diff --git a/test/commands/sip-trunks.test.ts b/test/commands/sip-trunks.test.ts index b3b88353..2390e32e 100644 --- a/test/commands/sip-trunks.test.ts +++ b/test/commands/sip-trunks.test.ts @@ -269,11 +269,13 @@ describe('sip-trunk commands', () => { }) test.each([ - ['rotate-credentials', 'rotated-first'], - ['create', 'created-first'], - ])( - '%s prints credentials first and exits successfully after a bounded hanging address lookup', - async (command, password) => { + ['rotate-credentials', 'rotated-first', undefined], + ['create', 'created-first', undefined], + ['rotate-credentials', 'rotated-fast', 500], + ['create', 'created-fast', 500], + ] as const)( + '%s prints %s before a hanging address lookup, respecting timeout %s', + async (command, password, timeoutMs) => { const credentials = { password, realm: 'sip.example.test', @@ -319,6 +321,7 @@ describe('sip-trunk commands', () => { 'tsx', fileURLToPath(new URL('../../bin/dev.js', import.meta.url)), ...args, + ...(timeoutMs === undefined ? [] : ['--timeout', String(timeoutMs)]), ], { env: { @@ -375,8 +378,10 @@ describe('sip-trunk commands', () => { expect(outcome, stderr).toEqual({code: 0, signal: null}) expect(endpointRequests).toBe(1) const lookupMs = performance.now() - endpointStartedAt - expect(lookupMs).toBeGreaterThanOrEqual(4_000) - expect(lookupMs).toBeLessThan(8_000) + expect(lookupMs).toBeGreaterThanOrEqual( + timeoutMs === undefined ? 4_000 : 100, + ) + expect(lookupMs).toBeLessThan(timeoutMs === undefined ? 8_000 : 2_000) expect(stdout).toContain(password) expect(stdout).not.toContain('SIP address') } finally {