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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 25 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

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

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

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@
"@modelcontextprotocol/server": "2.0.0",
"@napi-rs/keyring": "1.3.0",
"@oclif/core": "^4.13.5",
"@polymorfa/sdk": "0.1.0-dev.20261004011426",
"@polymorfa/sdk": "0.1.0-dev.20261004024706",
"jq-wasm": "3.0.0-jq-1.8.2",
"zod": "4.2.0"
},
Expand Down
30 changes: 30 additions & 0 deletions src/commands/flow/endpoint/delete.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
23 changes: 23 additions & 0 deletions src/commands/flow/endpoint/get.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
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<void> {
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)
}
}
22 changes: 22 additions & 0 deletions src/commands/flow/endpoint/receipts.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
93 changes: 93 additions & 0 deletions src/commands/flow/endpoint/set.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
18 changes: 18 additions & 0 deletions src/commands/flow/key/custody.ts
Original file line number Diff line number Diff line change
@@ -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<void> {
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)
}
}
29 changes: 29 additions & 0 deletions src/commands/flow/key/rotate.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
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<void> {
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)
}
}
Loading