From ecfe1330a1abeec945d65e275c8ed9c171a65aeb Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:49:28 +0200 Subject: [PATCH 1/5] feat: add Official API and Flow lifecycle commands --- README.md | 88 +++++ package.json | 15 + src/commands/cloud/catalog/list.ts | 42 +++ src/commands/cloud/credentials/get.ts | 27 ++ src/commands/cloud/credentials/reauthorize.ts | 27 ++ src/commands/cloud/pricing.ts | 36 ++ src/commands/cloud/service-window.ts | 33 ++ src/commands/cloud/template/create.ts | 33 ++ src/commands/cloud/template/delete.ts | 42 +++ src/commands/cloud/template/edit.ts | 40 ++ src/commands/cloud/template/get.ts | 34 ++ src/commands/cloud/template/list.ts | 25 ++ src/commands/flow/create.ts | 22 ++ src/commands/flow/delete.ts | 23 ++ src/commands/flow/deprecate.ts | 36 ++ src/commands/flow/discard.ts | 36 ++ src/commands/flow/get.ts | 15 + src/commands/flow/list.ts | 11 + src/commands/flow/publish.ts | 36 ++ src/commands/flow/receipts.ts | 15 + src/commands/flow/sync.ts | 30 ++ src/commands/flow/update.ts | 25 ++ src/commands/flow/upload.ts | 48 +++ src/commands/message/typing.ts | 16 +- src/commands/quicklink/view.ts | 12 +- src/flows/command.ts | 21 ++ src/services/cloud.ts | 68 ++++ src/services/flows.ts | 50 +++ test/commands/cloud.test.ts | 354 ++++++++++++++++++ test/commands/flows.test.ts | 116 ++++++ 30 files changed, 1368 insertions(+), 8 deletions(-) create mode 100644 src/commands/cloud/catalog/list.ts create mode 100644 src/commands/cloud/credentials/get.ts create mode 100644 src/commands/cloud/credentials/reauthorize.ts create mode 100644 src/commands/cloud/pricing.ts create mode 100644 src/commands/cloud/service-window.ts create mode 100644 src/commands/cloud/template/create.ts create mode 100644 src/commands/cloud/template/delete.ts create mode 100644 src/commands/cloud/template/edit.ts create mode 100644 src/commands/cloud/template/get.ts create mode 100644 src/commands/cloud/template/list.ts create mode 100644 src/commands/flow/create.ts create mode 100644 src/commands/flow/delete.ts create mode 100644 src/commands/flow/deprecate.ts create mode 100644 src/commands/flow/discard.ts create mode 100644 src/commands/flow/get.ts create mode 100644 src/commands/flow/list.ts create mode 100644 src/commands/flow/publish.ts create mode 100644 src/commands/flow/receipts.ts create mode 100644 src/commands/flow/sync.ts create mode 100644 src/commands/flow/update.ts create mode 100644 src/commands/flow/upload.ts create mode 100644 src/flows/command.ts create mode 100644 src/services/cloud.ts create mode 100644 src/services/flows.ts create mode 100644 test/commands/cloud.test.ts create mode 100644 test/commands/flows.test.ts diff --git a/README.md b/README.md index 3845eaa7..bb1dacb9 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,94 @@ does not resolve the uncertainty and can send a duplicate. See the `--idempotency-key`, but the API does not deduplicate those actions by that header. +### Flow drafts and provider lifecycle + +Flow commands require a project scope and an organization API key or project +token. Reads use `sessions:read`; mutations use `sessions:manage`. + +```shell +polymorfa flow list --project PROJECT_UUID +polymorfa flow create --project PROJECT_UUID --input draft.json +polymorfa flow get FLOW_UUID --project PROJECT_UUID +polymorfa flow update FLOW_UUID --project PROJECT_UUID --input update.json +polymorfa flow upload FLOW_UUID --project PROJECT_UUID --session number_123 --category APPOINTMENT_BOOKING --request-id REQUEST_UUID +polymorfa flow receipts FLOW_UUID --project PROJECT_UUID +polymorfa flow sync FLOW_UUID --project PROJECT_UUID --session number_123 +polymorfa flow publish FLOW_UUID --project PROJECT_UUID --session number_123 --yes +``` + +Creation input contains `name` and `definition`, plus an optional `draftId`. +Update input requires the draft's observed `expectedUpdatedAt` and optional +`name`, `status`, or `definition`. JSON input accepts a file or `-` for stdin. +Project and Flow identity come from command arguments and cannot be overridden +inside the file. + +Provider actions are beta and require an eligible Number. `publish`, `deprecate`, +`discard`, and local `delete` require `--yes`. Discard removes a provider draft; +local deletion is separate. Writes make one attempt. A successful HTTP response +can still carry an `uncertain` operation: read its receipt and use `sync` to +reconcile. Preserve `--request-id` for the exact same operation; a new ID does +not resolve an uncertain outcome. + +### Official API observations and templates + +The `cloud` commands require an organization API key or a project token with the +listed scope. Client tokens cannot use these commands. Server-side feature +access still applies: an installed command does not enroll a Number or enable a +beta feature. + +```shell +polymorfa cloud catalog list --waba WABA_ID --graph-version v26.0 --limit 25 +polymorfa cloud template list --session number_123 +polymorfa cloud template get order_update --session number_123 --language en_US +polymorfa cloud template create --session number_123 --input template.json +polymorfa cloud template edit order_update --session number_123 --language en_US --input components.json +polymorfa cloud template delete order_update --session number_123 --yes +polymorfa cloud service-window +15551234567 --session number_123 +polymorfa cloud pricing --session number_123 --since 2026-09-01T00:00:00Z --until 2026-09-26T00:00:00Z +polymorfa cloud credentials get --session number_123 +polymorfa cloud credentials reauthorize --session number_123 +``` + +Template commands use `messages:write` and the connected Meta business template +catalog. They are separate from project template drafts. Creation reads a JSON +object with `name`, `language`, `category`, and `components` from `--input`, or +from stdin when it is `-`. Template names and provider IDs remain strings. +Edits read `{ "components": [...] }` and return acceptance, not approval to send. +Deletion requires `--yes`. The native catalog routes reject Numbers originally +created as Linked Device, including their Hybrid mode. + +Service-window reads use `chats:read`. They report observed `open`, `closed`, or +`unknown` state; missing or interrupted notifications can prevent a reliable +window observation. Pricing reads use `sessions:read` and report Meta's message +classifications and counts, without calculating prices or invoice totals. The +optional ISO time range defaults to 30 days and cannot exceed 93 days. + +Credential reads use `sessions:read`; reauthorization uses `quicklink:manage`. +`cloud catalog list` reads linked product-catalog IDs and names with `sessions:read` +and Graph access. Pass the returned opaque cursor using `--after` for another +page. Catalog visibility does not grant merchant ownership or permission to sell. + +Reauthorization creates a QuickLink for a stopped or disconnected standalone +Official Number. It never stops the Number or opens the link automatically. +Running Numbers return a conflict; Hybrid reauthorization is unsupported. + +Template writes and credential reauthorization do not retry uncertain provider +outcomes. Inspect the catalog or onboarding state before another write. API +access, state, and server failures retain the CLI's exit codes: 3 for forbidden +access, 4 for conflicts, and 5 for server failures. Use `--json` to retain the +observation fields and structured errors. + +For Official Numbers, `message typing` requires `--id` for a message you received +and `--state typing`. It marks that message read and shows typing until a reply +is sent or 25 seconds pass. Linked Device Numbers also support `recording` and +`paused` without an inbound ID. + +`quicklink view` shows contacts/history request receipts and history-delivery +progress when present. An accepted request is distinct from delivered history; +`unknown` does not establish that a request failed. The connection status alone +does not establish that sync completed. + ### Typed sends Every `message send ` command accepts the same `--session`, `--to`, diff --git a/package.json b/package.json index f92f1306..2c733b36 100644 --- a/package.json +++ b/package.json @@ -293,6 +293,21 @@ }, "function:invocation": { "description": "Invoke Functions once and inspect metadata receipts." + }, + "cloud": { + "description": "Official API Number operations (beta). Team enrollment and scopes apply." + }, + "cloud:template": { + "description": "Manage Meta templates, separate from Polymorfa project drafts." + }, + "cloud:credentials": { + "description": "Read redacted credential health and create guided reauthorization links." + }, + "flow": { + "description": "Manage project Flow drafts and their Official API lifecycle." + }, + "cloud:catalog": { + "description": "Read Meta product catalogs visible to a WhatsApp Business Account." } } }, diff --git a/src/commands/cloud/catalog/list.ts b/src/commands/cloud/catalog/list.ts new file mode 100644 index 00000000..c6978f33 --- /dev/null +++ b/src/commands/cloud/catalog/list.ts @@ -0,0 +1,42 @@ +import {Flags} from '@oclif/core' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' +import {BaseCommand} from '../../../base-command.js' + +export default class CloudCatalogList extends BaseCommand { + static override description = + 'List visible Meta product catalogs for a WhatsApp Business Account. Requires sessions:read and Graph access.' + static override flags = { + waba: Flags.string({ + description: 'WhatsApp Business Account ID.', + required: true, + }), + 'graph-version': Flags.string({ + description: 'Meta Graph version, for example v26.0.', + required: true, + }), + limit: Flags.integer({ + description: 'Page size from 1 to 100; API default is 25.', + min: 1, + max: 100, + }), + after: Flags.string({ + description: 'Opaque cursor from the previous response.', + }), + } + async run(): Promise { + const {flags} = await this.parse(CloudCatalogList) + if (!flags.waba.trim() || !flags['graph-version'].trim()) + throw new PolymorfaConfigurationError( + 'Provide a WABA ID and Graph version.', + ) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.cloudCatalogs.list(flags.waba, { + version: flags['graph-version'], + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.after === undefined ? {} : {after: flags.after}), + }) + this.writeValue(response, JSON.stringify(response.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/credentials/get.ts b/src/commands/cloud/credentials/get.ts new file mode 100644 index 00000000..a7ab960d --- /dev/null +++ b/src/commands/cloud/credentials/get.ts @@ -0,0 +1,27 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult} from '../../../services/cloud.js' + +export default class CloudCredentialsGet extends BaseCommand { + static override description = + 'Read redacted Meta credential checks for an enrolled Official API Number (beta). Requires sessions:read.' + + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + } + async run(): Promise { + const {flags} = await this.parse(CloudCredentialsGet) + + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.sessions.getCloudCredentialHealth( + flags.session, + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/credentials/reauthorize.ts b/src/commands/cloud/credentials/reauthorize.ts new file mode 100644 index 00000000..5f6063ec --- /dev/null +++ b/src/commands/cloud/credentials/reauthorize.ts @@ -0,0 +1,27 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult} from '../../../services/cloud.js' + +export default class CloudCredentialsReauthorize extends BaseCommand { + static override description = + 'Create one reauthorization QuickLink for a stopped or disconnected standalone Official API Number (beta). Requires quicklink:manage. Never stops the Number or retries link creation.' + + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + } + async run(): Promise { + const {flags} = await this.parse(CloudCredentialsReauthorize) + + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.sessions.reauthorizeCloudCredentials( + flags.session, + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/pricing.ts b/src/commands/cloud/pricing.ts new file mode 100644 index 00000000..4b2cdc63 --- /dev/null +++ b/src/commands/cloud/pricing.ts @@ -0,0 +1,36 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../base-command.js' +import {cloudResult} from '../../services/cloud.js' + +export default class CloudPricing extends BaseCommand { + static override description = + 'Read Meta-reported message counts (beta), without prices or invoice amounts. Requires sessions:read and service-window enrollment.' + + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + since: Flags.string({ + description: + 'Inclusive ISO 8601 start; defaults to 30 days before until.', + }), + until: Flags.string({ + description: + 'Exclusive ISO 8601 end; defaults to now. Maximum period: 93 days.', + }), + } + async run(): Promise { + const {flags} = await this.parse(CloudPricing) + + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.sessions.getMetaPricing(flags.session, { + ...(flags.since === undefined ? {} : {since: flags.since}), + ...(flags.until === undefined ? {} : {until: flags.until}), + }) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/service-window.ts b/src/commands/cloud/service-window.ts new file mode 100644 index 00000000..3429f79d --- /dev/null +++ b/src/commands/cloud/service-window.ts @@ -0,0 +1,33 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../base-command.js' +import {cloudResult} from '../../services/cloud.js' + +export default class CloudServiceWindow extends BaseCommand { + static override description = + 'Read the observed customer service window (beta). Unknown does not establish permission to send. Requires chats:read and service-window enrollment; HMS is not required.' + static override args = { + conversation: Args.string({ + description: 'Conversation ID or E.164 phone number.', + required: true, + }), + } + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CloudServiceWindow) + + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.chats.getServiceWindow( + flags.session, + args.conversation, + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/template/create.ts b/src/commands/cloud/template/create.ts new file mode 100644 index 00000000..8612879e --- /dev/null +++ b/src/commands/cloud/template/create.ts @@ -0,0 +1,33 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult} from '../../../services/cloud.js' +import {readJsonInput} from '../../../api/request.js' +import {parseCloudTemplate} from '../../../services/cloud.js' + +export default class CloudTemplateCreate extends BaseCommand { + static override description = + 'Submit one Meta template (beta). Requires messages:write. Does not retry an uncertain write.' + + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + input: Flags.string({ + description: + 'Template JSON file or - for stdin: name, language, category, components.', + required: true, + }), + } + async run(): Promise { + const {flags} = await this.parse(CloudTemplateCreate) + + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const body = parseCloudTemplate(await readJsonInput(flags.input)) + const response = await client.cloudTemplates.create(flags.session, body) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/template/delete.ts b/src/commands/cloud/template/delete.ts new file mode 100644 index 00000000..76a02e4e --- /dev/null +++ b/src/commands/cloud/template/delete.ts @@ -0,0 +1,42 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult} from '../../../services/cloud.js' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +export default class CloudTemplateDelete extends BaseCommand { + static override description = + 'Delete every language of a Meta template name (beta). Requires messages:write. Does not retry an uncertain write.' + static override args = { + name: Args.string({ + description: 'Meta template name to delete in all languages.', + required: true, + }), + } + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + yes: Flags.boolean({ + description: 'Confirm deletion of all language variants.', + required: true, + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CloudTemplateDelete) + if (!flags.yes) + throw new PolymorfaConfigurationError( + 'Pass --yes to delete every language of this template.', + 'yes', + ) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.cloudTemplates.delete( + flags.session, + args.name, + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/template/edit.ts b/src/commands/cloud/template/edit.ts new file mode 100644 index 00000000..f63b27b2 --- /dev/null +++ b/src/commands/cloud/template/edit.ts @@ -0,0 +1,40 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult, parseCloudTemplateEdit} from '../../../services/cloud.js' +import {readJsonInput} from '../../../api/request.js' + +export default class CloudTemplateEdit extends BaseCommand { + static override description = + 'Submit changes to one Meta template language (beta). Requires messages:write. Acceptance does not establish approval.' + static override args = { + name: Args.string({description: 'Meta template name.', required: true}), + } + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + language: Flags.string({ + description: 'Template language; API default is en_US.', + }), + input: Flags.string({ + description: 'JSON file or - for stdin containing components.', + required: true, + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CloudTemplateEdit) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const body = parseCloudTemplateEdit(await readJsonInput(flags.input)) + const response = await client.cloudTemplates.update( + flags.session, + args.name, + body, + flags.language === undefined ? {} : {language: flags.language}, + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/template/get.ts b/src/commands/cloud/template/get.ts new file mode 100644 index 00000000..826184ac --- /dev/null +++ b/src/commands/cloud/template/get.ts @@ -0,0 +1,34 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult} from '../../../services/cloud.js' + +export default class CloudTemplateGet extends BaseCommand { + static override description = + 'Read a Meta template by name and language (beta). Omitted language defaults to en_US; requires messages:write.' + static override args = { + name: Args.string({description: 'Meta template name.', required: true}), + } + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + language: Flags.string({ + description: 'Template language; defaults to en_US at the API.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CloudTemplateGet) + + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.cloudTemplates.retrieve( + flags.session, + args.name, + flags.language === undefined ? {} : {language: flags.language}, + ) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/cloud/template/list.ts b/src/commands/cloud/template/list.ts new file mode 100644 index 00000000..75e72296 --- /dev/null +++ b/src/commands/cloud/template/list.ts @@ -0,0 +1,25 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import {cloudResult} from '../../../services/cloud.js' + +export default class CloudTemplateList extends BaseCommand { + static override description = + 'List the refreshed Meta template catalog for an Official API Number (beta). Requires messages:write.' + + static override flags = { + session: Flags.string({ + description: 'Messaging Number ID.', + required: true, + }), + } + async run(): Promise { + const {flags} = await this.parse(CloudTemplateList) + + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.cloudTemplates.list(flags.session) + const result = cloudResult(response) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/create.ts b/src/commands/flow/create.ts new file mode 100644 index 00000000..97a50e6b --- /dev/null +++ b/src/commands/flow/create.ts @@ -0,0 +1,22 @@ +import {Flags} from '@oclif/core' +import {FlowsCommand} from '../../flows/command.js' +import {readJsonInput} from '../../api/request.js' +import {parseFlowCreate} from '../../services/flows.js' +export default class FlowCreate extends FlowsCommand { + static override description = + 'Create a local project Flow draft. Requires sessions:manage.' + static override flags = { + input: Flags.string({ + description: + 'JSON file or - for stdin. Project and path identity come from command flags.', + required: true, + }), + } + async run(): Promise { + const {flags} = await this.parse(FlowCreate) + const body = parseFlowCreate(await readJsonInput(flags.input)) + const resource = await this.flows(flags) + const result = await resource.create(body) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/delete.ts b/src/commands/flow/delete.ts new file mode 100644 index 00000000..d4d0badf --- /dev/null +++ b/src/commands/flow/delete.ts @@ -0,0 +1,23 @@ +import {Args, Flags} from '@oclif/core' +import {FlowsCommand} from '../../flows/command.js' +export default class FlowDelete extends FlowsCommand { + static override description = + 'Delete a local Flow draft. Requires sessions:manage.' + static override args = { + flow: Args.string({description: 'Flow draft UUID.', required: true}), + } + static override flags = { + yes: Flags.boolean({ + description: 'Confirm this Flow change.', + required: true, + }), + } + async run(): Promise { + const {args, flags} = await this.parse(FlowDelete) + if (!flags.yes) + this.error('Pass --yes to confirm this Flow change.', {exit: 2}) + const resource = await this.flows(flags) + const result = await resource.delete(args.flow) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/deprecate.ts b/src/commands/flow/deprecate.ts new file mode 100644 index 00000000..9997e8ca --- /dev/null +++ b/src/commands/flow/deprecate.ts @@ -0,0 +1,36 @@ +import {Args, Flags} from '@oclif/core' +import {FlowsCommand} from '../../flows/command.js' +export default class FlowDeprecate extends FlowsCommand { + static override description = + 'Deprecate a published WhatsApp Flow (beta). Requires sessions:manage.' + static override args = { + flow: Args.string({description: 'Flow draft UUID.', required: true}), + } + static override flags = { + session: Flags.string({ + description: 'Number name or ID in the same project.', + required: true, + }), + 'request-id': Flags.string({ + description: + 'UUID for this exact provider operation. Reuse to inspect the original receipt.', + }), + yes: Flags.boolean({ + description: 'Confirm this Flow change.', + required: true, + }), + } + async run(): Promise { + const {args, flags} = await this.parse(FlowDeprecate) + if (!flags.yes) + this.error('Pass --yes to confirm this Flow change.', {exit: 2}) + const resource = await this.flows(flags) + const result = await resource.deprecate(args.flow, { + sessionId: flags.session, + ...(flags['request-id'] === undefined + ? {} + : {requestId: flags['request-id']}), + }) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/discard.ts b/src/commands/flow/discard.ts new file mode 100644 index 00000000..0b0cdcf7 --- /dev/null +++ b/src/commands/flow/discard.ts @@ -0,0 +1,36 @@ +import {Args, Flags} from '@oclif/core' +import {FlowsCommand} from '../../flows/command.js' +export default class FlowDiscard extends FlowsCommand { + static override description = + 'Delete a draft from WhatsApp (beta). Requires sessions:manage.' + static override args = { + flow: Args.string({description: 'Flow draft UUID.', required: true}), + } + static override flags = { + session: Flags.string({ + description: 'Number name or ID in the same project.', + required: true, + }), + 'request-id': Flags.string({ + description: + 'UUID for this exact provider operation. Reuse to inspect the original receipt.', + }), + yes: Flags.boolean({ + description: 'Confirm this Flow change.', + required: true, + }), + } + async run(): Promise { + const {args, flags} = await this.parse(FlowDiscard) + if (!flags.yes) + this.error('Pass --yes to confirm this Flow change.', {exit: 2}) + const resource = await this.flows(flags) + const result = await resource.discard(args.flow, { + sessionId: flags.session, + ...(flags['request-id'] === undefined + ? {} + : {requestId: flags['request-id']}), + }) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/get.ts b/src/commands/flow/get.ts new file mode 100644 index 00000000..1ce9316d --- /dev/null +++ b/src/commands/flow/get.ts @@ -0,0 +1,15 @@ +import {Args} from '@oclif/core' +import {FlowsCommand} from '../../flows/command.js' +export default class FlowGet extends FlowsCommand { + static override description = + 'Read a project Flow draft. Requires sessions:read.' + static override args = { + flow: Args.string({description: 'Flow draft UUID.', required: true}), + } + async run(): Promise { + const {args, flags} = await this.parse(FlowGet) + const resource = await this.flows(flags) + const result = await resource.retrieve(args.flow) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/list.ts b/src/commands/flow/list.ts new file mode 100644 index 00000000..de3d81b1 --- /dev/null +++ b/src/commands/flow/list.ts @@ -0,0 +1,11 @@ +import {FlowsCommand} from '../../flows/command.js' +export default class FlowList extends FlowsCommand { + static override description = + 'List project Flow drafts. Requires sessions:read.' + async run(): Promise { + const {flags} = await this.parse(FlowList) + const resource = await this.flows(flags) + const result = await resource.list() + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/publish.ts b/src/commands/flow/publish.ts new file mode 100644 index 00000000..78271918 --- /dev/null +++ b/src/commands/flow/publish.ts @@ -0,0 +1,36 @@ +import {Args, Flags} from '@oclif/core' +import {FlowsCommand} from '../../flows/command.js' +export default class FlowPublish extends FlowsCommand { + static override description = + 'Publish a validated Flow to WhatsApp (beta). Requires sessions:manage.' + static override args = { + flow: Args.string({description: 'Flow draft UUID.', required: true}), + } + static override flags = { + session: Flags.string({ + description: 'Number name or ID in the same project.', + required: true, + }), + 'request-id': Flags.string({ + description: + 'UUID for this exact provider operation. Reuse to inspect the original receipt.', + }), + yes: Flags.boolean({ + description: 'Confirm this Flow change.', + required: true, + }), + } + async run(): Promise { + const {args, flags} = await this.parse(FlowPublish) + if (!flags.yes) + this.error('Pass --yes to confirm this Flow change.', {exit: 2}) + const resource = await this.flows(flags) + const result = await resource.publish(args.flow, { + sessionId: flags.session, + ...(flags['request-id'] === undefined + ? {} + : {requestId: flags['request-id']}), + }) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/receipts.ts b/src/commands/flow/receipts.ts new file mode 100644 index 00000000..1dedd206 --- /dev/null +++ b/src/commands/flow/receipts.ts @@ -0,0 +1,15 @@ +import {Args} from '@oclif/core' +import {FlowsCommand} from '../../flows/command.js' +export default class FlowReceipts extends FlowsCommand { + static override description = + 'Read provider operation receipts, including uncertain outcomes. Requires sessions:read.' + static override args = { + flow: Args.string({description: 'Flow draft UUID.', required: true}), + } + async run(): Promise { + const {args, flags} = await this.parse(FlowReceipts) + const resource = await this.flows(flags) + const result = await resource.receipts(args.flow) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/sync.ts b/src/commands/flow/sync.ts new file mode 100644 index 00000000..6fd6ca5a --- /dev/null +++ b/src/commands/flow/sync.ts @@ -0,0 +1,30 @@ +import {Args, Flags} from '@oclif/core' +import {FlowsCommand} from '../../flows/command.js' +export default class FlowSync extends FlowsCommand { + static override description = + 'Reconcile provider status without replaying uncertain writes (beta). Requires sessions:manage.' + static override args = { + flow: Args.string({description: 'Flow draft UUID.', required: true}), + } + static override flags = { + session: Flags.string({ + description: 'Number name or ID in the same project.', + required: true, + }), + 'request-id': Flags.string({ + description: + 'UUID for this exact provider operation. Reuse to inspect the original receipt.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(FlowSync) + const resource = await this.flows(flags) + const result = await resource.sync(args.flow, { + sessionId: flags.session, + ...(flags['request-id'] === undefined + ? {} + : {requestId: flags['request-id']}), + }) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/update.ts b/src/commands/flow/update.ts new file mode 100644 index 00000000..8f03e6b7 --- /dev/null +++ b/src/commands/flow/update.ts @@ -0,0 +1,25 @@ +import {Args, Flags} from '@oclif/core' +import {FlowsCommand} from '../../flows/command.js' +import {readJsonInput} from '../../api/request.js' +import {parseFlowUpdate} from '../../services/flows.js' +export default class FlowUpdate extends FlowsCommand { + static override description = + 'Update a local project Flow draft. Requires sessions:manage.' + static override args = { + flow: Args.string({description: 'Flow draft UUID.', required: true}), + } + static override flags = { + input: Flags.string({ + description: + 'JSON file or - for stdin. Project and path identity come from command flags.', + required: true, + }), + } + async run(): Promise { + const {args, flags} = await this.parse(FlowUpdate) + const body = parseFlowUpdate(await readJsonInput(flags.input)) + const resource = await this.flows(flags) + const result = await resource.update(args.flow, body) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/upload.ts b/src/commands/flow/upload.ts new file mode 100644 index 00000000..e1d47a92 --- /dev/null +++ b/src/commands/flow/upload.ts @@ -0,0 +1,48 @@ +import {Args, Flags} from '@oclif/core' +import type {FlowCategory} from '@polymorfa/sdk' +import {FlowsCommand} from '../../flows/command.js' +export default class FlowUpload extends FlowsCommand { + static override description = + 'Upload the Flow to one Number (beta). Requires sessions:manage. Does not retry uncertain writes.' + static override args = { + flow: Args.string({description: 'Flow draft UUID.', required: true}), + } + static override flags = { + session: Flags.string({ + description: 'Number name or ID in the same project.', + required: true, + }), + 'request-id': Flags.string({ + description: + 'UUID for this exact provider operation. Reuse to inspect the original receipt.', + }), + category: Flags.string({ + description: 'WhatsApp Flow category; repeat for multiple categories.', + multiple: true, + options: [ + 'SIGN_UP', + 'SIGN_IN', + 'APPOINTMENT_BOOKING', + 'LEAD_GENERATION', + 'CONTACT_US', + 'CUSTOMER_SUPPORT', + 'SURVEY', + 'OTHER', + ], + }), + } + async run(): Promise { + const {args, flags} = await this.parse(FlowUpload) + const resource = await this.flows(flags) + const result = await resource.upload(args.flow, { + sessionId: flags.session, + ...(flags['request-id'] === undefined + ? {} + : {requestId: flags['request-id']}), + ...(flags.category === undefined + ? {} + : {categories: flags.category as FlowCategory[]}), + }) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/message/typing.ts b/src/commands/message/typing.ts index f23b798c..9024c5b4 100644 --- a/src/commands/message/typing.ts +++ b/src/commands/message/typing.ts @@ -7,15 +7,21 @@ import {setMessageTyping} from '../../services/messages.js' const states = ['typing', 'recording', 'paused'] as const export default class MessageTyping extends BaseCommand { - static override description = 'Set the typing state for a chat.' + static override description = + 'Set typing for a chat. Official Numbers require --id and --state typing; the inbound message is marked read.' static override flags = { chat: Flags.string({ description: 'Conversation ID, E.164 phone number, or BSUID.', required: true, }), + id: Flags.string({ + description: + 'Inbound message ID. Required on Official Numbers; marks the message read and shows typing for up to 25 seconds.', + }), 'idempotency-key': Flags.string({ - description: 'Safely identify retries of this typing update.', + description: + 'Request identifier. The API does not deduplicate typing updates by this header.', }), session: Flags.string({ description: 'Messaging session ID.', @@ -33,7 +39,11 @@ export default class MessageTyping extends BaseCommand { const result = await setMessageTyping( await this.messagingClient(flags), flags.session, - {conversation: conversationReference(flags.chat), state: flags.state}, + { + conversation: conversationReference(flags.chat), + state: flags.state, + ...(flags.id === undefined ? {} : {id: flags.id}), + }, flags['idempotency-key'] === undefined ? {} : {idempotencyKey: flags['idempotency-key']}, diff --git a/src/commands/quicklink/view.ts b/src/commands/quicklink/view.ts index fbd5b5b9..ddac9296 100644 --- a/src/commands/quicklink/view.ts +++ b/src/commands/quicklink/view.ts @@ -14,10 +14,12 @@ export default class QuickLinkView extends BaseCommand { const {client} = await this.quickLinksClient(flags) const response = await client.quickLinks.retrieve(args.quicklink) const quickLink = response.data.data - this.writeValue( - response, - `${quickLink.id}\t${quickLink.status}\t${quickLink.session}`, - flags, - ) + const sync = quickLink.onboarding?.sync + const summary = `${quickLink.id}\t${quickLink.status}\t${quickLink.session}` + const progress = + sync === undefined + ? '' + : `\nContacts request: ${sync.contacts.request}; receipt recorded: ${sync.contacts.receiptRecorded}\nHistory request: ${sync.history.request}; receipt recorded: ${sync.history.receiptRecorded}; delivery: ${sync.history.delivery}` + this.writeValue(response, summary + progress, flags) } } diff --git a/src/flows/command.ts b/src/flows/command.ts new file mode 100644 index 00000000..f3692861 --- /dev/null +++ b/src/flows/command.ts @@ -0,0 +1,21 @@ +import {PolymorfaConfigurationError} from '@polymorfa/sdk' +import {z} from 'zod' +import {BaseCommand, type GlobalFlags} from '../base-command.js' + +export abstract class FlowsCommand extends BaseCommand { + protected async flows(flags: GlobalFlags) { + if (flags.owner !== undefined && flags.owner !== 'project') + throw new PolymorfaConfigurationError( + 'Flows are project-owned; use --owner project.', + 'owner', + ) + const scope = await this.developerSession({...flags, owner: 'project'}) + const projectId = scope.context.projectId + if (!z.string().uuid().safeParse(projectId).success || !projectId) + throw new PolymorfaConfigurationError( + 'Provide --project with the UUID of the Flow project.', + 'project', + ) + return scope.client.project(projectId).flows + } +} diff --git a/src/services/cloud.ts b/src/services/cloud.ts new file mode 100644 index 00000000..a3e40da5 --- /dev/null +++ b/src/services/cloud.ts @@ -0,0 +1,68 @@ +import { + PolymorfaConfigurationError, + type ApiResponse, + type CreateCloudTemplateRequest, + type EditCloudTemplateRequest, +} from '@polymorfa/sdk' +import {z} from 'zod' + +const template = z + .object({ + name: z.string().min(1), + language: z.string().min(1), + category: z.enum(['MARKETING', 'UTILITY', 'AUTHENTICATION']), + components: z.array(z.unknown()).min(1), + }) + .strict() + +export function parseCloudTemplate(value: unknown): CreateCloudTemplateRequest { + const parsed = template.safeParse(value) + if (!parsed.success) + throw new PolymorfaConfigurationError( + 'Template input requires name, language, category and a nonempty components array.', + 'input', + ) + return parsed.data +} + +export function cloudResult( + response: ApiResponse<{ + readonly success: boolean + readonly data?: T + readonly message?: string + }>, +) { + return { + data: response.data.data ?? { + success: response.data.success, + ...(response.data.message === undefined + ? {} + : {message: response.data.message}), + }, + metadata: { + attempts: response.metadata.attempts, + status: response.metadata.status, + ...(response.metadata.apiVersion === undefined + ? {} + : {apiVersion: response.metadata.apiVersion}), + ...(response.metadata.requestId === undefined + ? {} + : {requestId: response.metadata.requestId}), + }, + } +} + +export function parseCloudTemplateEdit( + value: unknown, +): EditCloudTemplateRequest { + const parsed = z + .object({components: z.array(z.unknown()).min(1)}) + .strict() + .safeParse(value) + if (!parsed.success) + throw new PolymorfaConfigurationError( + 'Template edit input requires a nonempty components array.', + 'input', + ) + return parsed.data +} diff --git a/src/services/flows.ts b/src/services/flows.ts new file mode 100644 index 00000000..826dc9d1 --- /dev/null +++ b/src/services/flows.ts @@ -0,0 +1,50 @@ +import { + PolymorfaConfigurationError, + type CreateFlowRequest, + type UpdateFlowRequest, +} from '@polymorfa/sdk' +import {z} from 'zod' +const name = z.string().min(1).max(120) +const definition = z.record(z.string(), z.unknown()) +const create = z + .object({draftId: z.string().uuid().optional(), name, definition}) + .strict() +const update = z + .object({ + expectedUpdatedAt: z.number().int().nonnegative(), + name: name.optional(), + status: z.enum(['draft', 'ready', 'archived']).optional(), + definition: definition.optional(), + }) + .strict() +export function parseFlowCreate(value: unknown): CreateFlowRequest { + const parsed = create.safeParse(value) + if (!parsed.success) + throw new PolymorfaConfigurationError( + 'Flow input requires name and definition; optional draftId must be a UUID.', + 'input', + ) + return { + name: parsed.data.name, + definition: parsed.data.definition, + ...(parsed.data.draftId === undefined + ? {} + : {draftId: parsed.data.draftId}), + } +} +export function parseFlowUpdate(value: unknown): UpdateFlowRequest { + const parsed = update.safeParse(value) + if (!parsed.success) + throw new PolymorfaConfigurationError( + 'Flow update requires expectedUpdatedAt and optional name, status, definition.', + 'input', + ) + return { + expectedUpdatedAt: parsed.data.expectedUpdatedAt, + ...(parsed.data.name === undefined ? {} : {name: parsed.data.name}), + ...(parsed.data.status === undefined ? {} : {status: parsed.data.status}), + ...(parsed.data.definition === undefined + ? {} + : {definition: parsed.data.definition}), + } +} diff --git a/test/commands/cloud.test.ts b/test/commands/cloud.test.ts new file mode 100644 index 00000000..e2bc6544 --- /dev/null +++ b/test/commands/cloud.test.ts @@ -0,0 +1,354 @@ +import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {homedir} from 'node:os' +import {join} from 'node:path' +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +describe('Official API commands', () => { + let server: TestHttpServer | undefined + let directory: string | undefined + afterEach(async () => { + await server?.close() + server = undefined + if (directory) await rm(directory, {recursive: true, force: true}) + directory = undefined + }) + + test('uses the typed catalog and preserves IDs in structured output', async () => { + const data = [ + { + id: 'template_1', + metaTemplateId: '98765432109876543210', + name: 'order_update', + language: 'pt_BR', + }, + ] + server = await startHttpServer({body: {success: true, data}}) + const result = await credential(() => + runCommand('cloud template list --session number/eu --json'), + ) + expect(result.error).toBeUndefined() + expect((JSON.parse(result.stdout) as {data: unknown}).data).toEqual(data) + expect(server.requests[0]?.url).toBe('/messaging/number%2Feu/templates') + }) + + test('encodes a template name and forwards its selected language', async () => { + server = await startHttpServer({ + body: {success: true, data: {name: 'order/update', language: 'pt_BR'}}, + }) + const result = await credential(() => + runCommand( + 'cloud template get order/update --session number/eu --language pt_BR --json', + ), + ) + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toBe( + '/messaging/number%2Feu/templates/order%2Fupdate?language=pt_BR', + ) + }) + + test('submits bounded template JSON once', async () => { + directory = await mkdtemp( + join(homedir(), '.polymorfa-agent-work', 'cloud-cli-test-'), + ) + const path = join(directory, 'template.json') + const body = { + name: 'order_update', + language: 'en_US', + category: 'UTILITY', + components: [{type: 'BODY', text: 'Order ready'}], + } + await writeFile(path, JSON.stringify(body)) + server = await startHttpServer({ + status: 502, + body: { + error: { + code: 'upstream_failure', + message: 'Provider outcome is unknown', + }, + }, + }) + const result = await credential(() => + runCommand( + `cloud template create --session number --input ${path} --json`, + ), + ) + expect(exitCode(result.error)).toBe(5) + expect(server.requests).toHaveLength(1) + expect(JSON.parse(server.requests[0]!.body)).toEqual(body) + }) + + test('edits a selected template language and reports acceptance', async () => { + directory = await mkdtemp( + join(homedir(), '.polymorfa-agent-work', 'cloud-cli-test-'), + ) + const path = join(directory, 'edit.json') + const body = {components: [{type: 'BODY', text: 'Ready'}]} + await writeFile(path, JSON.stringify(body)) + server = await startHttpServer({ + status: 202, + body: { + success: true, + data: {accepted: true, name: 'order/update', language: 'pt_BR'}, + }, + }) + const result = await credential(() => + runCommand( + `cloud template edit order/update --session number --language pt_BR --input ${path} --json`, + ), + ) + expect(result.error).toBeUndefined() + expect(JSON.parse(result.stdout)).toMatchObject({ + data: {accepted: true, language: 'pt_BR'}, + metadata: {status: 202}, + }) + expect(server.requests[0]).toMatchObject({ + method: 'PATCH', + url: '/messaging/number/templates/order%2Fupdate?language=pt_BR', + }) + expect(JSON.parse(server.requests[0]!.body)).toEqual(body) + }) + + test('rejects an edit containing a renamed template before dispatch', async () => { + directory = await mkdtemp( + join(homedir(), '.polymorfa-agent-work', 'cloud-cli-test-'), + ) + const path = join(directory, 'invalid.json') + await writeFile(path, JSON.stringify({name: 'renamed', components: [{}]})) + server = await startHttpServer({body: {success: true}}) + const result = await credential(() => + runCommand( + `cloud template edit order --session number --input ${path} --json`, + ), + ) + expect(exitCode(result.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + test('preserves unknown sync evidence in the human QuickLink status', async () => { + server = await startHttpServer({ + body: { + success: true, + data: { + id: 'ql_1', + status: 'connected', + session: 'number', + purpose: 'reauthorization', + onboarding: { + sync: { + contacts: {request: 'accepted', receiptRecorded: true}, + history: { + request: 'unknown', + receiptRecorded: false, + delivery: 'unconfirmed', + }, + }, + }, + }, + }, + }) + const result = await credential( + () => + runCommand( + 'quicklink view ql_1 --project 11111111-1111-4111-8111-111111111111', + ), + 'project', + ) + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).toContain( + 'Contacts request: accepted; receipt recorded: true', + ) + expect(result.stdout).toContain( + 'History request: unknown; receipt recorded: false; delivery: unconfirmed', + ) + expect(server.requests).toHaveLength(1) + }) + + test('forwards the inbound Official typing ID unchanged', async () => { + server = await startHttpServer({ + body: {success: true, data: {status: 'OK'}}, + }) + const result = await credential(() => + runCommand( + 'message typing --session number --chat 730000000001 --id 740000000001 --state typing --json', + ), + ) + expect(result.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({ + method: 'POST', + url: '/messaging/number/messages/typing', + }) + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + conversation: {id: '730000000001'}, + id: '740000000001', + state: 'typing', + }) + }) + + test('requires deletion confirmation before any request', async () => { + server = await startHttpServer({body: {success: true}}) + const refused = await credential(() => + runCommand('cloud template delete order_update --session number --json'), + ) + expect(refused.error).toBeDefined() + expect(server.requests).toHaveLength(0) + const confirmed = await credential(() => + runCommand( + 'cloud template delete order_update --session number --yes --json', + ), + ) + expect(confirmed.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({ + method: 'DELETE', + url: '/messaging/number/templates/order_update', + }) + }) + + test('preserves unknown service-window observations for a project token', async () => { + const data = { + state: 'unknown', + reason: 'notifications_interrupted', + openedAt: null, + expiresAt: null, + checkedAt: '2026-09-26T00:00:00Z', + } + server = await startHttpServer({body: {success: true, data}}) + const result = await credential( + () => + runCommand( + 'cloud service-window +5511999999999 --session number --json', + ), + 'project', + ) + expect(result.error).toBeUndefined() + expect((JSON.parse(result.stdout) as {data: unknown}).data).toEqual(data) + expect(server.requests[0]?.url).toBe( + '/messaging/number/chats/%2B5511999999999/service-window', + ) + }) + + test('passes ISO pricing bounds and preserves unknown billing classifications', async () => { + const data = { + source: 'meta', + messages: 1, + groups: [ + { + category: 'utility', + pricingModel: 'PMP', + pricingType: null, + billable: null, + messages: 1, + }, + ], + } + server = await startHttpServer({body: {success: true, data}}) + const result = await credential(() => + runCommand( + 'cloud pricing --session number --since 2026-09-01T00:00:00Z --until 2026-09-26T00:00:00Z --json', + ), + ) + expect(result.error).toBeUndefined() + expect((JSON.parse(result.stdout) as {data: unknown}).data).toEqual(data) + expect(server.requests[0]?.url).toBe( + '/messaging/number/meta-pricing?since=2026-09-01T00%3A00%3A00Z&until=2026-09-26T00%3A00%3A00Z', + ) + }) + + test('reads health without starting reauthorization', async () => { + server = await startHttpServer({ + body: { + success: true, + data: {status: 'action_required', failureCode: 'token_expired'}, + }, + }) + const result = await credential(() => + runCommand('cloud credentials get --session number --json'), + ) + expect(result.error).toBeUndefined() + expect(server.requests).toHaveLength(1) + expect(server.requests[0]).toMatchObject({ + method: 'GET', + url: '/messaging/number/cloud-credentials', + }) + }) + + test('does not retry an uncertain reauthorization or stop the Number', async () => { + server = await startHttpServer({ + status: 502, + body: {error: {code: 'upstream_failure', message: 'Unknown'}}, + }) + const result = await credential(() => + runCommand('cloud credentials reauthorize --session number --json'), + ) + expect(exitCode(result.error)).toBe(5) + expect(server.requests).toHaveLength(1) + expect(server.requests[0]).toMatchObject({ + method: 'POST', + url: '/messaging/number/cloud-credentials/reauthorize', + }) + }) + + test('rejects client tokens before request dispatch', async () => { + server = await startHttpServer({body: {success: true, data: {}}}) + const result = await credential( + () => runCommand('cloud credentials get --session number --json'), + 'client', + ) + expect(exitCode(result.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + test.each([ + [403, 3], + [409, 4], + [503, 5], + ])('preserves a %s API failure as exit %s', async (status, expected) => { + server = await startHttpServer({ + status, + body: { + error: { + code: + status === 403 + ? 'feature_unavailable' + : status === 409 + ? 'state_conflict' + : 'service_unavailable', + message: 'Unavailable', + }, + }, + }) + const result = await credential(() => + runCommand('cloud credentials reauthorize --session number --json'), + ) + expect(exitCode(result.error)).toBe(expected) + expect(result.stdout).not.toContain('"success":true') + }) + + async function credential( + run: () => Promise, + kind: 'api' | 'project' | 'client' = 'api', + ) { + if (!server) throw Error('Server is not started') + return withEnvironment( + { + POLYMORFA_API_KEY: + kind === 'api' ? `pmfa_${'A'.repeat(72)}` : undefined, + POLYMORFA_PROJECT_TOKEN: + kind === 'project' ? `pmfa_pt_${'A'.repeat(94)}` : undefined, + POLYMORFA_CLIENT_TOKEN: + kind === 'client' ? `pmfa_ct_${'A'.repeat(94)}` : undefined, + POLYMORFA_BASE_URL: server.baseUrl, + }, + run, + ) + } +}) + +function exitCode(error: unknown) { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +} diff --git a/test/commands/flows.test.ts b/test/commands/flows.test.ts new file mode 100644 index 00000000..8e9594f9 --- /dev/null +++ b/test/commands/flows.test.ts @@ -0,0 +1,116 @@ +import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {homedir} from 'node:os' +import {join} from 'node:path' +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' +const project = '11111111-2222-4333-8444-555555555555' +const flow = '22222222-3333-4444-8555-666666666666' +const request = '33333333-3333-4333-8333-333333333333' +describe('project Flow commands', () => { + let server: TestHttpServer | undefined + let directory: string | undefined + afterEach(async () => { + await server?.close() + server = undefined + if (directory) await rm(directory, {recursive: true, force: true}) + directory = undefined + }) + async function run(command: string, kind: 'project' | 'client' = 'project') { + return withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_PROJECT_TOKEN: + kind === 'project' ? `pmfa_pt_${'A'.repeat(94)}` : undefined, + POLYMORFA_CLIENT_TOKEN: + kind === 'client' ? `pmfa_ct_${'A'.repeat(94)}` : undefined, + POLYMORFA_BASE_URL: server!.baseUrl, + }, + () => runCommand(command), + ) + } + test('reads the bound project and preserves a nullable draft', async () => { + server = await startHttpServer({body: {data: null}}) + const result = await run(`flow get ${flow} --project ${project} --json`) + expect(result.error).toBeUndefined() + expect(JSON.parse(result.stdout)).toMatchObject({data: null}) + expect(server.requests[0]?.url).toBe( + `/platform/flows/${flow}?projectId=${project}`, + ) + }) + test('preserves an uncertain operation receipt and its request identity', async () => { + const data = { + operation: {state: 'uncertain', requestId: request}, + flow: {id: flow}, + } + server = await startHttpServer({body: {data}}) + const result = await run( + `flow upload ${flow} --project ${project} --session support --category APPOINTMENT_BOOKING --request-id ${request} --json`, + ) + expect(result.error).toBeUndefined() + expect(JSON.parse(result.stdout)).toMatchObject({data}) + expect(server.requests).toHaveLength(1) + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + projectId: project, + sessionId: 'support', + categories: ['APPOINTMENT_BOOKING'], + requestId: request, + }) + }) + test.each(['publish', 'deprecate', 'discard', 'delete'])( + 'requires confirmation before %s', + async (action) => { + server = await startHttpServer({body: {data: {ok: true}}}) + const result = await run( + `flow ${action} ${flow} --project ${project}${action === 'delete' ? '' : ' --session support'} --json`, + ) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) + }, + ) + test('never retries an unknown provider publication', async () => { + server = await startHttpServer({ + status: 503, + body: {error: {code: 'service_unavailable', message: 'Unknown outcome'}}, + }) + const result = await run( + `flow publish ${flow} --project ${project} --session support --yes --json`, + ) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(1) + expect(server.requests[0]).toMatchObject({ + method: 'POST', + url: `/platform/flows/${flow}/publish`, + }) + }) + test('rejects project override in a draft file before dispatch', async () => { + directory = await mkdtemp( + join(homedir(), '.polymorfa-agent-work', 'flows-cli-test-'), + ) + const path = join(directory, 'draft.json') + await writeFile( + path, + JSON.stringify({ + projectId: 'another', + name: 'Booking', + definition: {version: '7.1'}, + }), + ) + server = await startHttpServer({body: {data: {}}}) + const result = await run( + `flow create --project ${project} --input ${path} --json`, + ) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) + }) + test('rejects client credentials before reading a project', async () => { + server = await startHttpServer({body: {data: []}}) + const result = await run(`flow list --project ${project} --json`, 'client') + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) + }) +}) From 48c555a6be5d100675e1e2f30f051a9cc38d9c45 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 22:44:54 +0200 Subject: [PATCH 2/5] Add Flow encryption key commands --- README.md | 12 ++++++++++++ package.json | 3 +++ src/commands/flow/key/get.ts | 16 ++++++++++++++++ src/commands/flow/key/register.ts | 24 ++++++++++++++++++++++++ 4 files changed, 55 insertions(+) create mode 100644 src/commands/flow/key/get.ts create mode 100644 src/commands/flow/key/register.ts diff --git a/README.md b/README.md index bb1dacb9..dfc7c9e6 100644 --- a/README.md +++ b/README.md @@ -348,6 +348,18 @@ can still carry an `uncertain` operation: read its receipt and use `sync` to reconcile. Preserve `--request-id` for the exact same operation; a new ID does not resolve an uncertain outcome. +Customer-hosted dynamic Flow endpoints use a public key registered to the Meta +phone number. Read that key with `polymorfa flow key get --phone-number-id +PHONE_NUMBER_ID --graph-version v26.0`. Register or replace it with `polymorfa +flow key register --phone-number-id PHONE_NUMBER_ID --graph-version v26.0 +--input public-key.json --yes`, where the JSON file contains only +`business_public_key` as a 2048-bit RSA public key in PEM format. The matching +private key stays on your endpoint. Registration affects every dynamic Flow on +the phone number, so read the registered key after an uncertain outcome before +submitting another replacement. Reading requires `sessions:read`; registration +requires `sessions:manage`, an eligible Official API Number, and Graph access. +These commands do not enable dynamic Flow publishing. + ### Official API observations and templates The `cloud` commands require an organization API key or a project token with the diff --git a/package.json b/package.json index 2c733b36..84f7243e 100644 --- a/package.json +++ b/package.json @@ -306,6 +306,9 @@ "flow": { "description": "Manage project Flow drafts and their Official API lifecycle." }, + "flow:key": { + "description": "Read and register phone-scoped Flow encryption public keys." + }, "cloud:catalog": { "description": "Read Meta product catalogs visible to a WhatsApp Business Account." } diff --git a/src/commands/flow/key/get.ts b/src/commands/flow/key/get.ts new file mode 100644 index 00000000..60ff4579 --- /dev/null +++ b/src/commands/flow/key/get.ts @@ -0,0 +1,16 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' + +export default class FlowKeyGet extends BaseCommand { + static override description = 'Read the public Flow encryption key registered to a Meta phone number. Requires sessions:read.' + static override flags = { + 'phone-number-id': Flags.string({description:'Meta phone number ID.',required:true}), + 'graph-version': Flags.string({description:'Meta Graph version, for example v26.0.',required:true}), + } + async run(): Promise { + const {flags} = await this.parse(FlowKeyGet) + const client = await this.messagingClient(flags,{serverCredentialOnly:'Official API'}) + const response = await client.flowEncryption.retrieve(flags['phone-number-id'],{version:flags['graph-version']}) + this.writeValue(response,JSON.stringify(response.data,null,2),flags) + } +} diff --git a/src/commands/flow/key/register.ts b/src/commands/flow/key/register.ts new file mode 100644 index 00000000..6a083d98 --- /dev/null +++ b/src/commands/flow/key/register.ts @@ -0,0 +1,24 @@ +import {Flags} from '@oclif/core' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' +import {z} from 'zod' +import {BaseCommand} from '../../../base-command.js' +import {readJsonInput} from '../../../api/request.js' + +export default class FlowKeyRegister extends BaseCommand { + static override description = 'Replace the public key used by every dynamic Flow on this Meta phone number. Requires sessions:manage; retain the matching private key on your endpoint.' + static override flags = { + 'phone-number-id': Flags.string({description:'Meta phone number ID.',required:true}), + 'graph-version': Flags.string({description:'Meta Graph version, for example v26.0.',required:true}), + input: Flags.string({description:'JSON file containing business_public_key, or - for stdin.',required:true}), + yes: Flags.boolean({description:'Confirm replacing the public key for every Flow on this phone number.',required:true}), + } + async run(): Promise { + const {flags} = await this.parse(FlowKeyRegister) + if (!flags.yes) throw new PolymorfaConfigurationError('Pass --yes to replace the phone number’s Flow public key.','yes') + const body = z.object({business_public_key:z.string().min(1).max(8192)}).strict().safeParse(await readJsonInput(flags.input)) + if (!body.success) throw new PolymorfaConfigurationError('Input requires only business_public_key in PEM format.','input') + const client = await this.messagingClient(flags,{serverCredentialOnly:'Official API'}) + const response = await client.flowEncryption.register(flags['phone-number-id'],{businessPublicKey:body.data.business_public_key},{version:flags['graph-version']}) + this.writeValue(response,JSON.stringify(response.data,null,2),flags) + } +} From 9e8991230570ff7b63e6de19f9a4196cc8d98d6c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 23:33:31 +0200 Subject: [PATCH 3/5] style(cli): format Flow key commands --- src/commands/flow/key/get.ts | 24 ++++++++++---- src/commands/flow/key/register.ts | 53 ++++++++++++++++++++++++------- 2 files changed, 60 insertions(+), 17 deletions(-) diff --git a/src/commands/flow/key/get.ts b/src/commands/flow/key/get.ts index 60ff4579..a0c066ce 100644 --- a/src/commands/flow/key/get.ts +++ b/src/commands/flow/key/get.ts @@ -2,15 +2,27 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../../base-command.js' export default class FlowKeyGet extends BaseCommand { - static override description = 'Read the public Flow encryption key registered to a Meta phone number. Requires sessions:read.' + static override description = + 'Read the public Flow encryption key registered to a Meta phone number. Requires sessions:read.' static override flags = { - 'phone-number-id': Flags.string({description:'Meta phone number ID.',required:true}), - 'graph-version': Flags.string({description:'Meta Graph version, for example v26.0.',required:true}), + 'phone-number-id': Flags.string({ + description: 'Meta phone number ID.', + required: true, + }), + 'graph-version': Flags.string({ + description: 'Meta Graph version, for example v26.0.', + required: true, + }), } async run(): Promise { const {flags} = await this.parse(FlowKeyGet) - const client = await this.messagingClient(flags,{serverCredentialOnly:'Official API'}) - const response = await client.flowEncryption.retrieve(flags['phone-number-id'],{version:flags['graph-version']}) - this.writeValue(response,JSON.stringify(response.data,null,2),flags) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.flowEncryption.retrieve( + flags['phone-number-id'], + {version: flags['graph-version']}, + ) + this.writeValue(response, JSON.stringify(response.data, null, 2), flags) } } diff --git a/src/commands/flow/key/register.ts b/src/commands/flow/key/register.ts index 6a083d98..90f5b42f 100644 --- a/src/commands/flow/key/register.ts +++ b/src/commands/flow/key/register.ts @@ -5,20 +5,51 @@ import {BaseCommand} from '../../../base-command.js' import {readJsonInput} from '../../../api/request.js' export default class FlowKeyRegister extends BaseCommand { - static override description = 'Replace the public key used by every dynamic Flow on this Meta phone number. Requires sessions:manage; retain the matching private key on your endpoint.' + static override description = + 'Replace the public key used by every dynamic Flow on this Meta phone number. Requires sessions:manage; retain the matching private key on your endpoint.' static override flags = { - 'phone-number-id': Flags.string({description:'Meta phone number ID.',required:true}), - 'graph-version': Flags.string({description:'Meta Graph version, for example v26.0.',required:true}), - input: Flags.string({description:'JSON file containing business_public_key, or - for stdin.',required:true}), - yes: Flags.boolean({description:'Confirm replacing the public key for every Flow on this phone number.',required:true}), + 'phone-number-id': Flags.string({ + description: 'Meta phone number ID.', + required: true, + }), + 'graph-version': Flags.string({ + description: 'Meta Graph version, for example v26.0.', + required: true, + }), + input: Flags.string({ + description: 'JSON file containing business_public_key, or - for stdin.', + required: true, + }), + yes: Flags.boolean({ + description: + 'Confirm replacing the public key for every Flow on this phone number.', + required: true, + }), } async run(): Promise { const {flags} = await this.parse(FlowKeyRegister) - if (!flags.yes) throw new PolymorfaConfigurationError('Pass --yes to replace the phone number’s Flow public key.','yes') - const body = z.object({business_public_key:z.string().min(1).max(8192)}).strict().safeParse(await readJsonInput(flags.input)) - if (!body.success) throw new PolymorfaConfigurationError('Input requires only business_public_key in PEM format.','input') - const client = await this.messagingClient(flags,{serverCredentialOnly:'Official API'}) - const response = await client.flowEncryption.register(flags['phone-number-id'],{businessPublicKey:body.data.business_public_key},{version:flags['graph-version']}) - this.writeValue(response,JSON.stringify(response.data,null,2),flags) + if (!flags.yes) + throw new PolymorfaConfigurationError( + 'Pass --yes to replace the phone number’s Flow public key.', + 'yes', + ) + const body = z + .object({business_public_key: z.string().min(1).max(8192)}) + .strict() + .safeParse(await readJsonInput(flags.input)) + if (!body.success) + throw new PolymorfaConfigurationError( + 'Input requires only business_public_key in PEM format.', + 'input', + ) + const client = await this.messagingClient(flags, { + serverCredentialOnly: 'Official API', + }) + const response = await client.flowEncryption.register( + flags['phone-number-id'], + {businessPublicKey: body.data.business_public_key}, + {version: flags['graph-version']}, + ) + this.writeValue(response, JSON.stringify(response.data, null, 2), flags) } } From ce58584275cc2962955f81b0d72c7cecc110b82d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 1 Oct 2026 15:56:52 +0300 Subject: [PATCH 4/5] Pin @polymorfa/sdk 0.1.0-dev.20261001124422 and fence template retries Pin the SDK prerelease published from SDK dev a46d2f2, which contains the Official API consumers. Add --idempotency-key to cloud template create and delete (sent once, never retried). Drop the call-detail runtime guard now that the pinned SDK exposes Client.calls.retrieve. --- README.md | 12 ++++++++---- package-lock.json | 8 ++++---- package.json | 2 +- src/commands/cloud/template/create.ts | 14 +++++++++++--- src/commands/cloud/template/delete.ts | 7 ++++++- src/services/call-analytics.ts | 16 +--------------- src/services/cloud.ts | 8 ++++++++ test/commands/cloud.test.ts | 4 +++- test/release/check-prerelease.test.ts | 2 +- 9 files changed, 43 insertions(+), 30 deletions(-) diff --git a/README.md b/README.md index 46035399..86d88cdb 100644 --- a/README.md +++ b/README.md @@ -38,7 +38,7 @@ prerelease in `package.json`. The version output includes both runtime components: ```text -polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.20260926005918 +polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.20261001124422 ``` The SDK component reports the version built into the installed SDK. Dev @@ -448,7 +448,7 @@ beta feature. polymorfa cloud catalog list --waba WABA_ID --graph-version v26.0 --limit 25 polymorfa cloud template list --session number_123 polymorfa cloud template get order_update --session number_123 --language en_US -polymorfa cloud template create --session number_123 --input template.json +polymorfa cloud template create --session number_123 --input template.json --idempotency-key tpl-order-update polymorfa cloud template edit order_update --session number_123 --language en_US --input components.json polymorfa cloud template delete order_update --session number_123 --yes polymorfa cloud service-window +15551234567 --session number_123 @@ -462,8 +462,12 @@ catalog. They are separate from project template drafts. Creation reads a JSON object with `name`, `language`, `category`, and `components` from `--input`, or from stdin when it is `-`. Template names and provider IDs remain strings. Edits read `{ "components": [...] }` and return acceptance, not approval to send. -Deletion requires `--yes`. The native catalog routes reject Numbers originally -created as Linked Device, including their Hybrid mode. +Deletion requires `--yes`. Create, edit and delete send one request and never +retry. Create and delete accept `--idempotency-key`; reuse the same key after an +unknown outcome and the API answers `idempotency_completed` instead of writing +again. List the catalog before resubmitting. The template routes need an active +Official API connection, including on a Hybrid Number; a Number with only a +Linked Devices connection is rejected. Service-window reads use `chats:read`. They report observed `open`, `closed`, or `unknown` state; missing or interrupted notifications can prevent a reliable diff --git a/package-lock.json b/package-lock.json index bac52793..fe2a94a2 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.20260926005918", + "@polymorfa/sdk": "0.1.0-dev.20261001124422", "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.20260926005918", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260926005918.tgz", - "integrity": "sha512-PAkVAqN5QNSKcoT4i7DJHDRp5RfKbDmiJUDurkgxJYOyFR4d+wJ65HrAqkY5KGvBjtANsjkqRczmt8gQRAnGVw==", + "version": "0.1.0-dev.20261001124422", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261001124422.tgz", + "integrity": "sha512-nZbd0jpq7OhQseH3gLXwI+UoMQJCaxQYERJmyxEmvGJgLm+xHqaFFXA8uOf87Epq0uyn221lmDiK6R5/fsa0eA==", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index f0442dc1..bb66f97e 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.20260926005918", + "@polymorfa/sdk": "0.1.0-dev.20261001124422", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, diff --git a/src/commands/cloud/template/create.ts b/src/commands/cloud/template/create.ts index 8612879e..273a3d39 100644 --- a/src/commands/cloud/template/create.ts +++ b/src/commands/cloud/template/create.ts @@ -2,11 +2,11 @@ import {Flags} from '@oclif/core' import {BaseCommand} from '../../../base-command.js' import {cloudResult} from '../../../services/cloud.js' import {readJsonInput} from '../../../api/request.js' -import {parseCloudTemplate} from '../../../services/cloud.js' +import {parseCloudTemplate, requestKey} from '../../../services/cloud.js' export default class CloudTemplateCreate extends BaseCommand { static override description = - 'Submit one Meta template (beta). Requires messages:write. Does not retry an uncertain write.' + 'Submit one Meta template (beta). Requires messages:write. Sends once; pass --idempotency-key so a manual retry cannot submit twice.' static override flags = { session: Flags.string({ @@ -18,6 +18,10 @@ export default class CloudTemplateCreate extends BaseCommand { 'Template JSON file or - for stdin: name, language, category, components.', required: true, }), + 'idempotency-key': Flags.string({ + description: + 'Key that fences a manual retry: reuse it after an unknown outcome and the API returns idempotency_completed instead of writing again.', + }), } async run(): Promise { const {flags} = await this.parse(CloudTemplateCreate) @@ -26,7 +30,11 @@ export default class CloudTemplateCreate extends BaseCommand { serverCredentialOnly: 'Official API', }) const body = parseCloudTemplate(await readJsonInput(flags.input)) - const response = await client.cloudTemplates.create(flags.session, body) + const response = await client.cloudTemplates.create( + flags.session, + body, + requestKey(flags['idempotency-key']), + ) const result = cloudResult(response) this.writeValue(result, JSON.stringify(result.data, null, 2), flags) } diff --git a/src/commands/cloud/template/delete.ts b/src/commands/cloud/template/delete.ts index 76a02e4e..d49a6020 100644 --- a/src/commands/cloud/template/delete.ts +++ b/src/commands/cloud/template/delete.ts @@ -1,6 +1,6 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../../base-command.js' -import {cloudResult} from '../../../services/cloud.js' +import {cloudResult, requestKey} from '../../../services/cloud.js' import {PolymorfaConfigurationError} from '@polymorfa/sdk' export default class CloudTemplateDelete extends BaseCommand { @@ -21,6 +21,10 @@ export default class CloudTemplateDelete extends BaseCommand { description: 'Confirm deletion of all language variants.', required: true, }), + 'idempotency-key': Flags.string({ + description: + 'Key that fences a manual retry: reuse it after an unknown outcome and the API returns idempotency_completed instead of writing again.', + }), } async run(): Promise { const {args, flags} = await this.parse(CloudTemplateDelete) @@ -35,6 +39,7 @@ export default class CloudTemplateDelete extends BaseCommand { const response = await client.cloudTemplates.delete( flags.session, args.name, + requestKey(flags['idempotency-key']), ) const result = cloudResult(response) this.writeValue(result, JSON.stringify(result.data, null, 2), flags) diff --git a/src/services/call-analytics.ts b/src/services/call-analytics.ts index 9f223232..a6b3e636 100644 --- a/src/services/call-analytics.ts +++ b/src/services/call-analytics.ts @@ -1,8 +1,6 @@ import { Client, PolymorfaServerError, - PolymorfaConfigurationError, - type ApiResponse, type CallRecord, PolymorfaValidationError, type CallStats, @@ -133,19 +131,7 @@ export class CallAnalyticsApi { throw invalid( 'Call ID must contain 1 to 128 letters, digits, underscores or hyphens.', ) - const calls = this.calls as typeof this.calls & { - retrieve?: ( - id: string, - params: Query, - ) => Promise> - } - if (typeof calls.retrieve !== 'function') { - throw new PolymorfaConfigurationError( - 'Call detail requires a published SDK version that includes Client.calls.retrieve. This CLI has not activated a source-only SDK pin.', - 'sdk', - ) - } - const response = await calls.retrieve(callId, query) + const response = await this.calls.retrieve(callId, query) const data = response.data if ( !data || diff --git a/src/services/cloud.ts b/src/services/cloud.ts index a3e40da5..86dda8bb 100644 --- a/src/services/cloud.ts +++ b/src/services/cloud.ts @@ -1,3 +1,4 @@ +import {idempotencyKey} from '../input/idempotency.js' import { PolymorfaConfigurationError, type ApiResponse, @@ -66,3 +67,10 @@ export function parseCloudTemplateEdit( ) return parsed.data } + +/** Forwards a caller-supplied Idempotency-Key; the SDK still sends once. */ +export function requestKey(value: string | undefined): { + idempotencyKey?: string +} { + return value === undefined ? {} : {idempotencyKey: idempotencyKey(value)} +} diff --git a/test/commands/cloud.test.ts b/test/commands/cloud.test.ts index e2bc6544..8381edbd 100644 --- a/test/commands/cloud.test.ts +++ b/test/commands/cloud.test.ts @@ -75,12 +75,13 @@ describe('Official API commands', () => { }) const result = await credential(() => runCommand( - `cloud template create --session number --input ${path} --json`, + `cloud template create --session number --input ${path} --idempotency-key tpl-1 --json`, ), ) expect(exitCode(result.error)).toBe(5) expect(server.requests).toHaveLength(1) expect(JSON.parse(server.requests[0]!.body)).toEqual(body) + expect(server.requests[0]!.headers['idempotency-key']).toBe('tpl-1') }) test('edits a selected template language and reports acceptance', async () => { @@ -207,6 +208,7 @@ describe('Official API commands', () => { method: 'DELETE', url: '/messaging/number/templates/order_update', }) + expect(server.requests[0]!.headers['idempotency-key']).toBeUndefined() }) test('preserves unknown service-window observations for a project token', async () => { diff --git a/test/release/check-prerelease.test.ts b/test/release/check-prerelease.test.ts index 1faa33ce..60f98fe3 100644 --- a/test/release/check-prerelease.test.ts +++ b/test/release/check-prerelease.test.ts @@ -56,7 +56,7 @@ describe('npm prerelease guard', () => { test('accepts the reviewed manifest and lockfile', () => { const result = runWith() expect(result.status).toBe(0) - expect(result.stdout).toContain('@polymorfa/sdk@0.1.0-dev.20260926005918') + expect(result.stdout).toContain('@polymorfa/sdk@0.1.0-dev.20261001124422') }) test('rejects the default latest tag', () => { From 13124c29ed6476b511715436b81ca3a5bb5c5f0e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 1 Oct 2026 16:05:36 +0300 Subject: [PATCH 5/5] Update brace-expansion to clear the production audit --- package-lock.json | 30 +++++++++++++++--------------- 1 file changed, 15 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index fe2a94a2..2c47e2ef 100644 --- a/package-lock.json +++ b/package-lock.json @@ -510,9 +510,9 @@ "dev": true }, "node_modules/@eslint/config-array/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "dependencies": { "balanced-match": "^1.0.0", @@ -585,9 +585,9 @@ "dev": true }, "node_modules/@eslint/eslintrc/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "dependencies": { "balanced-match": "^1.0.0", @@ -1810,9 +1810,9 @@ } }, "node_modules/brace-expansion": { - "version": "5.0.9", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.9.tgz", - "integrity": "sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==", + "version": "5.0.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.12.tgz", + "integrity": "sha512-YovQ3rzhaLMIrDjNDMkNS01tea93qhEhG5xy8f6+R0l+dw3Ki+5sCoIoI942iuLZTHWogWktgwVDhU09iNEimQ==", "dependencies": { "balanced-match": "^4.0.2" }, @@ -2151,9 +2151,9 @@ "dev": true }, "node_modules/eslint/node_modules/brace-expansion": { - "version": "1.1.18", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz", - "integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==", + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.21.tgz", + "integrity": "sha512-9zeA+KLZNNzglF2TPKRQEDyx6Yby7daAkuy8MiPzpXPsYDWi/DRM8jmwUDxokQjYqBpv5DgPiwD4h4ZZSy1Ujw==", "dev": true, "dependencies": { "balanced-match": "^1.0.0", @@ -2521,9 +2521,9 @@ "dev": true }, "node_modules/js-yaml": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.1.tgz", - "integrity": "sha512-CY6crGq313MX8GkwvB7tzgp99vjQxY1++5y10/BKN/GUfHqWaOGQMNZkBvqSzsZKWk/ijwHlWzzkLulsGHhjWQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.3.2.tgz", + "integrity": "sha512-SFNOvSJ+Dgf/9An904Yx+CgSlIPCkIpao4qo51lpee25TIRejdH3rhR4EZMGoNx3/TP3O+wzWuiTFl4sqbltzA==", "dev": true, "funding": [ {