From c8a9977f5ac6aef494b07d6b6f3e764d1d4b4d3d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 4 Oct 2026 05:17:48 +0300 Subject: [PATCH 1/4] feat(cli): dynamic Flow endpoint and managed key commands [wip] --- src/commands/flow/endpoint/delete.ts | 30 +++++++++ src/commands/flow/endpoint/get.ts | 21 ++++++ src/commands/flow/endpoint/receipts.ts | 22 ++++++ src/commands/flow/endpoint/set.ts | 93 ++++++++++++++++++++++++++ src/commands/flow/key/custody.ts | 18 +++++ src/commands/flow/key/rotate.ts | 27 ++++++++ 6 files changed, 211 insertions(+) create mode 100644 src/commands/flow/endpoint/delete.ts create mode 100644 src/commands/flow/endpoint/get.ts create mode 100644 src/commands/flow/endpoint/receipts.ts create mode 100644 src/commands/flow/endpoint/set.ts create mode 100644 src/commands/flow/key/custody.ts create mode 100644 src/commands/flow/key/rotate.ts diff --git a/src/commands/flow/endpoint/delete.ts b/src/commands/flow/endpoint/delete.ts new file mode 100644 index 0000000..5b56eef --- /dev/null +++ b/src/commands/flow/endpoint/delete.ts @@ -0,0 +1,30 @@ +import {Args, Flags} from '@oclif/core' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' +import {FlowsCommand} from '../../../flows/command.js' +export default class FlowEndpointDelete extends FlowsCommand { + static override description = + "Remove a Flow's data endpoint on one Number (beta). WhatsApp requests to it are refused afterwards; receipts are kept. 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, + }), + yes: Flags.boolean({description: 'Confirm removing the endpoint.'}), + } + async run(): Promise { + const {args, flags} = await this.parse(FlowEndpointDelete) + if (!flags.yes) + throw new PolymorfaConfigurationError( + 'Pass --yes to remove the Flow endpoint.', + 'yes', + ) + const resource = await this.flows(flags) + const result = await resource.deleteEndpoint(args.flow, { + sessionId: flags.session, + }) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/endpoint/get.ts b/src/commands/flow/endpoint/get.ts new file mode 100644 index 0000000..02b33a4 --- /dev/null +++ b/src/commands/flow/endpoint/get.ts @@ -0,0 +1,21 @@ +import {Args, Flags} from '@oclif/core' +import {FlowsCommand} from '../../../flows/command.js' +export default class FlowEndpointGet extends FlowsCommand { + static override description = + "Read a Flow's data endpoint on one Number and the Number's encryption custody (beta). Requires sessions:read." + 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, + }), + } + async run(): Promise { + const {args, flags} = await this.parse(FlowEndpointGet) + const resource = await this.flows(flags) + const result = await resource.endpoint(args.flow, {sessionId: flags.session}) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/endpoint/receipts.ts b/src/commands/flow/endpoint/receipts.ts new file mode 100644 index 0000000..30e06d5 --- /dev/null +++ b/src/commands/flow/endpoint/receipts.ts @@ -0,0 +1,22 @@ +import {Args, Flags} from '@oclif/core' +import {FlowsCommand} from '../../../flows/command.js' +export default class FlowEndpointReceipts extends FlowsCommand { + static override description = + "Read a Flow's data-exchange receipts, newest first (beta). Metadata only: no form data, Flow token or response. Requires sessions:read." + static override args = { + flow: Args.string({description: 'Flow draft UUID.', required: true}), + } + static override flags = { + session: Flags.string({description: 'Only this Number (name or ID).'}), + limit: Flags.integer({description: '1 to 100 receipts.', min: 1, max: 100}), + } + async run(): Promise { + const {args, flags} = await this.parse(FlowEndpointReceipts) + const resource = await this.flows(flags) + const result = await resource.endpointReceipts(args.flow, { + ...(flags.session === undefined ? {} : {sessionId: flags.session}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + }) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/endpoint/set.ts b/src/commands/flow/endpoint/set.ts new file mode 100644 index 0000000..b771504 --- /dev/null +++ b/src/commands/flow/endpoint/set.ts @@ -0,0 +1,93 @@ +import {Args, Flags} from '@oclif/core' +import { + PolymorfaConfigurationError, + type SetFlowEndpointRequest, +} from '@polymorfa/sdk' +import {FlowsCommand} from '../../../flows/command.js' +export default class FlowEndpointSet extends FlowsCommand { + static override description = + "Create or replace a Flow's data endpoint on one Number (beta). Requires sessions:manage. forward and function need a Polymorfa-managed key; direct needs your own key. A forward signing secret is printed once. Makes one attempt." + 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, + }), + mode: Flags.string({ + description: + 'forward: Polymorfa decrypts and forwards signed JSON to --url. function: runs --function. direct: WhatsApp calls --url with your own key.', + options: ['forward', 'function', 'direct'], + required: true, + }), + url: Flags.string({description: 'HTTPS URL for forward or direct.'}), + function: Flags.string({description: 'Function UUID for function mode.'}), + deployment: Flags.string({ + description: 'Pin an immutable Function deployment UUID.', + }), + disabled: Flags.boolean({ + description: 'Save the endpoint disabled; WhatsApp requests are refused.', + }), + 'expected-revision': Flags.integer({ + description: 'Replace only when the stored revision matches.', + min: 1, + }), + 'rotate-signing-secret': Flags.boolean({ + description: 'Issue a new forward signing secret.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(FlowEndpointSet) + const common = { + sessionId: flags.session, + ...(flags.disabled ? {enabled: false} : {}), + ...(flags['expected-revision'] === undefined + ? {} + : {expectedRevision: flags['expected-revision']}), + } + let body: SetFlowEndpointRequest + if (flags.mode === 'function') { + if (!flags.function || flags.url || flags['rotate-signing-secret']) + throw new PolymorfaConfigurationError( + 'function mode takes --function and optional --deployment only.', + 'mode', + ) + body = { + ...common, + mode: 'function', + functionId: flags.function, + ...(flags.deployment ? {deploymentId: flags.deployment} : {}), + } + } else { + if (!flags.url || flags.function || flags.deployment) + throw new PolymorfaConfigurationError( + `${flags.mode} mode takes --url and no Function flags.`, + 'url', + ) + if (flags.mode === 'direct' && flags['rotate-signing-secret']) + throw new PolymorfaConfigurationError( + '--rotate-signing-secret applies only to forward mode.', + 'rotate-signing-secret', + ) + body = + flags.mode === 'forward' + ? { + ...common, + mode: 'forward', + url: flags.url, + ...(flags['rotate-signing-secret'] + ? {rotateSigningSecret: true} + : {}), + } + : {...common, mode: 'direct', url: flags.url} + } + const resource = await this.flows(flags) + const result = await resource.setEndpoint(args.flow, body) + if (result.data.signingSecret !== undefined) + this.warn( + 'The signing secret is shown once. Store it before closing this output.', + ) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/key/custody.ts b/src/commands/flow/key/custody.ts new file mode 100644 index 0000000..4e59792 --- /dev/null +++ b/src/commands/flow/key/custody.ts @@ -0,0 +1,18 @@ +import {Flags} from '@oclif/core' +import {FlowsCommand} from '../../../flows/command.js' +export default class FlowKeyCustody extends FlowsCommand { + static override description = + "Read whether Polymorfa holds a Number's Flow encryption key (managed) or not (customer), and recent managed keys (beta). Requires sessions:read." + static override flags = { + session: Flags.string({ + description: 'Number name or ID in the project.', + required: true, + }), + } + async run(): Promise { + const {flags} = await this.parse(FlowKeyCustody) + const resource = await this.flows(flags) + const result = await resource.encryptionKey({sessionId: flags.session}) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} diff --git a/src/commands/flow/key/rotate.ts b/src/commands/flow/key/rotate.ts new file mode 100644 index 0000000..b6701ee --- /dev/null +++ b/src/commands/flow/key/rotate.ts @@ -0,0 +1,27 @@ +import {Flags} from '@oclif/core' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' +import {FlowsCommand} from '../../../flows/command.js' +export default class FlowKeyRotate extends FlowsCommand { + static override description = + "Generate and register a new Polymorfa-managed Flow encryption key for a Number (beta). Every dynamic Flow on the Number uses it; the previous managed key decrypts for 24 hours. Requires sessions:manage. Makes one attempt." + static override flags = { + session: Flags.string({ + description: 'Number name or ID in the project.', + required: true, + }), + yes: Flags.boolean({ + description: 'Confirm replacing the key for every Flow on this Number.', + }), + } + async run(): Promise { + const {flags} = await this.parse(FlowKeyRotate) + if (!flags.yes) + throw new PolymorfaConfigurationError( + 'Pass --yes to rotate the Number’s Flow encryption key.', + 'yes', + ) + const resource = await this.flows(flags) + const result = await resource.rotateEncryptionKey({sessionId: flags.session}) + this.writeValue(result, JSON.stringify(result.data, null, 2), flags) + } +} From fb5751b564fa0ccac3f45a174fd8c9b2670e0466 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 4 Oct 2026 05:18:19 +0300 Subject: [PATCH 2/4] test(cli): dynamic Flow endpoint commands --- test/commands/flow-endpoints.test.ts | 103 +++++++++++++++++++++++++++ 1 file changed, 103 insertions(+) create mode 100644 test/commands/flow-endpoints.test.ts diff --git a/test/commands/flow-endpoints.test.ts b/test/commands/flow-endpoints.test.ts new file mode 100644 index 0000000..d0cbe16 --- /dev/null +++ b/test/commands/flow-endpoints.test.ts @@ -0,0 +1,103 @@ +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 fn = '44444444-3333-4444-8555-666666666666' +describe('dynamic Flow endpoint commands', () => { + let server: TestHttpServer | undefined + afterEach(async () => { + await server?.close() + server = undefined + }) + async function run(command: string) { + return withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_PROJECT_TOKEN: `pmfa_pt_${'A'.repeat(94)}`, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_BASE_URL: server!.baseUrl, + }, + () => runCommand(command), + ) + } + test('reads the endpoint, receipts and custody for one Number', async () => { + server = await startHttpServer({body: {data: {endpoint: null}}}) + for (const command of [ + `flow endpoint get ${flow} --session support`, + `flow endpoint receipts ${flow} --session support --limit 5`, + `flow key custody --session support`, + ]) { + const result = await run(`${command} --project ${project} --json`) + expect(result.error).toBeUndefined() + } + expect(server.requests.map((r) => `${r.method} ${r.url}`)).toEqual([ + `GET /platform/flows/${flow}/endpoint?sessionId=support&projectId=${project}`, + `GET /platform/flows/${flow}/endpoint/receipts?sessionId=support&limit=5&projectId=${project}`, + `GET /platform/flow-encryption-keys?sessionId=support&projectId=${project}`, + ]) + }) + test('sets each mode with exactly its fields and prints the secret once', async () => { + server = await startHttpServer({ + body: {data: {endpoint: {id: 'e'}, signingSecret: 'pfes_x', encryption: {}}}, + }) + const forward = await run( + `flow endpoint set ${flow} --project ${project} --session support --mode forward --url https://example.com/flow --rotate-signing-secret --expected-revision 2 --json`, + ) + expect(forward.error).toBeUndefined() + expect(JSON.parse(forward.stdout).data.signingSecret).toBe('pfes_x') + await run( + `flow endpoint set ${flow} --project ${project} --session support --mode function --function ${fn} --json`, + ) + await run( + `flow endpoint set ${flow} --project ${project} --session support --mode direct --url https://example.com/direct --disabled --json`, + ) + expect(server.requests.map((r) => JSON.parse(r.body))).toEqual([ + {sessionId: 'support', expectedRevision: 2, mode: 'forward', url: 'https://example.com/flow', rotateSigningSecret: true, projectId: project}, + {sessionId: 'support', mode: 'function', functionId: fn, projectId: project}, + {sessionId: 'support', enabled: false, mode: 'direct', url: 'https://example.com/direct', projectId: project}, + ]) + expect(server.requests.every((r) => r.method === 'PUT')).toBe(true) + }) + test.each([ + '--mode function --url https://example.com', + '--mode forward', + `--mode direct --url https://example.com --function ${fn}`, + '--mode direct --url https://example.com --rotate-signing-secret', + ])('refuses mismatched flags (%s) before dispatch', async (flags) => { + server = await startHttpServer({body: {data: {}}}) + const result = await run( + `flow endpoint set ${flow} --project ${project} --session support ${flags} --json`, + ) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) + }) + test.each([ + `flow endpoint delete ${flow} --session support`, + 'flow key rotate --session support', + ])('requires confirmation: %s', async (command) => { + server = await startHttpServer({body: {data: {ok: true}}}) + const result = await run(`${command} --project ${project} --json`) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(0) + }) + test('rotates once and never retries an unknown outcome', async () => { + server = await startHttpServer({ + status: 503, + body: {error: {code: 'service_unavailable', message: 'Unknown outcome'}}, + }) + const result = await run( + `flow key rotate --project ${project} --session support --yes --json`, + ) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(1) + expect(server.requests[0]).toMatchObject({ + method: 'POST', + url: '/platform/flow-encryption-keys/rotate', + }) + }) +}) From 7bf779713fb73b593d39a2c40ab7d94c5463c8f4 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 4 Oct 2026 05:18:30 +0300 Subject: [PATCH 3/4] docs(cli): dynamic Flow endpoint commands --- README.md | 26 +++++++++++++++++++++++++- 1 file changed, 25 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index 201b95f..34499ef 100644 --- a/README.md +++ b/README.md @@ -439,7 +439,31 @@ 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. +These commands make Polymorfa release its managed key custody for that Number. + +Polymorfa can also host the data endpoint (beta). It can hold a managed key per +Number, decrypt each WhatsApp request, and either forward it to your HTTPS URL +or run a deployed Function: + +```shell +polymorfa flow key rotate --project PROJECT_UUID --session number_123 --yes +polymorfa flow key custody --project PROJECT_UUID --session number_123 +polymorfa flow endpoint set FLOW_UUID --project PROJECT_UUID --session number_123 --mode forward --url https://example.com/flow +polymorfa flow endpoint set FLOW_UUID --project PROJECT_UUID --session number_123 --mode function --function FUNCTION_UUID +polymorfa flow endpoint set FLOW_UUID --project PROJECT_UUID --session number_123 --mode direct --url https://example.com/flow +polymorfa flow endpoint get FLOW_UUID --project PROJECT_UUID --session number_123 +polymorfa flow endpoint receipts FLOW_UUID --project PROJECT_UUID --limit 20 +polymorfa flow endpoint delete FLOW_UUID --project PROJECT_UUID --session number_123 --yes +``` + +`forward` and `function` need a Polymorfa-managed key on the Number. `direct` +needs your own key, registered with `flow key register`. A forward endpoint's +signing secret is printed once, when it is created, switched to forward, or +rotated with `--rotate-signing-secret`. Verify `X-Polymorfa-Flow-Signature` +with the SDK's `verifyFlowForwardSignature`. Configure the endpoint before +`flow upload`, which attaches its URI to the Flow. Key rotation affects every +Flow on the Number; the previous managed key decrypts for 24 hours. Rotation +and endpoint writes make one attempt. Receipts are metadata only. ### Submit a project template From 4d8faa35f04a013af630e407ec79c6a79ab03fbb Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 4 Oct 2026 05:52:26 +0300 Subject: [PATCH 4/4] feat(cli): pin SDK 0.1.0-dev.20261004024706 for dynamic Flow endpoint commands --- package-lock.json | 9 ++++--- package.json | 2 +- src/commands/flow/endpoint/get.ts | 4 +++- src/commands/flow/key/rotate.ts | 6 +++-- test/commands/flow-endpoints.test.ts | 35 +++++++++++++++++++++++----- 5 files changed, 41 insertions(+), 15 deletions(-) diff --git a/package-lock.json b/package-lock.json index a0a78fb..2da282d 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.20261004011426", + "@polymorfa/sdk": "0.1.0-dev.20261004024706", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,10 +997,9 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20261004011426", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261004011426.tgz", - "integrity": "sha512-1bC03PyxMpzlRtIFBHfk6OQhcyqsgGKHfWrShq+0pN/0i+w0JRw3JoGo6UC1NdBKmsRIZtsRqs8cpYxsacbQFA==", - "license": "MIT", + "version": "0.1.0-dev.20261004024706", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261004024706.tgz", + "integrity": "sha512-lCgzm4tzkAoy+cl8oyMo+qLrujaBgVwJccqyAKRRpPbGnUVluG3BA7C6+7/wVkckcmo2b1csc+eCU/tPBngx0Q==", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index 6283acc..cd3d35b 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.20261004011426", + "@polymorfa/sdk": "0.1.0-dev.20261004024706", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, diff --git a/src/commands/flow/endpoint/get.ts b/src/commands/flow/endpoint/get.ts index 02b33a4..cfb0585 100644 --- a/src/commands/flow/endpoint/get.ts +++ b/src/commands/flow/endpoint/get.ts @@ -15,7 +15,9 @@ export default class FlowEndpointGet extends FlowsCommand { async run(): Promise { const {args, flags} = await this.parse(FlowEndpointGet) const resource = await this.flows(flags) - const result = await resource.endpoint(args.flow, {sessionId: flags.session}) + const result = await resource.endpoint(args.flow, { + sessionId: flags.session, + }) this.writeValue(result, JSON.stringify(result.data, null, 2), flags) } } diff --git a/src/commands/flow/key/rotate.ts b/src/commands/flow/key/rotate.ts index b6701ee..bac68fc 100644 --- a/src/commands/flow/key/rotate.ts +++ b/src/commands/flow/key/rotate.ts @@ -3,7 +3,7 @@ import {PolymorfaConfigurationError} from '@polymorfa/sdk' import {FlowsCommand} from '../../../flows/command.js' export default class FlowKeyRotate extends FlowsCommand { static override description = - "Generate and register a new Polymorfa-managed Flow encryption key for a Number (beta). Every dynamic Flow on the Number uses it; the previous managed key decrypts for 24 hours. Requires sessions:manage. Makes one attempt." + 'Generate and register a new Polymorfa-managed Flow encryption key for a Number (beta). Every dynamic Flow on the Number uses it; the previous managed key decrypts for 24 hours. Requires sessions:manage. Makes one attempt.' static override flags = { session: Flags.string({ description: 'Number name or ID in the project.', @@ -21,7 +21,9 @@ export default class FlowKeyRotate extends FlowsCommand { 'yes', ) const resource = await this.flows(flags) - const result = await resource.rotateEncryptionKey({sessionId: flags.session}) + const result = await resource.rotateEncryptionKey({ + sessionId: flags.session, + }) this.writeValue(result, JSON.stringify(result.data, null, 2), flags) } } diff --git a/test/commands/flow-endpoints.test.ts b/test/commands/flow-endpoints.test.ts index d0cbe16..3362c8f 100644 --- a/test/commands/flow-endpoints.test.ts +++ b/test/commands/flow-endpoints.test.ts @@ -43,23 +43,46 @@ describe('dynamic Flow endpoint commands', () => { }) test('sets each mode with exactly its fields and prints the secret once', async () => { server = await startHttpServer({ - body: {data: {endpoint: {id: 'e'}, signingSecret: 'pfes_x', encryption: {}}}, + body: { + data: {endpoint: {id: 'e'}, signingSecret: 'pfes_x', encryption: {}}, + }, }) const forward = await run( `flow endpoint set ${flow} --project ${project} --session support --mode forward --url https://example.com/flow --rotate-signing-secret --expected-revision 2 --json`, ) expect(forward.error).toBeUndefined() - expect(JSON.parse(forward.stdout).data.signingSecret).toBe('pfes_x') + expect( + (JSON.parse(forward.stdout) as {data: {signingSecret?: string}}).data + .signingSecret, + ).toBe('pfes_x') await run( `flow endpoint set ${flow} --project ${project} --session support --mode function --function ${fn} --json`, ) await run( `flow endpoint set ${flow} --project ${project} --session support --mode direct --url https://example.com/direct --disabled --json`, ) - expect(server.requests.map((r) => JSON.parse(r.body))).toEqual([ - {sessionId: 'support', expectedRevision: 2, mode: 'forward', url: 'https://example.com/flow', rotateSigningSecret: true, projectId: project}, - {sessionId: 'support', mode: 'function', functionId: fn, projectId: project}, - {sessionId: 'support', enabled: false, mode: 'direct', url: 'https://example.com/direct', projectId: project}, + expect(server.requests.map((r) => JSON.parse(r.body) as unknown)).toEqual([ + { + sessionId: 'support', + expectedRevision: 2, + mode: 'forward', + url: 'https://example.com/flow', + rotateSigningSecret: true, + projectId: project, + }, + { + sessionId: 'support', + mode: 'function', + functionId: fn, + projectId: project, + }, + { + sessionId: 'support', + enabled: false, + mode: 'direct', + url: 'https://example.com/direct', + projectId: project, + }, ]) expect(server.requests.every((r) => r.method === 'PUT')).toBe(true) })