Skip to content
13 changes: 13 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -687,6 +687,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
Expand All @@ -704,6 +705,18 @@ 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 password before looking up the SIP address in human output. The
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

`call reject` is a separate command for the Linked Device session route. It
Expand Down
19 changes: 17 additions & 2 deletions docs/command-research/calls.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down Expand Up @@ -51,6 +54,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` | `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` |
Expand All @@ -60,6 +64,14 @@ calls
| `calls watch` | Lifecycle socket | `CallsClient` events |
| `calls media` | Place or accept, then the media socket | `CallsClient`, `Call`, `call.audio` |

`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 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
internal SDK module.
Expand Down Expand Up @@ -132,5 +144,8 @@ 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.

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.
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.

2 changes: 1 addition & 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.20260922102711",
"@polymorfa/sdk": "0.1.0-dev.20260922115749",
"jq-wasm": "3.0.0-jq-1.8.2",
"zod": "4.2.0"
},
Expand Down
16 changes: 14 additions & 2 deletions src/commands/sip-trunk/create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,13 @@ import {
createSipTrunk,
renderSipTrunkCreated,
requireSipTrunkProject,
sipEndpointAppendix,
trySipEndpoint,
} 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',
Expand All @@ -27,11 +29,21 @@ export default class SipTrunkCreate extends BaseCommand {

async run(): Promise<void> {
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),
)
// 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'
) {
const endpoint = await trySipEndpoint(client)
if (endpoint !== undefined)
process.stdout.write(sipEndpointAppendix(endpoint))
}
}
}
20 changes: 20 additions & 0 deletions src/commands/sip-trunk/endpoint.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
const {flags} = await this.parse(SipTrunkEndpoint)
const result = await getSipEndpoint(await this.platformClient(flags))
this.writeValue(result, renderSipEndpoint(result.data), flags)
}
}
16 changes: 11 additions & 5 deletions src/commands/sip-trunk/rotate-credentials.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import {BaseCommand} from '../../base-command.js'
import {
renderSipTrunkCredentials,
rotateSipTrunkCredentials,
sipEndpointAppendix,
trySipEndpoint,
} from '../../services/sip-trunks.js'

export default class SipTrunkRotateCredentials extends BaseCommand {
Expand All @@ -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({
Expand All @@ -25,10 +27,14 @@ export default class SipTrunkRotateCredentials extends BaseCommand {

async run(): Promise<void> {
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)
// 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))
}
}
}
148 changes: 148 additions & 0 deletions src/services/sip-trunks.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,16 @@
import {
PolymorfaConfigurationError,
PolymorfaServerError,
PolymorfaValidationError,
type Client as PlatformClient,
type CreateSipTrunkInput,
type RequestOptions,
type ResponseMetadata,
type SipTrunk,
type SipTrunkCreated,
type SipTrunkCredentials,
type SipEndpoint,
type SipEndpointTransport,
type SipTrunkDeleted,
} from '@polymorfa/sdk'

Expand Down Expand Up @@ -121,6 +125,56 @@ export async function rotateSipTrunkCredentials(
return result(response.data, response.metadata)
}

export type {
SipEndpoint,
SipEndpointHosted,
SipEndpointNotHosted,
SipEndpointRtp,
SipEndpointTransport,
} from '@polymorfa/sdk'

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. 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,
options: RequestOptions = {},
): Promise<SipTrunkResult<SipEndpoint>> {
const response = await client.sipTrunks.endpoint(options)
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<SipEndpoint | undefined> {
try {
return (
await getSipEndpoint(client, {
maxNetworkRetries: 0,
signal: AbortSignal.timeout(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 {
Expand Down Expand Up @@ -193,6 +247,37 @@ export function renderSipTrunkCredentials(
].join('\n')
}

export function renderSipEndpoint(endpoint: SipEndpoint): string {
if (endpoint.status !== 'hosted') {
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'
}`,
)
}
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')
}

/**
* 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 {
return `Deleted SIP trunk ${deleted.id}.`
}
Expand All @@ -214,6 +299,69 @@ function validateTrunkId(trunkId: string): string {
return trimmed
}

const SIP_TRANSPORTS: ReadonlySet<string> = new Set(['udp', 'tcp', 'tls'])

function decodeSipEndpoint(
data: unknown,
metadata: ResponseMetadata,
): SipEndpoint {
if (isSipEndpoint(data)) return data
throw new PolymorfaServerError(
'The Polymorfa API returned an invalid SIP address response.',
{
code: 'invalid_response',
details: data,
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 (
!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) &&
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<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
}

function objectInput(input: unknown, label: string): Record<string, unknown> {
if (typeof input !== 'object' || input === null || Array.isArray(input)) {
throw new PolymorfaValidationError(
Expand Down
Loading