From 02b2204ffb32c42bd017338626be6039de72a028 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 22 Sep 2026 15:36:30 +0200 Subject: [PATCH 01/29] feat(cli): prepare Hybrid Link controls and operation recovery --- README.md | 40 ++++++ src/base-command.ts | 5 + src/commands/hybrid-link/pause.ts | 32 +++++ src/commands/hybrid-link/resume.ts | 32 +++++ src/commands/hybrid-link/state.ts | 25 ++++ src/commands/message/delete.ts | 14 ++- src/commands/message/edit.ts | 14 ++- src/commands/message/operation-status.ts | 35 ++++++ src/commands/message/react.ts | 5 + src/commands/message/send/text.ts | 5 + src/commands/quicklink/availability.ts | 29 +++++ src/commands/quicklink/cancel.ts | 2 +- src/commands/quicklink/create.ts | 48 ++++++- src/commands/routing-policy/get.ts | 23 ++++ src/commands/routing-policy/set.ts | 73 +++++++++++ src/errors/present.ts | 18 ++- src/input/hybrid-routing.ts | 38 ++++++ src/input/message-send.ts | 31 ++++- src/services/messages.ts | 24 +++- src/services/quicklinks.ts | 4 + test/commands/hybrid-link.test.ts | 153 +++++++++++++++++++++++ test/commands/message-sends.test.ts | 2 +- test/commands/messages.test.ts | 2 +- 23 files changed, 636 insertions(+), 18 deletions(-) create mode 100644 src/commands/hybrid-link/pause.ts create mode 100644 src/commands/hybrid-link/resume.ts create mode 100644 src/commands/hybrid-link/state.ts create mode 100644 src/commands/message/operation-status.ts create mode 100644 src/commands/quicklink/availability.ts create mode 100644 src/commands/routing-policy/get.ts create mode 100644 src/commands/routing-policy/set.ts create mode 100644 src/input/hybrid-routing.ts create mode 100644 test/commands/hybrid-link.test.ts diff --git a/README.md b/README.md index 078d5d42..cfd1cb2f 100644 --- a/README.md +++ b/README.md @@ -264,6 +264,10 @@ polymorfa message star 740000000001 \ --chat +15551234567 ``` +JSON message output preserves `whatsapp_ids.linked_devices` and +`whatsapp_ids.official_api` when supplied by the API. Use the Polymorfa message +`id` for replies and actions; provider references remain strings. + Add `--remove` to `message star` to unstar. Text sends accept repeatable `--mention` with a Polymorfa user ID, `--reply-to` with a Polymorfa message ID, and `--forwarded`. `--to` and `--chat` accept a conversation ID, E.164 phone @@ -1289,3 +1293,39 @@ then explicitly submit its `quoteId` with `PATCH /platform/sessions/{sessionId}` Read `GET /platform/sessions/{sessionId}/tier-quotes/{quoteId}` until the result is applied or rejected. Store the full JSON request in a file and pass `--input` when it contains numeric values or `null`; `--field` creates string values. + +### Hybrid Link controls + +These additions are under development and are not available in the published +CLI. They are tested against the companion Hybrid Link SDK source using API +contract `c087e3496f3fcc8977d94fcba60904158c7550cf`. The registry SDK dependency +must be updated after that SDK artifact is published before releasing this CLI. + +These commands require API-authorized private-preview access. An installed CLI +or a transport flag does not enable Hybrid Link. Use a team API key or project +token; browser client tokens cannot control Hybrid Link or send through it. + +```bash +polymorfa quicklink availability --project PROJECT_UUID --session NUMBER +polymorfa quicklink create --project PROJECT_UUID --connection-goal hybrid +polymorfa quicklink create --project PROJECT_UUID --purpose add_connection --session NUMBER --add-connection linked_devices +polymorfa message send text --session NUMBER --to +14155550123 --body Hello --transport official_api +polymorfa message operation-status OPERATION_UUID --session NUMBER +polymorfa routing-policy get --scope session --project PROJECT_UUID --session NUMBER +polymorfa routing-policy set --scope session --project PROJECT_UUID --session NUMBER --expected-revision REVISION --prefer linked_devices --allow linked_devices --allow official_api +polymorfa hybrid-link state --session NUMBER +polymorfa hybrid-link pause --session NUMBER --expected-revision REVISION +polymorfa hybrid-link resume --session NUMBER --expected-revision REVISION +``` + +All message send types, reactions, edits, and deletions accept `--transport auto`, +`linked_devices`, or `official_api`. Explicit choices never fall back. JSON output +preserves route metadata and the operation ID. If a send reports an unknown +outcome, inspect `message operation-status`; do not resend with a new key or change +transport. Status reads do not send. `pending` and `unknown` are unresolved outcomes. + +Policy writes require the revision returned by `routing-policy get` (`default` +for an unsaved policy). Pause/resume requires the UUID returned by +`hybrid-link state`. A stale write fails without replacing later changes. +`--prefer inherit` sends no local preference. `--deny-all` disallows both transports at the selected scope. Repeating `--allow` permits both +transports within the restrictions inherited from the team and project. diff --git a/src/base-command.ts b/src/base-command.ts index dbea60f6..87014ec7 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -228,11 +228,16 @@ export abstract class BaseCommand extends Command { ? {} : {requestId: presented.requestId}), retryable: presented.retryable, + ...(presented.operationId === undefined + ? {} + : {operationId: presented.operationId}), }), )}\n`, ) } else { process.stderr.write(`Error: ${presented.message}\n`) + if (presented.operationId !== undefined) + process.stderr.write(`Operation ID: ${presented.operationId}\n`) if (presented.requestId !== undefined) { process.stderr.write(`Request ID: ${presented.requestId}\n`) } diff --git a/src/commands/hybrid-link/pause.ts b/src/commands/hybrid-link/pause.ts new file mode 100644 index 00000000..354a72ef --- /dev/null +++ b/src/commands/hybrid-link/pause.ts @@ -0,0 +1,32 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../base-command.js' + +export default class HybridLinkPause extends BaseCommand { + static override description = + 'Pause Hybrid routing at the exact current revision.' + static override flags = { + session: Flags.string({ + required: true, + description: 'Number session name.', + }), + 'expected-revision': Flags.string({ + required: true, + description: 'Revision from hybrid-link state.', + }), + } + async run(): Promise { + const {flags} = await this.parse(HybridLinkPause) + const client = await this.messagingClient(flags, { + serverApiKeyOnly: 'Hybrid Link', + }) + const response = await client.hybridLink.setPaused(flags.session, { + expectedRevision: flags['expected-revision'], + paused: true, + }) + this.writeValue( + response, + `Hybrid Link ${response.data.data.paused ? 'paused' : 'active'}: ${response.data.data.revision}`, + flags, + ) + } +} diff --git a/src/commands/hybrid-link/resume.ts b/src/commands/hybrid-link/resume.ts new file mode 100644 index 00000000..49f44ba6 --- /dev/null +++ b/src/commands/hybrid-link/resume.ts @@ -0,0 +1,32 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../base-command.js' + +export default class HybridLinkResume extends BaseCommand { + static override description = + 'Resume Hybrid routing at the exact current revision.' + static override flags = { + session: Flags.string({ + required: true, + description: 'Number session name.', + }), + 'expected-revision': Flags.string({ + required: true, + description: 'Revision from hybrid-link state.', + }), + } + async run(): Promise { + const {flags} = await this.parse(HybridLinkResume) + const client = await this.messagingClient(flags, { + serverApiKeyOnly: 'Hybrid Link', + }) + const response = await client.hybridLink.setPaused(flags.session, { + expectedRevision: flags['expected-revision'], + paused: false, + }) + this.writeValue( + response, + `Hybrid Link ${response.data.data.paused ? 'paused' : 'active'}: ${response.data.data.revision}`, + flags, + ) + } +} diff --git a/src/commands/hybrid-link/state.ts b/src/commands/hybrid-link/state.ts new file mode 100644 index 00000000..1e216ef4 --- /dev/null +++ b/src/commands/hybrid-link/state.ts @@ -0,0 +1,25 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../base-command.js' + +export default class HybridLinkState extends BaseCommand { + static override description = + 'Read Hybrid Link readiness, pause state, and revision.' + static override flags = { + session: Flags.string({ + required: true, + description: 'Number session name.', + }), + } + async run(): Promise { + const {flags} = await this.parse(HybridLinkState) + const client = await this.messagingClient(flags, { + serverApiKeyOnly: 'Hybrid Link', + }) + const response = await client.hybridLink.state(flags.session) + this.writeValue( + response, + `${response.data.data.revision}\t${response.data.data.paused ? 'paused' : 'active'}`, + flags, + ) + } +} diff --git a/src/commands/message/delete.ts b/src/commands/message/delete.ts index 5f0f6890..f9fb9e18 100644 --- a/src/commands/message/delete.ts +++ b/src/commands/message/delete.ts @@ -2,7 +2,11 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../base-command.js' import {confirmDangerous} from '../../input/confirmation.js' -import {idempotencyOptions} from '../../input/message-send.js' +import { + idempotencyOptions, + transportFlag, + messageTransport, +} from '../../input/message-send.js' import {deleteMessage} from '../../services/messages.js' export default class MessageDelete extends BaseCommand { @@ -18,6 +22,7 @@ export default class MessageDelete extends BaseCommand { ] static override flags = { + transport: transportFlag, chat: Flags.string({ description: 'Conversation that contains the message.', required: true, @@ -45,7 +50,12 @@ export default class MessageDelete extends BaseCommand { flags.session, flags.chat, args.message, - idempotencyOptions(flags['idempotency-key']), + { + ...idempotencyOptions(flags['idempotency-key']), + ...(flags.transport === undefined + ? {} + : {transport: messageTransport(flags.transport)}), + }, ) this.writeValue(result, `Deleted message ${args.message}.`, flags) } diff --git a/src/commands/message/edit.ts b/src/commands/message/edit.ts index 9108840e..75bc98ca 100644 --- a/src/commands/message/edit.ts +++ b/src/commands/message/edit.ts @@ -1,7 +1,11 @@ import {Args, Flags} from '@oclif/core' import {BaseCommand} from '../../base-command.js' -import {idempotencyOptions} from '../../input/message-send.js' +import { + idempotencyOptions, + transportFlag, + messageTransport, +} from '../../input/message-send.js' import {editMessage} from '../../services/messages.js' export default class MessageEdit extends BaseCommand { @@ -16,6 +20,7 @@ export default class MessageEdit extends BaseCommand { ] static override flags = { + transport: transportFlag, body: Flags.string({description: 'Replacement text.', required: true}), chat: Flags.string({ description: 'Conversation that contains the message.', @@ -38,7 +43,12 @@ export default class MessageEdit extends BaseCommand { flags.chat, args.message, flags.body, - idempotencyOptions(flags['idempotency-key']), + { + ...idempotencyOptions(flags['idempotency-key']), + ...(flags.transport === undefined + ? {} + : {transport: messageTransport(flags.transport)}), + }, ) this.writeValue(result, `Edited message ${args.message}.`, flags) } diff --git a/src/commands/message/operation-status.ts b/src/commands/message/operation-status.ts new file mode 100644 index 00000000..39979bd3 --- /dev/null +++ b/src/commands/message/operation-status.ts @@ -0,0 +1,35 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../base-command.js' + +export default class MessageOperationStatus extends BaseCommand { + static override description = + 'Read the outcome of a message operation without sending again.' + static override args = { + operation: Args.string({ + description: 'Operation ID from the send response.', + required: true, + }), + } + static override flags = { + session: Flags.string({ + description: 'Number session name.', + required: true, + }), + } + async run(): Promise { + const {args, flags} = await this.parse(MessageOperationStatus) + const client = await this.messagingClient(flags, { + serverApiKeyOnly: 'Message operation status', + }) + const response = await client.messages.operationStatus( + flags.session, + args.operation, + ) + const value = response.data.data + this.writeValue( + response, + `${value.operationId}\t${value.status}${value.transport ? `\t${value.transport}` : ''}${value.status === 'completed' ? '' : '\nOutcome pending. Read this operation again; do not resend.'}`, + flags, + ) + } +} diff --git a/src/commands/message/react.ts b/src/commands/message/react.ts index 933fc0e5..f5d61c28 100644 --- a/src/commands/message/react.ts +++ b/src/commands/message/react.ts @@ -1,3 +1,4 @@ +import {transportFlag, messageTransport} from '../../input/message-send.js' import {Args, Flags} from '@oclif/core' import {conversationReference} from '../../services/conversation.js' @@ -12,6 +13,7 @@ export default class MessageReact extends BaseCommand { static override description = 'React to a message.' static override flags = { + transport: transportFlag, chat: Flags.string({ description: 'Conversation ID, E.164 phone number, or BSUID.', required: true, @@ -35,6 +37,9 @@ export default class MessageReact extends BaseCommand { conversation: conversationReference(flags.chat), id: args.message, reaction: flags.emoji, + ...(flags.transport === undefined + ? {} + : {transport: messageTransport(flags.transport)}), }, flags['idempotency-key'] === undefined ? {} diff --git a/src/commands/message/send/text.ts b/src/commands/message/send/text.ts index 5e04b946..a4141e5b 100644 --- a/src/commands/message/send/text.ts +++ b/src/commands/message/send/text.ts @@ -3,12 +3,14 @@ import type {SendTextMessageRequest} from '@polymorfa/sdk' import {BaseCommand} from '../../../base-command.js' import {sendTextMessage} from '../../../services/messages.js' +import {transportFlag, messageTransport} from '../../../input/message-send.js' import {conversationReference} from '../../../services/conversation.js' export default class MessageSendText extends BaseCommand { static override description = 'Send a text message.' static override flags = { + transport: transportFlag, body: Flags.string({ description: 'Text to send.', required: true, @@ -42,6 +44,9 @@ export default class MessageSendText extends BaseCommand { const request: SendTextMessageRequest = { conversation: conversationReference(flags.to), content: {text: flags.body}, + ...(flags.transport === undefined + ? {} + : {transport: messageTransport(flags.transport)}), ...(flags.forwarded ? {isForwarded: true} : {}), ...(flags.mention === undefined ? {} : {mentions: flags.mention}), ...(flags['reply-to'] === undefined diff --git a/src/commands/quicklink/availability.ts b/src/commands/quicklink/availability.ts new file mode 100644 index 00000000..c9197d25 --- /dev/null +++ b/src/commands/quicklink/availability.ts @@ -0,0 +1,29 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../base-command.js' + +export default class QuickLinkAvailability extends BaseCommand { + static override description = + 'Read a Number’s connections and whether an added connection is available.' + static override flags = { + session: Flags.string({ + description: 'Existing Number session name.', + required: true, + }), + } + async run(): Promise { + const {flags} = await this.parse(QuickLinkAvailability) + const {client, projectId} = await this.quickLinksClient(flags) + const response = await client.quickLinks.availability({ + projectId, + session: flags.session, + }) + const data = response.data.data + this.writeValue( + response, + data.allowed && data.addConnection + ? `Available: ${data.addConnection}` + : 'No added connection is available.', + flags, + ) + } +} diff --git a/src/commands/quicklink/cancel.ts b/src/commands/quicklink/cancel.ts index 2cf7addb..a8610c87 100644 --- a/src/commands/quicklink/cancel.ts +++ b/src/commands/quicklink/cancel.ts @@ -9,7 +9,7 @@ export default class QuickLinkCancel extends BaseCommand { } static override description = - 'Cancel a QuickLink that has not connected and remove its pending session.' + 'Cancel pending QuickLink setup. Supplementary links preserve the existing Number.' static override flags = { yes: Flags.boolean({description: 'Confirm QuickLink cancellation.'}), diff --git a/src/commands/quicklink/create.ts b/src/commands/quicklink/create.ts index b817de45..8410ee21 100644 --- a/src/commands/quicklink/create.ts +++ b/src/commands/quicklink/create.ts @@ -1,3 +1,4 @@ +import {PolymorfaConfigurationError} from '@polymorfa/sdk' import {Flags} from '@oclif/core' import {readJsonInput} from '../../api/request.js' import {BaseCommand} from '../../base-command.js' @@ -7,17 +8,60 @@ export default class QuickLinkCreate extends BaseCommand { static override description = 'Create a persistent real or simulated onboarding QuickLink.' static override flags = { + purpose: Flags.string({ + options: ['initial', 'add_connection'] as const, + description: 'Initial setup or add a connection to an existing Number.', + }), + 'connection-goal': Flags.string({ + options: ['single', 'hybrid'] as const, + description: 'One connection or authorized Hybrid setup.', + }), + session: Flags.string({ + description: 'Existing Number for an added connection.', + }), + 'add-connection': Flags.string({ + options: ['linked_devices', 'official_api'] as const, + description: 'Missing connection to add.', + }), input: Flags.string({ description: 'JSON request file or - for stdin. Use configuration for connection, phone, history and simulation.', - required: true, }), } async run(): Promise { const {flags} = await this.parse(QuickLinkCreate) + const input = flags.input ? await readJsonInput(flags.input) : {} + if (!input || typeof input !== 'object' || Array.isArray(input)) + throw new PolymorfaConfigurationError( + 'QuickLink input must be an object.', + 'input', + ) + const body = {...input} as Record + for (const [key, value] of Object.entries({ + purpose: flags.purpose, + connectionGoal: flags['connection-goal'], + session: flags.session, + addConnection: flags['add-connection'], + })) { + if (value === undefined) continue + if (body[key] !== undefined && body[key] !== value) + throw new PolymorfaConfigurationError( + `Input ${key} conflicts with its flag.`, + 'input', + ) + body[key] = value + } + if ( + body.purpose === 'add_connection' && + (!body.session || !body.addConnection || body.connectionGoal === 'single') + ) + throw new PolymorfaConfigurationError( + 'Adding a connection requires --session and --add-connection with the hybrid goal.', + 'input', + ) const result = await createQuickLink( await this.messagingClient(flags, {serverApiKeyOnly: 'QuickLink'}), - await readJsonInput(flags.input), + body, flags.project, ) this.writeValue(result, result.data.url, flags) diff --git a/src/commands/routing-policy/get.ts b/src/commands/routing-policy/get.ts new file mode 100644 index 00000000..368d3eff --- /dev/null +++ b/src/commands/routing-policy/get.ts @@ -0,0 +1,23 @@ +import {BaseCommand} from '../../base-command.js' +import { + hybridPolicyFlags, + hybridPolicyScope, +} from '../../input/hybrid-routing.js' + +export default class RoutingPolicyGet extends BaseCommand { + static override description = 'Read Hybrid routing policy and its revision.' + static override flags = hybridPolicyFlags + async run(): Promise { + const {flags} = await this.parse(RoutingPolicyGet) + const client = await this.messagingClient(flags, { + serverApiKeyOnly: 'Hybrid routing', + }) + const response = await client.hybridLink.getPolicy(hybridPolicyScope(flags)) + const policy = response.data.data + this.writeValue( + response, + `${policy.scope}\t${policy.revision}\tprefer=${policy.prefer ?? 'inherit'}\tallowed=${policy.allowedTransports.join(',')}`, + flags, + ) + } +} diff --git a/src/commands/routing-policy/set.ts b/src/commands/routing-policy/set.ts new file mode 100644 index 00000000..6c44777c --- /dev/null +++ b/src/commands/routing-policy/set.ts @@ -0,0 +1,73 @@ +import {Flags} from '@oclif/core' +import {PolymorfaConfigurationError} from '@polymorfa/sdk' +import {BaseCommand} from '../../base-command.js' +import { + hybridPolicyFlags, + hybridPolicyScope, +} from '../../input/hybrid-routing.js' + +export default class RoutingPolicySet extends BaseCommand { + static override description = + 'Update Hybrid routing at the exact revision. Ancestor restrictions still apply.' + static override flags = { + ...hybridPolicyFlags, + 'expected-revision': Flags.string({ + required: true, + description: + 'Revision from routing-policy get; default for an unsaved policy.', + }), + prefer: Flags.string({ + required: true, + options: ['linked_devices', 'official_api', 'inherit'] as const, + description: 'Preferred transport or inherit.', + }), + 'deny-all': Flags.boolean({ + description: 'Disallow both transports at this scope.', + exclusive: ['allow'], + }), + allow: Flags.string({ + multiple: true, + options: ['linked_devices', 'official_api'] as const, + description: 'Allowed transport; repeat to allow both.', + }), + } + async run(): Promise { + const {flags} = await this.parse(RoutingPolicySet) + if (!flags['deny-all'] && !flags.allow?.length) + throw new PolymorfaConfigurationError( + 'Supply --allow or --deny-all.', + 'allow', + ) + const allow = flags.allow ?? [] + if (new Set(allow).size !== allow.length) + throw new PolymorfaConfigurationError( + 'Each --allow transport must be unique.', + 'allow', + ) + const prefer = + flags.prefer === 'linked_devices' + ? 'linked_devices' + : flags.prefer === 'official_api' + ? 'official_api' + : null + const allowedTransports = allow.map((value) => { + if (value === 'linked_devices' || value === 'official_api') return value + throw new PolymorfaConfigurationError( + 'Invalid allowed transport.', + 'allow', + ) + }) + const client = await this.messagingClient(flags, { + serverApiKeyOnly: 'Hybrid routing', + }) + const response = await client.hybridLink.setPolicy( + hybridPolicyScope(flags), + {expectedRevision: flags['expected-revision'], prefer, allowedTransports}, + ) + this.writeValue( + response, + `Routing policy updated: ${response.data.data.revision}`, + flags, + ) + } +} diff --git a/src/errors/present.ts b/src/errors/present.ts index 80ef6573..b96a5a04 100644 --- a/src/errors/present.ts +++ b/src/errors/present.ts @@ -43,6 +43,7 @@ export interface PresentedError { readonly exitCode: number readonly message: string readonly requestId?: string + readonly operationId?: string readonly retryable: boolean } @@ -71,12 +72,19 @@ export function presentError( : error instanceof PolymorfaError && error.code ? redact(error.code, secrets) : `${classified.category}_error`, - message, + message: + error instanceof PolymorfaError && error.code === 'send_outcome_unknown' + ? `${message} Read message operation status; do not resend or change transport.` + : message, + ...(error instanceof PolymorfaError && error.metadata?.operationId + ? {operationId: redact(error.metadata.operationId, secrets)} + : {}), retryable: - (error instanceof ListenerProtocolError && error.retryable) || - ['network', 'rate_limit', 'server', 'timeout'].includes( - classified.category, - ), + !(error instanceof PolymorfaError && error.metadata?.operationId) && + ((error instanceof ListenerProtocolError && error.retryable) || + ['network', 'rate_limit', 'server', 'timeout'].includes( + classified.category, + )), ...(requestId === undefined ? {} : {requestId}), } } diff --git a/src/input/hybrid-routing.ts b/src/input/hybrid-routing.ts new file mode 100644 index 00000000..3badc666 --- /dev/null +++ b/src/input/hybrid-routing.ts @@ -0,0 +1,38 @@ +import {Flags} from '@oclif/core' +import { + PolymorfaConfigurationError, + type HybridRoutingPolicyScope, +} from '@polymorfa/sdk' + +export const hybridPolicyFlags = { + scope: Flags.string({ + options: ['team', 'project', 'session'] as const, + required: true, + description: 'Policy authority to read or update.', + }), + session: Flags.string({ + description: 'Number session name for session scope.', + }), +} +export function hybridPolicyScope(flags: { + scope: string + project?: string | undefined + session?: string | undefined +}): HybridRoutingPolicyScope { + if (flags.scope === 'team') return {scope: 'team'} + if (!flags.project) + throw new PolymorfaConfigurationError( + 'This scope requires --project.', + 'project', + ) + if (flags.scope === 'project') + return {scope: 'project', projectId: flags.project} + if (flags.scope !== 'session') + throw new PolymorfaConfigurationError('Invalid policy scope.', 'scope') + if (!flags.session) + throw new PolymorfaConfigurationError( + 'Session scope requires --session.', + 'session', + ) + return {scope: 'session', projectId: flags.project, session: flags.session} +} diff --git a/src/input/message-send.ts b/src/input/message-send.ts index a6611697..7a522a3f 100644 --- a/src/input/message-send.ts +++ b/src/input/message-send.ts @@ -1,10 +1,22 @@ import {Flags} from '@oclif/core' -import type {MessageSendContext, RequestOptions} from '@polymorfa/sdk' +import { + PolymorfaConfigurationError, + type MessageSendContext, + type MessageTransport, + type RequestOptions, +} from '@polymorfa/sdk' import {conversationReference} from '../services/conversation.js' +export const transportFlag = Flags.string({ + description: + 'Send using routing rules (auto), Linked Devices, or Official API. Explicit choices never fall back.', + options: ['auto', 'linked_devices', 'official_api'] as const, +}) + /** Flags shared by every `message send ` command. */ export const sendContextFlags = { + transport: transportFlag, forwarded: Flags.boolean({ description: 'Mark the message as forwarded.', }), @@ -29,6 +41,7 @@ export const sendContextFlags = { } export interface SendContextFlagValues { + readonly transport?: string | undefined readonly forwarded?: boolean | undefined readonly mention?: readonly string[] | undefined readonly 'reply-to'?: string | undefined @@ -38,6 +51,9 @@ export interface SendContextFlagValues { export function sendContext(flags: SendContextFlagValues): MessageSendContext { return { conversation: conversationReference(flags.to), + ...(flags.transport === undefined + ? {} + : {transport: messageTransport(flags.transport)}), ...(flags.forwarded === true ? {isForwarded: true} : {}), ...(flags.mention === undefined ? {} : {mentions: flags.mention}), ...(flags['reply-to'] === undefined @@ -49,3 +65,16 @@ export function sendContext(flags: SendContextFlagValues): MessageSendContext { export function idempotencyOptions(key: string | undefined): RequestOptions { return key === undefined ? {} : {idempotencyKey: key} } + +export function messageTransport(value: string): MessageTransport { + if ( + value === 'auto' || + value === 'linked_devices' || + value === 'official_api' + ) + return value + throw new PolymorfaConfigurationError( + 'Invalid message transport.', + 'transport', + ) +} diff --git a/src/services/messages.ts b/src/services/messages.ts index 23eb1c48..cdd16ede 100644 --- a/src/services/messages.ts +++ b/src/services/messages.ts @@ -1,6 +1,7 @@ import { PolymorfaConfigurationError, type MessageResponse, + type MessageTransport, type MessageReceipt, type MessagingClient, type ReactRequest, @@ -21,6 +22,9 @@ export interface MessageResult { readonly attempts: number readonly requestId?: string readonly status: number + readonly transport?: ResponseMetadata['transport'] + readonly routingReason?: ResponseMetadata['routingReason'] + readonly operationId?: string } } @@ -57,7 +61,7 @@ export async function editMessage( chat: string, message: string, text: string, - options: RequestOptions = {}, + options: RequestOptions & {readonly transport?: MessageTransport} = {}, ): Promise> { if (text.trim().length === 0) { throw new PolymorfaConfigurationError( @@ -69,7 +73,12 @@ export async function editMessage( session, chat, message, - {text}, + { + text, + ...(options.transport === undefined + ? {} + : {transport: options.transport}), + }, options, ) return result(response.data, response.metadata) @@ -80,7 +89,7 @@ export async function deleteMessage( session: string, chat: string, message: string, - options: RequestOptions = {}, + options: RequestOptions & {readonly transport?: MessageTransport} = {}, ): Promise> { const response = await client.chats.deleteMessage( session, @@ -136,6 +145,15 @@ function result(data: T, metadata: ResponseMetadata): MessageResult { data, metadata: { attempts: metadata.attempts, + ...(metadata.transport === undefined + ? {} + : {transport: metadata.transport}), + ...(metadata.routingReason === undefined + ? {} + : {routingReason: metadata.routingReason}), + ...(metadata.operationId === undefined + ? {} + : {operationId: metadata.operationId}), status: metadata.status, ...(metadata.apiVersion === undefined ? {} diff --git a/src/services/quicklinks.ts b/src/services/quicklinks.ts index 98f56005..7b2c73c2 100644 --- a/src/services/quicklinks.ts +++ b/src/services/quicklinks.ts @@ -13,6 +13,10 @@ export async function createQuickLink( const body = input as Record const allowed = new Set([ 'projectId', + 'purpose', + 'connectionGoal', + 'session', + 'addConnection', 'customerId', 'externalId', 'configuration', diff --git a/test/commands/hybrid-link.test.ts b/test/commands/hybrid-link.test.ts new file mode 100644 index 00000000..b3abe824 --- /dev/null +++ b/test/commands/hybrid-link.test.ts @@ -0,0 +1,153 @@ +import {runCommand} from '@oclif/test' +import {expect, test} from 'vitest' +import { + startHttpSequenceServer, + withEnvironment, +} from '../helpers/http-server.js' +const project = '11111111-1111-4111-8111-111111111111' +const env = (url: string) => ({ + POLYMORFA_API_KEY: `pmfa_${'A'.repeat(72)}`, + POLYMORFA_BASE_URL: url, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, +}) +test('supplementary QuickLink flags preserve the Number and availability scope', async () => { + const server = await startHttpSequenceServer([ + { + body: { + success: true, + data: { + id: 'ql_1', + session: 'existing', + url: 'https://example.test/quicklink/1', + expiresAt: null, + }, + }, + }, + { + body: { + success: true, + data: { + allowed: false, + addConnection: null, + connections: [], + resumeQuickLinkId: null, + }, + }, + }, + ]) + try { + await withEnvironment(env(server.baseUrl), async () => { + expect( + ( + await runCommand( + `quicklink create --project ${project} --purpose add_connection --session existing --add-connection linked_devices --json`, + ) + ).error, + ).toBeUndefined() + expect( + ( + await runCommand( + `quicklink availability --project ${project} --session existing --json`, + ) + ).error, + ).toBeUndefined() + }) + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + projectId: project, + purpose: 'add_connection', + session: 'existing', + addConnection: 'linked_devices', + }) + expect(server.requests[1]!.url).toBe( + `/messaging/quicklinks/availability?projectId=${project}&session=existing`, + ) + } finally { + await server.close() + } +}) +test('policy and pause writes retain exact revision guards', async () => { + const server = await startHttpSequenceServer([ + { + body: { + success: true, + data: { + scope: 'session', + revision: 'new', + prefer: 'official_api', + allowedTransports: ['official_api'], + }, + }, + }, + {body: {success: true, data: {revision: 'new', paused: true}}}, + ]) + try { + await withEnvironment(env(server.baseUrl), async () => { + const policy = await runCommand( + `routing-policy set --scope session --project ${project} --session existing --expected-revision default --prefer official_api --allow official_api --json`, + ) + expect(policy.error, policy.stderr).toBeUndefined() + const pause = await runCommand( + 'hybrid-link pause --session existing --expected-revision revision-1 --json', + ) + expect(pause.error, pause.stderr).toBeUndefined() + }) + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + expectedRevision: 'default', + prefer: 'official_api', + allowedTransports: ['official_api'], + }) + expect(JSON.parse(server.requests[1]!.body)).toEqual({ + expectedRevision: 'revision-1', + paused: true, + }) + } finally { + await server.close() + } +}) +test('unknown outcome is printed with its operation ID and never resends', async () => { + const server = await startHttpSequenceServer([ + { + status: 409, + headers: {'x-polymorfa-operation-id': 'op-1'}, + body: { + error: { + code: 'send_outcome_unknown', + message: 'Outcome unknown', + type: 'conflict_error', + }, + }, + }, + { + body: { + success: true, + data: { + operationId: 'op-1', + status: 'unknown', + transport: 'official_api', + }, + }, + }, + ]) + try { + await withEnvironment(env(server.baseUrl), async () => { + const send = await runCommand( + 'message send text --session existing --to +14155550123 --body hello --transport official_api --json', + ) + expect(send.error).toBeDefined() + expect(send.stdout + send.stderr).toContain('op-1') + expect(send.stdout + send.stderr).toContain('do not resend') + const status = await runCommand( + 'message operation-status op-1 --session existing', + ) + expect(status.error, status.stderr).toBeUndefined() + expect(status.stdout).toContain('do not resend') + }) + expect(server.requests.map((r) => r.method)).toEqual(['POST', 'GET']) + expect( + (JSON.parse(server.requests[0]!.body) as {transport: string}).transport, + ).toBe('official_api') + } finally { + await server.close() + } +}) diff --git a/test/commands/message-sends.test.ts b/test/commands/message-sends.test.ts index 00fac6f6..8b177599 100644 --- a/test/commands/message-sends.test.ts +++ b/test/commands/message-sends.test.ts @@ -617,7 +617,7 @@ async function messageServer(): Promise { status: 'sent', timestamp: '2026-09-19T00:00:00.000Z', type: 'image', - whatsapp_id: '3A0000', + whatsapp_ids: {linked_devices: '3A0000'}, }, success: true, }, diff --git a/test/commands/messages.test.ts b/test/commands/messages.test.ts index 4b0b3cc2..2a08c210 100644 --- a/test/commands/messages.test.ts +++ b/test/commands/messages.test.ts @@ -225,7 +225,7 @@ async function messageServer(requestId?: string): Promise { function completeMessage() { return { id: '740000000001', - whatsapp_id: '3A000000000000000001', + whatsapp_ids: {linked_devices: '3A000000000000000001', official_api: 'wamid.exact-provider-reference'}, conversation: {id: '730000000001', phoneNumber: '+15551234567'}, type: 'text', timestamp: '2026-08-19T20:00:00.000Z', From bb1601790e2fd5546b400499ee29a611184a16e8 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 11:55:51 +0200 Subject: [PATCH 02/29] Accept server project tokens in Hybrid CLI controls --- README.md | 17 +- src/base-command.ts | 17 +- src/commands/hybrid-link/pause.ts | 2 +- src/commands/hybrid-link/resume.ts | 2 +- src/commands/hybrid-link/state.ts | 2 +- src/commands/message/operation-status.ts | 2 +- src/commands/quicklink/create.ts | 2 +- src/commands/routing-policy/get.ts | 12 +- src/commands/routing-policy/set.ts | 17 +- src/context/environment.ts | 41 ++- test/commands/hybrid-credentials.test.ts | 324 +++++++++++++++++++++++ test/context/environment.test.ts | 105 +++++++- 12 files changed, 511 insertions(+), 32 deletions(-) create mode 100644 test/commands/hybrid-credentials.test.ts diff --git a/README.md b/README.md index cfd1cb2f..a71692e7 100644 --- a/README.md +++ b/README.md @@ -1134,9 +1134,11 @@ export POLYMORFA_API_KEY='pmfa_...' polymorfa api /v1/projects --surface platform --json ``` -Messaging accepts exactly one of `POLYMORFA_API_KEY` or -`POLYMORFA_CLIENT_TOKEN`. Platform accepts only `POLYMORFA_API_KEY`. Supplying -both Messaging credentials is an error rather than an implicit preference. +Messaging accepts exactly one of `POLYMORFA_API_KEY`, `POLYMORFA_PROJECT_TOKEN`, +or `POLYMORFA_CLIENT_TOKEN`. Project tokens remain confined to their project; +individual commands enforce their credential requirements. Raw Platform requests +accept only `POLYMORFA_API_KEY`. Supplying multiple Messaging credentials fails +before a request is sent. Examples: @@ -1298,12 +1300,17 @@ it contains numeric values or `null`; `--field` creates string values. These additions are under development and are not available in the published CLI. They are tested against the companion Hybrid Link SDK source using API -contract `c087e3496f3fcc8977d94fcba60904158c7550cf`. The registry SDK dependency +contract `e50d2d69f0b6bec6da2b0b6719a5a498a20bf34b`. The registry SDK dependency must be updated after that SDK artifact is published before releasing this CLI. +The companion SDK defaults native requests to API revision `2026-09-22`. An +explicit `--api-version` or saved-profile version remains explicit; retired +versions fail without an automatic upgrade or resend. These commands require API-authorized private-preview access. An installed CLI or a transport flag does not enable Hybrid Link. Use a team API key or project -token; browser client tokens cannot control Hybrid Link or send through it. +token through `POLYMORFA_PROJECT_TOKEN` or a project-token profile. Project tokens +can control only their project and its Numbers; team routing policies require a +team API key. Browser client tokens cannot control Hybrid Link or send through it. ```bash polymorfa quicklink availability --project PROJECT_UUID --session NUMBER diff --git a/src/base-command.ts b/src/base-command.ts index 87014ec7..b32086a7 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -99,7 +99,10 @@ export abstract class BaseCommand extends Command { protected async messagingClient( flags: GlobalFlags, - options: {readonly serverApiKeyOnly?: string} = {}, + options: { + readonly serverApiKeyOnly?: string + readonly serverCredentialOnly?: string + } = {}, ): Promise { const environment = await this.messagingEnvironment(flags) if ( @@ -107,7 +110,16 @@ export abstract class BaseCommand extends Command { environment.credential.type !== 'apiKey' ) { throw new PolymorfaConfigurationError( - `${options.serverApiKeyOnly} commands require a server API key; Messaging client tokens do not have ${options.serverApiKeyOnly} access. Set POLYMORFA_API_KEY or select an API-key profile.`, + `${options.serverApiKeyOnly} commands require a server API key; project and Messaging client tokens do not have ${options.serverApiKeyOnly} access. Set POLYMORFA_API_KEY or select an API-key profile.`, + 'credential', + ) + } + if ( + options.serverCredentialOnly !== undefined && + environment.credential.type === 'clientToken' + ) { + throw new PolymorfaConfigurationError( + `${options.serverCredentialOnly} commands require a server API key or project token; Messaging client tokens do not have this access. Set POLYMORFA_API_KEY or POLYMORFA_PROJECT_TOKEN, or select a server-credential profile.`, 'credential', ) } @@ -194,6 +206,7 @@ export abstract class BaseCommand extends Command { const environment = await resolveCommandEnvironment({ env: process.env, surface, + ...(flags.project === undefined ? {} : {project: flags.project}), ...(flags.profile === undefined ? {} : {profile: flags.profile}), ...(flags['api-version'] === undefined ? {} diff --git a/src/commands/hybrid-link/pause.ts b/src/commands/hybrid-link/pause.ts index 354a72ef..75428161 100644 --- a/src/commands/hybrid-link/pause.ts +++ b/src/commands/hybrid-link/pause.ts @@ -17,7 +17,7 @@ export default class HybridLinkPause extends BaseCommand { async run(): Promise { const {flags} = await this.parse(HybridLinkPause) const client = await this.messagingClient(flags, { - serverApiKeyOnly: 'Hybrid Link', + serverCredentialOnly: 'Hybrid Link', }) const response = await client.hybridLink.setPaused(flags.session, { expectedRevision: flags['expected-revision'], diff --git a/src/commands/hybrid-link/resume.ts b/src/commands/hybrid-link/resume.ts index 49f44ba6..7309b45a 100644 --- a/src/commands/hybrid-link/resume.ts +++ b/src/commands/hybrid-link/resume.ts @@ -17,7 +17,7 @@ export default class HybridLinkResume extends BaseCommand { async run(): Promise { const {flags} = await this.parse(HybridLinkResume) const client = await this.messagingClient(flags, { - serverApiKeyOnly: 'Hybrid Link', + serverCredentialOnly: 'Hybrid Link', }) const response = await client.hybridLink.setPaused(flags.session, { expectedRevision: flags['expected-revision'], diff --git a/src/commands/hybrid-link/state.ts b/src/commands/hybrid-link/state.ts index 1e216ef4..098ccc2b 100644 --- a/src/commands/hybrid-link/state.ts +++ b/src/commands/hybrid-link/state.ts @@ -13,7 +13,7 @@ export default class HybridLinkState extends BaseCommand { async run(): Promise { const {flags} = await this.parse(HybridLinkState) const client = await this.messagingClient(flags, { - serverApiKeyOnly: 'Hybrid Link', + serverCredentialOnly: 'Hybrid Link', }) const response = await client.hybridLink.state(flags.session) this.writeValue( diff --git a/src/commands/message/operation-status.ts b/src/commands/message/operation-status.ts index 39979bd3..07b1109f 100644 --- a/src/commands/message/operation-status.ts +++ b/src/commands/message/operation-status.ts @@ -19,7 +19,7 @@ export default class MessageOperationStatus extends BaseCommand { async run(): Promise { const {args, flags} = await this.parse(MessageOperationStatus) const client = await this.messagingClient(flags, { - serverApiKeyOnly: 'Message operation status', + serverCredentialOnly: 'Message operation status', }) const response = await client.messages.operationStatus( flags.session, diff --git a/src/commands/quicklink/create.ts b/src/commands/quicklink/create.ts index 8410ee21..87f91098 100644 --- a/src/commands/quicklink/create.ts +++ b/src/commands/quicklink/create.ts @@ -60,7 +60,7 @@ export default class QuickLinkCreate extends BaseCommand { 'input', ) const result = await createQuickLink( - await this.messagingClient(flags, {serverApiKeyOnly: 'QuickLink'}), + await this.messagingClient(flags, {serverCredentialOnly: 'QuickLink'}), body, flags.project, ) diff --git a/src/commands/routing-policy/get.ts b/src/commands/routing-policy/get.ts index 368d3eff..ee33536e 100644 --- a/src/commands/routing-policy/get.ts +++ b/src/commands/routing-policy/get.ts @@ -9,10 +9,14 @@ export default class RoutingPolicyGet extends BaseCommand { static override flags = hybridPolicyFlags async run(): Promise { const {flags} = await this.parse(RoutingPolicyGet) - const client = await this.messagingClient(flags, { - serverApiKeyOnly: 'Hybrid routing', - }) - const response = await client.hybridLink.getPolicy(hybridPolicyScope(flags)) + const scope = hybridPolicyScope(flags) + const client = await this.messagingClient( + flags, + scope.scope === 'team' + ? {serverApiKeyOnly: 'Team Hybrid routing'} + : {serverCredentialOnly: 'Hybrid routing'}, + ) + const response = await client.hybridLink.getPolicy(scope) const policy = response.data.data this.writeValue( response, diff --git a/src/commands/routing-policy/set.ts b/src/commands/routing-policy/set.ts index 6c44777c..ec831598 100644 --- a/src/commands/routing-policy/set.ts +++ b/src/commands/routing-policy/set.ts @@ -57,13 +57,18 @@ export default class RoutingPolicySet extends BaseCommand { 'allow', ) }) - const client = await this.messagingClient(flags, { - serverApiKeyOnly: 'Hybrid routing', - }) - const response = await client.hybridLink.setPolicy( - hybridPolicyScope(flags), - {expectedRevision: flags['expected-revision'], prefer, allowedTransports}, + const scope = hybridPolicyScope(flags) + const client = await this.messagingClient( + flags, + scope.scope === 'team' + ? {serverApiKeyOnly: 'Team Hybrid routing'} + : {serverCredentialOnly: 'Hybrid routing'}, ) + const response = await client.hybridLink.setPolicy(scope, { + expectedRevision: flags['expected-revision'], + prefer, + allowedTransports, + }) this.writeValue( response, `Routing policy updated: ${response.data.data.revision}`, diff --git a/src/context/environment.ts b/src/context/environment.ts index 86345d07..90b8657d 100644 --- a/src/context/environment.ts +++ b/src/context/environment.ts @@ -35,6 +35,7 @@ export interface ResolveEnvironmentInput { export interface ResolveCommandEnvironmentInput extends ResolveEnvironmentInput { readonly profile?: string | undefined + readonly project?: string | undefined } export interface ListenerEnvironment { @@ -395,6 +396,7 @@ export async function resolveCommandEnvironment( const explicitProfile = nonEmpty(input.profile) const hasEnvironmentCredential = nonEmpty(input.env.POLYMORFA_API_KEY) !== undefined || + nonEmpty(input.env.POLYMORFA_PROJECT_TOKEN) !== undefined || nonEmpty(input.env.POLYMORFA_CLIENT_TOKEN) !== undefined if (explicitProfile === undefined && hasEnvironmentCredential) { @@ -418,17 +420,28 @@ export async function resolveCommandEnvironment( ) } - if (profile.credential.kind !== 'apiKey') { + if (input.surface === 'platform' && profile.credential.kind !== 'apiKey') { throw new PolymorfaConfigurationError( - `Profile ${selected} contains a project token, which is accepted only by project-owned developer commands and polymorfa listen.`, + `Profile ${selected} contains a project token; this Platform command requires an organization API key.`, 'credential', ) } - const apiKey = await credentials.get(profile.credential.account) - if (apiKey === undefined) { + if ( + profile.credential.kind === 'projectToken' && + nonEmpty(input.project) !== undefined && + profile.project !== nonEmpty(input.project) + ) { + throw new PolymorfaConfigurationError( + 'A project-token profile cannot access a different --project.', + 'project', + ) + } + + const storedCredential = await credentials.get(profile.credential.account) + if (storedCredential === undefined) { throw new PolymorfaConfigurationError( - `Profile ${selected} has no credential in the operating-system keyring. Run polymorfa auth login --with-api-key --profile ${selected}.`, + `Profile ${selected} has no credential in the operating-system keyring. Run polymorfa auth login ${profile.credential.kind === 'projectToken' ? '--with-project-token' : '--with-api-key'} --profile ${selected}.`, 'keyring', ) } @@ -437,7 +450,9 @@ export async function resolveCommandEnvironment( ...input, apiVersion: input.apiVersion ?? profile.apiVersion, env: { - POLYMORFA_API_KEY: apiKey, + ...(profile.credential.kind === 'projectToken' + ? {POLYMORFA_PROJECT_TOKEN: storedCredential} + : {POLYMORFA_API_KEY: storedCredential}), POLYMORFA_BASE_URL: nonEmpty(input.env.POLYMORFA_BASE_URL) ?? profile.baseUrl, }, @@ -450,6 +465,7 @@ export function resolveEnvironment( ): ResolvedEnvironment { const apiKey = nonEmpty(input.env.POLYMORFA_API_KEY) const clientToken = nonEmpty(input.env.POLYMORFA_CLIENT_TOKEN) + const projectToken = nonEmpty(input.env.POLYMORFA_PROJECT_TOKEN) const baseUrl = nonEmpty(input.env.POLYMORFA_BASE_URL) if (input.surface === 'platform') { @@ -463,13 +479,20 @@ export function resolveEnvironment( return resolved(input, {type: 'apiKey', value: apiKey}, baseUrl) } - if (apiKey !== undefined && clientToken !== undefined) { + if ( + [apiKey, projectToken, clientToken].filter((value) => value !== undefined) + .length > 1 + ) { throw new PolymorfaConfigurationError( - 'Messaging credentials are ambiguous; set only POLYMORFA_API_KEY or POLYMORFA_CLIENT_TOKEN.', + 'Messaging credentials are ambiguous; set only one of POLYMORFA_API_KEY, POLYMORFA_PROJECT_TOKEN, or POLYMORFA_CLIENT_TOKEN.', 'credential', ) } + if (projectToken !== undefined) { + return resolved(input, {type: 'projectToken', value: projectToken}, baseUrl) + } + if (clientToken !== undefined) { return resolved(input, {type: 'clientToken', value: clientToken}, baseUrl) } @@ -479,7 +502,7 @@ export function resolveEnvironment( } throw new PolymorfaConfigurationError( - 'Messaging API requests require POLYMORFA_API_KEY or POLYMORFA_CLIENT_TOKEN.', + 'Messaging API requests require POLYMORFA_API_KEY, POLYMORFA_PROJECT_TOKEN, or POLYMORFA_CLIENT_TOKEN.', 'credential', ) } diff --git a/test/commands/hybrid-credentials.test.ts b/test/commands/hybrid-credentials.test.ts new file mode 100644 index 00000000..f0efc915 --- /dev/null +++ b/test/commands/hybrid-credentials.test.ts @@ -0,0 +1,324 @@ +import {runCommand} from '@oclif/test' +import {AsyncEntry} from '@napi-rs/keyring' +import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {homedir} from 'node:os' +import {join} from 'node:path' +import {expect, test, vi} from 'vitest' +import { + startHttpSequenceServer, + withEnvironment, +} from '../helpers/http-server.js' + +const project = '11111111-1111-4111-8111-111111111111' +const apiKey = `pmfa_${'A'.repeat(72)}` +const projectToken = `pmfa_pt_${'A'.repeat(94)}` +const cases = [ + { + command: 'hybrid-link state --session existing', + method: 'GET', + path: '/messaging/existing/hybrid-link', + data: {revision: 'revision-1', paused: true, connections: []}, + }, + { + command: + 'hybrid-link pause --session existing --expected-revision revision-1', + method: 'PUT', + path: '/messaging/existing/hybrid-link', + data: {revision: 'revision-2', paused: true}, + }, + { + command: + 'hybrid-link resume --session existing --expected-revision revision-2', + method: 'PUT', + path: '/messaging/existing/hybrid-link', + data: {revision: 'revision-3', paused: false}, + }, + { + command: `routing-policy get --scope project --project ${project}`, + method: 'GET', + path: `/messaging/routing/hybrid?scope=project&projectId=${project}`, + data: { + scope: 'project', + revision: 'default', + prefer: null, + allowedTransports: [], + }, + }, + { + command: `routing-policy set --scope session --project ${project} --session existing --expected-revision default --prefer inherit --deny-all`, + method: 'PUT', + path: `/messaging/routing/hybrid?scope=session&projectId=${project}&session=existing`, + data: { + scope: 'session', + revision: 'revision-4', + prefer: null, + allowedTransports: [], + }, + }, + { + command: 'message operation-status op-1 --session existing', + method: 'GET', + path: '/messaging/existing/operations/op-1', + data: {operationId: 'op-1', status: 'unknown'}, + }, + { + command: `quicklink create --project ${project} --purpose add_connection --session existing --add-connection linked_devices`, + method: 'POST', + path: '/messaging/quicklinks', + data: { + id: 'ql_1', + session: 'existing', + url: 'https://example.test/quicklink/1', + expiresAt: null, + }, + }, + { + command: `quicklink availability --project ${project} --session existing`, + method: 'GET', + path: `/messaging/quicklinks/availability?projectId=${project}&session=existing`, + data: { + allowed: false, + addConnection: null, + connections: [], + resumeQuickLinkId: null, + }, + }, +] as const + +const credentials = [ + {source: 'environment', kind: 'apiKey', credential: apiKey}, + {source: 'environment', kind: 'projectToken', credential: projectToken}, + {source: 'profile', kind: 'apiKey', credential: apiKey}, + {source: 'profile', kind: 'projectToken', credential: projectToken}, +] as const + +test.each(credentials)( + 'Hybrid commands accept $source $kind without changing request scope', + async ({source, kind, credential}) => { + const server = await startHttpSequenceServer( + cases.map(({data}) => ({body: {success: true, data}})), + ) + const configDirectory = await mkdtemp( + join(homedir(), '.polymorfa-cli-tests-'), + ) + const keyring = vi + .spyOn(AsyncEntry.prototype, 'getPassword') + .mockResolvedValue(credential) + try { + await writeFile( + join(configDirectory, 'config.json'), + JSON.stringify({ + version: 2, + activeProfile: 'hybrid', + profiles: { + hybrid: { + credential: {kind, account: `profile:hybrid:${kind}`}, + ...(kind === 'projectToken' ? {owner: 'project', project} : {}), + }, + }, + }), + ) + await withEnvironment( + { + POLYMORFA_CONFIG_DIR: configDirectory, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_API_KEY: + source === 'environment' && kind === 'apiKey' + ? credential + : undefined, + POLYMORFA_PROJECT_TOKEN: + source === 'environment' && kind === 'projectToken' + ? credential + : undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + async () => { + for (const entry of cases) { + const result = await runCommand(`${entry.command} --json`) + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).not.toContain(credential) + } + }, + ) + expect( + server.requests.map(({method, url}) => ({method, path: url})), + ).toEqual(cases.map(({method, path}) => ({method, path}))) + expect( + server.requests.every( + ({headers}) => headers.authorization === `Bearer ${credential}`, + ), + ).toBe(true) + expect( + server.requests.every( + ({headers}) => headers['polymorfa-version'] === '2026-09-22', + ), + ).toBe(true) + expect(JSON.parse(server.requests[6]!.body)).toEqual({ + projectId: project, + purpose: 'add_connection', + session: 'existing', + addConnection: 'linked_devices', + }) + if (source === 'environment') expect(keyring).not.toHaveBeenCalled() + else expect(keyring).toHaveBeenCalledTimes(cases.length) + } finally { + keyring.mockRestore() + await rm(configDirectory, {recursive: true, force: true}) + await server.close() + } + }, +) + +test.each(cases)( + 'browser credentials cannot invoke $command', + async ({command}) => { + const server = await startHttpSequenceServer([{body: {}}]) + try { + await withEnvironment( + { + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_API_KEY: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + }, + async () => { + const result = await runCommand(`${command} --json`) + expect(result.error).toBeDefined() + expect(JSON.parse(result.stderr)).toMatchObject({ + error: {category: 'configuration'}, + }) + expect(result.stderr).not.toContain('pmfa_ct_browser') + }, + ) + expect(server.requests).toEqual([]) + } finally { + await server.close() + } + }, +) + +test.each([ + 'routing-policy get --scope team', + 'routing-policy set --scope team --expected-revision default --prefer inherit --deny-all', +])( + 'project credentials cannot invoke organization policy: %s', + async (command) => { + const server = await startHttpSequenceServer([{body: {}}]) + try { + await withEnvironment( + { + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_API_KEY: undefined, + POLYMORFA_PROJECT_TOKEN: projectToken, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + async () => { + const result = await runCommand(`${command} --json`) + expect(result.error).toBeDefined() + expect(result.stderr).toContain('require a server API key') + }, + ) + expect(server.requests).toEqual([]) + } finally { + await server.close() + } + }, +) + +test('invalid project-token format is rejected before transport', async () => { + const server = await startHttpSequenceServer([{body: {}}]) + try { + await withEnvironment( + { + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_API_KEY: undefined, + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_invalid', + POLYMORFA_CLIENT_TOKEN: undefined, + }, + async () => { + const result = await runCommand( + 'hybrid-link state --session existing --json', + ) + expect(result.error).toBeDefined() + expect(result.stderr).toContain('canonical pmfa_pt_ v1 format') + expect(result.stderr).not.toContain('pmfa_pt_invalid') + }, + ) + expect(server.requests).toEqual([]) + } finally { + await server.close() + } +}) + +test('a server scope refusal is surfaced once without changing credentials', async () => { + const server = await startHttpSequenceServer([ + { + status: 404, + body: {error: {code: 'not_found', message: 'Resource not found.'}}, + }, + ]) + try { + await withEnvironment( + { + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_API_KEY: undefined, + POLYMORFA_PROJECT_TOKEN: projectToken, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + async () => { + const result = await runCommand( + 'hybrid-link state --session another-project --json', + ) + expect(result.error).toBeDefined() + expect(JSON.parse(result.stderr)).toMatchObject({ + error: {code: 'not_found'}, + }) + }, + ) + expect(server.requests).toHaveLength(1) + expect(server.requests[0]?.headers.authorization).toBe( + `Bearer ${projectToken}`, + ) + } finally { + await server.close() + } +}) + +test('an explicit retired API revision is sent and rejected without automatic upgrade', async () => { + const server = await startHttpSequenceServer([ + { + status: 400, + body: { + error: { + code: 'invalid_parameter', + message: + 'API version 2026-08-19 is no longer supported. Minimum: 2026-09-22', + param: 'Polymorfa-Version', + }, + }, + }, + ]) + try { + await withEnvironment( + { + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_API_KEY: undefined, + POLYMORFA_PROJECT_TOKEN: projectToken, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + async () => { + const result = await runCommand( + 'hybrid-link state --session existing --api-version 2026-08-19 --json', + ) + expect(result.error).toBeDefined() + expect(JSON.parse(result.stderr)).toMatchObject({ + error: {code: 'invalid_parameter'}, + }) + }, + ) + expect(server.requests).toHaveLength(1) + expect(server.requests[0]?.headers['polymorfa-version']).toBe('2026-08-19') + } finally { + await server.close() + } +}) diff --git a/test/context/environment.test.ts b/test/context/environment.test.ts index aa92cfb1..c337bbcd 100644 --- a/test/context/environment.test.ts +++ b/test/context/environment.test.ts @@ -340,10 +340,94 @@ describe('resolveEnvironment', () => { surface: 'messaging', }), ).toThrow( - 'Messaging credentials are ambiguous; set only POLYMORFA_API_KEY or POLYMORFA_CLIENT_TOKEN.', + 'Messaging credentials are ambiguous; set only one of POLYMORFA_API_KEY, POLYMORFA_PROJECT_TOKEN, or POLYMORFA_CLIENT_TOKEN.', ) }) + test('uses an environment project token before the active API-key profile', async () => { + const credentials = fakeCredentials('unused') + await expect( + resolveCommandEnvironment( + { + env: {POLYMORFA_PROJECT_TOKEN: PROJECT_TOKEN}, + surface: 'messaging', + }, + fakeProfiles(), + credentials, + ), + ).resolves.toMatchObject({ + credential: {type: 'projectToken', value: PROJECT_TOKEN}, + source: 'environment', + }) + expect(credentials.get).not.toHaveBeenCalled() + }) + + test.each([ + {POLYMORFA_API_KEY: 'key', POLYMORFA_PROJECT_TOKEN: PROJECT_TOKEN}, + {POLYMORFA_CLIENT_TOKEN: 'client', POLYMORFA_PROJECT_TOKEN: PROJECT_TOKEN}, + ])('rejects ambiguous Messaging environment credentials %o', (env) => { + expect(() => resolveEnvironment({env, surface: 'messaging'})).toThrow( + 'ambiguous', + ) + }) + + test.each([undefined, 'project'])( + 'resolves active or explicit project-token Messaging profile %s', + async (profile) => { + await expect( + resolveCommandEnvironment( + { + env: profile + ? { + POLYMORFA_API_KEY: 'ambient', + POLYMORFA_CLIENT_TOKEN: 'ambient', + } + : {}, + surface: 'messaging', + profile, + project: PROJECT, + }, + projectProfiles(), + fakeCredentials(PROJECT_TOKEN), + ), + ).resolves.toMatchObject({ + credential: {type: 'projectToken', value: PROJECT_TOKEN}, + source: 'profile', + profile: 'project', + }) + }, + ) + + test('rejects a different project before reading the project-profile credential', async () => { + const credentials = fakeCredentials(PROJECT_TOKEN) + await expect( + resolveCommandEnvironment( + {env: {}, surface: 'messaging', project: OTHER_PROJECT}, + projectProfiles(), + credentials, + ), + ).rejects.toThrow('different --project') + expect(credentials.get).not.toHaveBeenCalled() + }) + + test('preserves the organization Platform credential boundary for project profiles and environment', async () => { + expect(() => + resolveEnvironment({ + env: {POLYMORFA_PROJECT_TOKEN: PROJECT_TOKEN}, + surface: 'platform', + }), + ).toThrow('POLYMORFA_API_KEY') + const credentials = fakeCredentials(PROJECT_TOKEN) + await expect( + resolveCommandEnvironment( + {env: {}, surface: 'platform'}, + projectProfiles(), + credentials, + ), + ).rejects.toThrow('organization API key') + expect(credentials.get).not.toHaveBeenCalled() + }) + test('prefers an environment credential over the active profile', async () => { const credentials = fakeCredentials( 'pmfa_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', @@ -465,3 +549,22 @@ function fakeCredentials(value: string): CredentialStore { set: vi.fn(() => Promise.resolve()), } } + +const PROJECT_TOKEN = `pmfa_pt_${'A'.repeat(94)}` +function projectProfiles() { + const config: ProfileConfig = { + version: 2, + activeProfile: 'project', + profiles: { + project: { + credential: { + kind: 'projectToken', + account: 'profile:project:projectToken', + }, + owner: 'project', + project: PROJECT, + }, + }, + } + return {load: () => Promise.resolve(config), save: () => Promise.resolve()} +} From 796cac94a6e08ec120ab0d1d35429b14d5908ad8 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 13:24:51 +0200 Subject: [PATCH 03/29] Report terminal Hybrid send rejections in operation status --- README.md | 3 +++ src/commands/message/operation-status.ts | 11 ++++++++- test/commands/hybrid-link.test.ts | 29 ++++++++++++++++++++++++ 3 files changed, 42 insertions(+), 1 deletion(-) diff --git a/README.md b/README.md index a71692e7..aec27522 100644 --- a/README.md +++ b/README.md @@ -1330,6 +1330,9 @@ All message send types, reactions, edits, and deletions accept `--transport auto preserves route metadata and the operation ID. If a send reports an unknown outcome, inspect `message operation-status`; do not resend with a new key or change transport. Status reads do not send. `pending` and `unknown` are unresolved outcomes. +`rejected` means the runtime proved it denied the send before a provider attempt. +Fix the access or routing cause before creating a new operation with a new +idempotency key. The rejected operation itself cannot be resumed. Policy writes require the revision returned by `routing-policy get` (`default` for an unsaved policy). Pause/resume requires the UUID returned by diff --git a/src/commands/message/operation-status.ts b/src/commands/message/operation-status.ts index 07b1109f..0ae4613f 100644 --- a/src/commands/message/operation-status.ts +++ b/src/commands/message/operation-status.ts @@ -26,9 +26,18 @@ export default class MessageOperationStatus extends BaseCommand { args.operation, ) const value = response.data.data + // The preview server can return this terminal state before the registry + // SDK release carries its widened MessageOperation type. + const outcome = value as {status: string; rejectionCode?: string} + const guidance = + outcome.status === 'rejected' + ? `\nRejected before provider send (${outcome.rejectionCode ?? 'hybrid_authority_unavailable'}). Fix the cause, then start a new operation with a new idempotency key.` + : outcome.status === 'completed' + ? '' + : '\nOutcome pending. Read this operation again; do not resend.' this.writeValue( response, - `${value.operationId}\t${value.status}${value.transport ? `\t${value.transport}` : ''}${value.status === 'completed' ? '' : '\nOutcome pending. Read this operation again; do not resend.'}`, + `${value.operationId}\t${value.status}${value.transport ? `\t${value.transport}` : ''}${guidance}`, flags, ) } diff --git a/test/commands/hybrid-link.test.ts b/test/commands/hybrid-link.test.ts index b3abe824..b1996a9f 100644 --- a/test/commands/hybrid-link.test.ts +++ b/test/commands/hybrid-link.test.ts @@ -151,3 +151,32 @@ test('unknown outcome is printed with its operation ID and never resends', async await server.close() } }) +test('proven pre-send rejection is terminal and does not advise polling', async () => { + const server = await startHttpSequenceServer([ + { + body: { + success: true, + data: { + operationId: 'op-rejected', + status: 'rejected', + transport: 'linked_devices', + rejectionCode: 'hybrid_authority_unavailable', + }, + }, + }, + ]) + try { + await withEnvironment(env(server.baseUrl), async () => { + const result = await runCommand( + 'message operation-status op-rejected --session existing', + ) + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).toContain('Rejected before provider send') + expect(result.stdout).toContain('new idempotency key') + expect(result.stdout).not.toContain('Outcome pending') + }) + expect(server.requests.map((request) => request.method)).toEqual(['GET']) + } finally { + await server.close() + } +}) From 9687b5b1935cfa259d6a260275e0f1898bc975cb Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 14:50:39 +0200 Subject: [PATCH 04/29] Format Hybrid message receipt fixture --- test/commands/messages.test.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/test/commands/messages.test.ts b/test/commands/messages.test.ts index b75cc31e..ff13d345 100644 --- a/test/commands/messages.test.ts +++ b/test/commands/messages.test.ts @@ -276,7 +276,10 @@ async function messageServer(requestId?: string): Promise { function completeMessage() { return { id: '740000000001', - whatsapp_ids: {linked_devices: '3A000000000000000001', official_api: 'wamid.exact-provider-reference'}, + whatsapp_ids: { + linked_devices: '3A000000000000000001', + official_api: 'wamid.exact-provider-reference', + }, conversation: {id: '730000000001', phoneNumber: '+15551234567'}, type: 'text', timestamp: '2026-08-19T20:00:00.000Z', From 72087d841842d929e648b6f66c28aa3fc3b89364 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 15:00:10 +0200 Subject: [PATCH 05/29] Show Hybrid connection readiness in CLI state --- src/commands/hybrid-link/state.ts | 8 +++++++- test/commands/hybrid-link.test.ts | 28 ++++++++++++++++++++++++++++ 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/src/commands/hybrid-link/state.ts b/src/commands/hybrid-link/state.ts index 098ccc2b..1afb2ccb 100644 --- a/src/commands/hybrid-link/state.ts +++ b/src/commands/hybrid-link/state.ts @@ -16,9 +16,15 @@ export default class HybridLinkState extends BaseCommand { serverCredentialOnly: 'Hybrid Link', }) const response = await client.hybridLink.state(flags.session) + const connections = response.data.data.connections + .map( + ({kind, status, enabled}) => + `${kind}: ${status} (${enabled ? 'enabled' : 'disabled'})`, + ) + .join(', ') this.writeValue( response, - `${response.data.data.revision}\t${response.data.data.paused ? 'paused' : 'active'}`, + `${response.data.data.revision}\t${response.data.data.paused ? 'paused' : 'active'}\t${connections || 'no connections'}`, flags, ) } diff --git a/test/commands/hybrid-link.test.ts b/test/commands/hybrid-link.test.ts index b1996a9f..7055e0cb 100644 --- a/test/commands/hybrid-link.test.ts +++ b/test/commands/hybrid-link.test.ts @@ -11,6 +11,34 @@ const env = (url: string) => ({ POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_PROJECT_TOKEN: undefined, }) +test('state output shows both connection readiness and enablement', async () => { + const server = await startHttpSequenceServer([ + { + body: { + success: true, + data: { + revision: 'pair-4', + paused: false, + connections: [ + {kind: 'linked_devices', status: 'connected', enabled: true}, + {kind: 'official_api', status: 'disconnected', enabled: false}, + ], + }, + }, + }, + ]) + try { + await withEnvironment(env(server.baseUrl), async () => { + const result = await runCommand('hybrid-link state --session existing') + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).toContain('pair-4\tactive') + expect(result.stdout).toContain('linked_devices: connected (enabled)') + expect(result.stdout).toContain('official_api: disconnected (disabled)') + }) + } finally { + await server.close() + } +}) test('supplementary QuickLink flags preserve the Number and availability scope', async () => { const server = await startHttpSequenceServer([ { From 70fdc8d9d2ce523b7a8abca202f0276b6be63a09 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 15:05:34 +0200 Subject: [PATCH 06/29] Verify CLI against pinned companion SDK source --- .github/workflows/ci.yml | 14 ++++++++++++++ .gitignore | 1 + .prettierignore | 1 + eslint.config.js | 2 +- test/commands/version.test.ts | 12 +++--------- 5 files changed, 20 insertions(+), 10 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74f07b00..ec06a3fc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -25,5 +25,19 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: npm + # The Hybrid Link SDK contract is in a companion PR. Verify this PR against + # that immutable source revision until its package is published. + - run: npm ci --ignore-scripts + - uses: actions/checkout@v6 + with: + repository: polymorfa/sdks + ref: aa4e1d07286b6ccda36dd91a2ce2482b7188608f + path: .sdk-source - run: npm ci + working-directory: .sdk-source + - run: npm run build -w @polymorfa/sdk + working-directory: .sdk-source + - run: npm pack -w @polymorfa/sdk --pack-destination .. --ignore-scripts + working-directory: .sdk-source + - run: npm install --no-save --package-lock=false --ignore-scripts ./polymorfa-sdk-*.tgz - run: npm run verify diff --git a/.gitignore b/.gitignore index 9dc13d62..5f95008b 100644 --- a/.gitignore +++ b/.gitignore @@ -2,4 +2,5 @@ node_modules/ dist/ coverage/ *.tgz +.sdk-source/ .DS_Store diff --git a/.prettierignore b/.prettierignore index 59fce59f..4cc05dbb 100644 --- a/.prettierignore +++ b/.prettierignore @@ -3,3 +3,4 @@ dist/ node_modules/ package-lock.json *.tgz +.sdk-source/ diff --git a/eslint.config.js b/eslint.config.js index 866580a9..e720c41c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -3,7 +3,7 @@ import tseslint from 'typescript-eslint' export default tseslint.config( { - ignores: ['coverage/**', 'dist/**', 'node_modules/**'], + ignores: ['.sdk-source/**', 'coverage/**', 'dist/**', 'node_modules/**'], }, { linterOptions: {reportUnusedDisableDirectives: 'error'}, diff --git a/test/commands/version.test.ts b/test/commands/version.test.ts index b7cd97f6..6bd73312 100644 --- a/test/commands/version.test.ts +++ b/test/commands/version.test.ts @@ -1,19 +1,13 @@ -import {readFileSync} from 'node:fs' - import {runCommand} from '@oclif/test' +import {SDK_VERSION} from '@polymorfa/sdk' import {describe, expect, test} from 'vitest' -const manifest = JSON.parse( - readFileSync(new URL('../../package.json', import.meta.url), 'utf8'), -) as {dependencies: {'@polymorfa/sdk': string}} -const sdkVersion = manifest.dependencies['@polymorfa/sdk'] - describe('version', () => { test('prints the CLI and SDK development versions', async () => { const result = await runCommand('version') expect(result.error).toBeUndefined() - expect(result.stdout).toBe(`polymorfa/0.1.0-dev.0 sdk/${sdkVersion}\n`) + expect(result.stdout).toBe(`polymorfa/0.1.0-dev.0 sdk/${SDK_VERSION}\n`) expect(result.stderr).toBe('') }) @@ -23,7 +17,7 @@ describe('version', () => { expect(result.error).toBeUndefined() expect(JSON.parse(result.stdout)).toEqual({ cli: '0.1.0-dev.0', - sdk: sdkVersion, + sdk: SDK_VERSION, }) expect(result.stderr).toBe('') }) From e94b4b3a6a093462e182b808d17475079afbc821 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 15:08:54 +0200 Subject: [PATCH 07/29] Build CLI before source-backed tests --- .github/workflows/ci.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ec06a3fc..03075f35 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -40,4 +40,5 @@ jobs: - run: npm pack -w @polymorfa/sdk --pack-destination .. --ignore-scripts working-directory: .sdk-source - run: npm install --no-save --package-lock=false --ignore-scripts ./polymorfa-sdk-*.tgz + - run: npm run build - run: npm run verify From cbab6037ca151f0b9165fdd1e5929b7ccf495b11 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 15:12:06 +0200 Subject: [PATCH 08/29] Pin CLI verification to final Hybrid SDK source --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 03075f35..3633342c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v6 with: repository: polymorfa/sdks - ref: aa4e1d07286b6ccda36dd91a2ce2482b7188608f + ref: 076d593f4effe8d850768682b448b6583ae420b3 path: .sdk-source - run: npm ci working-directory: .sdk-source From b5713fc01b90f7e4994cd679c0ac37349bf7d83a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 15:20:07 +0200 Subject: [PATCH 09/29] Honor Hybrid project context and show QuickLink availability --- README.md | 6 ++ src/base-command.ts | 2 + src/commands/quicklink/availability.ts | 18 +++- src/commands/routing-policy/get.ts | 17 ++-- src/commands/routing-policy/set.ts | 17 ++-- src/context/environment.ts | 13 +++ src/input/hybrid-routing.ts | 21 ++-- test/commands/hybrid-credentials.test.ts | 124 ++++++++++++++++++++++- test/commands/hybrid-link.test.ts | 88 ++++++++++++++++ 9 files changed, 278 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 05b45ae2..52e92bb2 100644 --- a/README.md +++ b/README.md @@ -1645,6 +1645,12 @@ polymorfa hybrid-link pause --session NUMBER --expected-revision REVISION polymorfa hybrid-link resume --session NUMBER --expected-revision REVISION ``` +For project and session routing scopes, `--project`, the nearest linked project, +and the selected profile determine the project in that order. A project-token +profile fails when its project differs from the linked directory. `quicklink +availability` shows existing connection status and any QuickLink that can be +resumed. + All message send types, reactions, edits, and deletions accept `--transport auto`, `linked_devices`, or `official_api`. Explicit choices never fall back. JSON output preserves route metadata and the operation ID. If a send reports an unknown diff --git a/src/base-command.ts b/src/base-command.ts index bbff346f..6cae01bf 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -225,9 +225,11 @@ export abstract class BaseCommand extends Command { flags: GlobalFlags, surface: ResolvedEnvironment['surface'], ): Promise { + const linkedProject = await this.linkedProject(flags) const environment = await resolveCommandEnvironment({ env: process.env, surface, + ...(linkedProject === undefined ? {} : {linkedProject}), ...(flags.project === undefined ? {} : {project: flags.project}), ...(flags.profile === undefined ? {} : {profile: flags.profile}), ...(flags['api-version'] === undefined diff --git a/src/commands/quicklink/availability.ts b/src/commands/quicklink/availability.ts index c9197d25..b38c2150 100644 --- a/src/commands/quicklink/availability.ts +++ b/src/commands/quicklink/availability.ts @@ -18,11 +18,23 @@ export default class QuickLinkAvailability extends BaseCommand { session: flags.session, }) const data = response.data.data + const connections = data.connections + .map( + ({kind, status, enabled}) => + `${kind}: ${status} (${enabled ? 'enabled' : 'disabled'})`, + ) + .join(', ') this.writeValue( response, - data.allowed && data.addConnection - ? `Available: ${data.addConnection}` - : 'No added connection is available.', + [ + data.allowed && data.addConnection + ? `Available: ${data.addConnection}` + : 'No added connection is available.', + `Connections: ${connections || 'none'}`, + ...(data.resumeQuickLinkId + ? [`Resume QuickLink: ${data.resumeQuickLinkId}`] + : []), + ].join('\n'), flags, ) } diff --git a/src/commands/routing-policy/get.ts b/src/commands/routing-policy/get.ts index ee33536e..f70a0d7f 100644 --- a/src/commands/routing-policy/get.ts +++ b/src/commands/routing-policy/get.ts @@ -9,13 +9,16 @@ export default class RoutingPolicyGet extends BaseCommand { static override flags = hybridPolicyFlags async run(): Promise { const {flags} = await this.parse(RoutingPolicyGet) - const scope = hybridPolicyScope(flags) - const client = await this.messagingClient( - flags, - scope.scope === 'team' - ? {serverApiKeyOnly: 'Team Hybrid routing'} - : {serverCredentialOnly: 'Hybrid routing'}, - ) + const {client, projectId} = + flags.scope === 'team' + ? { + client: await this.messagingClient(flags, { + serverApiKeyOnly: 'Team Hybrid routing', + }), + projectId: undefined, + } + : await this.quickLinksClient(flags) + const scope = hybridPolicyScope(flags, projectId) const response = await client.hybridLink.getPolicy(scope) const policy = response.data.data this.writeValue( diff --git a/src/commands/routing-policy/set.ts b/src/commands/routing-policy/set.ts index ec831598..0ccc2b2a 100644 --- a/src/commands/routing-policy/set.ts +++ b/src/commands/routing-policy/set.ts @@ -57,13 +57,16 @@ export default class RoutingPolicySet extends BaseCommand { 'allow', ) }) - const scope = hybridPolicyScope(flags) - const client = await this.messagingClient( - flags, - scope.scope === 'team' - ? {serverApiKeyOnly: 'Team Hybrid routing'} - : {serverCredentialOnly: 'Hybrid routing'}, - ) + const {client, projectId} = + flags.scope === 'team' + ? { + client: await this.messagingClient(flags, { + serverApiKeyOnly: 'Team Hybrid routing', + }), + projectId: undefined, + } + : await this.quickLinksClient(flags) + const scope = hybridPolicyScope(flags, projectId) const response = await client.hybridLink.setPolicy(scope, { expectedRevision: flags['expected-revision'], prefer, diff --git a/src/context/environment.ts b/src/context/environment.ts index b3ec3d91..6ddac6f6 100644 --- a/src/context/environment.ts +++ b/src/context/environment.ts @@ -34,6 +34,7 @@ export interface ResolveEnvironmentInput { } export interface ResolveCommandEnvironmentInput extends ResolveEnvironmentInput { + readonly linkedProject?: string | undefined readonly profile?: string | undefined readonly project?: string | undefined } @@ -438,6 +439,18 @@ export async function resolveCommandEnvironment( ) } + if ( + profile.credential.kind === 'projectToken' && + nonEmpty(input.project) === undefined && + nonEmpty(input.linkedProject) !== undefined && + profile.project !== nonEmpty(input.linkedProject) + ) { + throw new PolymorfaConfigurationError( + `This directory is linked to project ${input.linkedProject}, but project-token profile ${selected} is bound to ${profile.project ?? 'another project'}. Pass --project or run polymorfa link again.`, + 'project', + ) + } + const storedCredential = await credentials.get(profile.credential.account) if (storedCredential === undefined) { throw new PolymorfaConfigurationError( diff --git a/src/input/hybrid-routing.ts b/src/input/hybrid-routing.ts index 3badc666..80c3043f 100644 --- a/src/input/hybrid-routing.ts +++ b/src/input/hybrid-routing.ts @@ -14,19 +14,20 @@ export const hybridPolicyFlags = { description: 'Number session name for session scope.', }), } -export function hybridPolicyScope(flags: { - scope: string - project?: string | undefined - session?: string | undefined -}): HybridRoutingPolicyScope { +export function hybridPolicyScope( + flags: { + scope: string + session?: string | undefined + }, + projectId?: string, +): HybridRoutingPolicyScope { if (flags.scope === 'team') return {scope: 'team'} - if (!flags.project) + if (!projectId) throw new PolymorfaConfigurationError( - 'This scope requires --project.', + 'This scope requires a project context.', 'project', ) - if (flags.scope === 'project') - return {scope: 'project', projectId: flags.project} + if (flags.scope === 'project') return {scope: 'project', projectId} if (flags.scope !== 'session') throw new PolymorfaConfigurationError('Invalid policy scope.', 'scope') if (!flags.session) @@ -34,5 +35,5 @@ export function hybridPolicyScope(flags: { 'Session scope requires --session.', 'session', ) - return {scope: 'session', projectId: flags.project, session: flags.session} + return {scope: 'session', projectId, session: flags.session} } diff --git a/test/commands/hybrid-credentials.test.ts b/test/commands/hybrid-credentials.test.ts index f0efc915..1fa6818f 100644 --- a/test/commands/hybrid-credentials.test.ts +++ b/test/commands/hybrid-credentials.test.ts @@ -1,6 +1,6 @@ import {runCommand} from '@oclif/test' import {AsyncEntry} from '@napi-rs/keyring' -import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises' import {homedir} from 'node:os' import {join} from 'node:path' import {expect, test, vi} from 'vitest' @@ -169,6 +169,128 @@ test.each(credentials)( }, ) +test('Hybrid mutation refuses a project-token profile in a different linked directory', async () => { + const server = await startHttpSequenceServer([{body: {}}]) + const directory = await mkdtemp(join(homedir(), '.polymorfa-cli-link-')) + const configDirectory = join(directory, 'config') + const previousDirectory = process.cwd() + const keyring = vi + .spyOn(AsyncEntry.prototype, 'getPassword') + .mockResolvedValue(projectToken) + try { + await writeFile( + join(directory, 'polymorfa.json'), + JSON.stringify({ + project: '22222222-2222-4222-8222-222222222222', + version: 1, + }), + ) + await mkdir(configDirectory) + await writeFile( + join(configDirectory, 'config.json'), + JSON.stringify({ + version: 2, + activeProfile: 'hybrid', + profiles: { + hybrid: { + credential: { + kind: 'projectToken', + account: 'profile:hybrid:projectToken', + }, + owner: 'project', + project, + }, + }, + }), + ) + process.chdir(directory) + await withEnvironment( + { + POLYMORFA_CONFIG_DIR: configDirectory, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_API_KEY: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + async () => { + const result = await runCommand( + 'hybrid-link pause --session existing --expected-revision prior --json', + ) + expect(result.error).toBeDefined() + expect(result.stderr).toContain('This directory is linked to project') + }, + ) + expect(server.requests).toEqual([]) + expect(keyring).not.toHaveBeenCalled() + } finally { + process.chdir(previousDirectory) + keyring.mockRestore() + await rm(directory, {recursive: true, force: true}) + await server.close() + } +}) + +test('session policy uses the project bound to the selected profile', async () => { + const server = await startHttpSequenceServer([ + { + body: { + success: true, + data: { + scope: 'session', + revision: 'default', + prefer: null, + allowedTransports: ['linked_devices'], + }, + }, + }, + ]) + const configDirectory = await mkdtemp( + join(homedir(), '.polymorfa-cli-profile-'), + ) + const keyring = vi + .spyOn(AsyncEntry.prototype, 'getPassword') + .mockResolvedValue(apiKey) + try { + await writeFile( + join(configDirectory, 'config.json'), + JSON.stringify({ + version: 2, + activeProfile: 'hybrid', + profiles: { + hybrid: { + credential: {kind: 'apiKey', account: 'profile:hybrid:apiKey'}, + owner: 'project', + project, + }, + }, + }), + ) + await withEnvironment( + { + POLYMORFA_CONFIG_DIR: configDirectory, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_API_KEY: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + async () => { + const result = await runCommand( + 'routing-policy get --scope session --session existing --json', + ) + expect(result.error, result.stderr).toBeUndefined() + }, + ) + expect(server.requests.map(({url}) => url)).toEqual([ + `/messaging/routing/hybrid?scope=session&projectId=${project}&session=existing`, + ]) + expect(keyring).toHaveBeenCalledOnce() + } finally { + keyring.mockRestore() + await rm(configDirectory, {recursive: true, force: true}) + await server.close() + } +}) + test.each(cases)( 'browser credentials cannot invoke $command', async ({command}) => { diff --git a/test/commands/hybrid-link.test.ts b/test/commands/hybrid-link.test.ts index 7055e0cb..87852505 100644 --- a/test/commands/hybrid-link.test.ts +++ b/test/commands/hybrid-link.test.ts @@ -1,4 +1,7 @@ import {runCommand} from '@oclif/test' +import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {homedir} from 'node:os' +import {join} from 'node:path' import {expect, test} from 'vitest' import { startHttpSequenceServer, @@ -39,6 +42,91 @@ test('state output shows both connection readiness and enablement', async () => await server.close() } }) +test('routing policy uses linked project and resolves an explicit slug', async () => { + const policy = { + scope: 'project', + revision: 'default', + prefer: null, + allowedTransports: ['linked_devices'], + } + const server = await startHttpSequenceServer([ + {body: {success: true, data: policy}}, + { + body: { + success: true, + data: [ + { + _creationTime: 0, + _id: project, + name: 'App', + orgId: 'org_1', + slug: 'app', + stage: 'development', + }, + ], + }, + }, + {body: {success: true, data: policy}}, + ]) + const directory = await mkdtemp(join(homedir(), '.polymorfa-cli-policy-')) + const previousDirectory = process.cwd() + try { + await writeFile( + join(directory, 'polymorfa.json'), + JSON.stringify({project, version: 1}), + ) + process.chdir(directory) + await withEnvironment(env(server.baseUrl), async () => { + const linked = await runCommand('routing-policy get --scope project') + expect(linked.error, linked.stderr).toBeUndefined() + const explicit = await runCommand( + 'routing-policy get --scope project --project app', + ) + expect(explicit.error, explicit.stderr).toBeUndefined() + }) + expect(server.requests.map(({url}) => url)).toEqual([ + `/messaging/routing/hybrid?scope=project&projectId=${project}`, + '/platform/projects', + `/messaging/routing/hybrid?scope=project&projectId=${project}`, + ]) + } finally { + process.chdir(previousDirectory) + await rm(directory, {recursive: true, force: true}) + await server.close() + } +}) +test('availability output names existing connections and resumable QuickLink', async () => { + const server = await startHttpSequenceServer([ + { + body: { + success: true, + data: { + allowed: false, + addConnection: null, + connections: [ + {kind: 'linked_devices', status: 'connected', enabled: true}, + {kind: 'official_api', status: 'pending', enabled: false}, + ], + resumeQuickLinkId: 'ql_pending', + }, + }, + }, + ]) + try { + await withEnvironment(env(server.baseUrl), async () => { + const result = await runCommand( + `quicklink availability --project ${project} --session existing`, + ) + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).toContain('No added connection is available.') + expect(result.stdout).toContain('linked_devices: connected (enabled)') + expect(result.stdout).toContain('official_api: pending (disabled)') + expect(result.stdout).toContain('Resume QuickLink: ql_pending') + }) + } finally { + await server.close() + } +}) test('supplementary QuickLink flags preserve the Number and availability scope', async () => { const server = await startHttpSequenceServer([ { From c302cd625575e5be45edea439b53423d029460ac Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 15:21:27 +0200 Subject: [PATCH 10/29] Pin CLI verification to final SDK review head --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3633342c..d8b81dfd 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v6 with: repository: polymorfa/sdks - ref: 076d593f4effe8d850768682b448b6583ae420b3 + ref: 4245e4bf1026e0495592ebfc3866d520b0356ced path: .sdk-source - run: npm ci working-directory: .sdk-source From 33c09ff4aef11405ca937472ef226df10e2137b7 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 15:29:34 +0200 Subject: [PATCH 11/29] Pin CLI to rebased Hybrid SDK source --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index d8b81dfd..265abe6e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v6 with: repository: polymorfa/sdks - ref: 4245e4bf1026e0495592ebfc3866d520b0356ced + ref: 92a2617a5a4ad02cc082f33b6bd0354e03103eeb path: .sdk-source - run: npm ci working-directory: .sdk-source From 3ddfac12c69e2088f6813f4e96ffad984dad7789 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 15:31:06 +0200 Subject: [PATCH 12/29] Pin CLI verification to final API and SDK contract --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 265abe6e..ea10e821 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v6 with: repository: polymorfa/sdks - ref: 92a2617a5a4ad02cc082f33b6bd0354e03103eeb + ref: c7cdba278c3d53f2e21a8d6efda097d6a09a2374 path: .sdk-source - run: npm ci working-directory: .sdk-source From d0fbaea859b26dca8215326cc450f7ec67593ae8 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 15:44:11 +0200 Subject: [PATCH 13/29] Pin CLI CI to reviewed SDK source --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ea10e821..7a0856d6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v6 with: repository: polymorfa/sdks - ref: c7cdba278c3d53f2e21a8d6efda097d6a09a2374 + ref: 6e576ce75930992e290c9a42a9cf19febe2691b6 path: .sdk-source - run: npm ci working-directory: .sdk-source From b3fb09d107110f51405caf57da0915754cf2d95a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 15:46:25 +0200 Subject: [PATCH 14/29] Pin CLI review workflow to validated SDK head --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7a0856d6..c2b8b377 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v6 with: repository: polymorfa/sdks - ref: 6e576ce75930992e290c9a42a9cf19febe2691b6 + ref: df7e6aadb1b7eef2a06fe803666036c85de3fade path: .sdk-source - run: npm ci working-directory: .sdk-source From 53dea71bdeb336ce038c6867ae5b28c7f298ca71 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 15:58:13 +0200 Subject: [PATCH 15/29] Resolve QuickLink project context for flag-only creation --- src/commands/quicklink/create.ts | 14 +++++++---- test/commands/hybrid-link.test.ts | 42 +++++++++++++++++++++++++++++++ test/commands/quicklinks.test.ts | 8 +++--- 3 files changed, 55 insertions(+), 9 deletions(-) diff --git a/src/commands/quicklink/create.ts b/src/commands/quicklink/create.ts index 87f91098..2cfef519 100644 --- a/src/commands/quicklink/create.ts +++ b/src/commands/quicklink/create.ts @@ -59,11 +59,15 @@ export default class QuickLinkCreate extends BaseCommand { 'Adding a connection requires --session and --add-connection with the hybrid goal.', 'input', ) - const result = await createQuickLink( - await this.messagingClient(flags, {serverCredentialOnly: 'QuickLink'}), - body, - flags.project, - ) + const linkedProject = await this.linkedProject(flags) + const selectedFlags = + flags.project === undefined && + linkedProject === undefined && + typeof body.projectId === 'string' + ? {...flags, project: body.projectId} + : flags + const {client, projectId} = await this.quickLinksClient(selectedFlags) + const result = await createQuickLink(client, body, projectId) this.writeValue(result, result.data.url, flags) } } diff --git a/test/commands/hybrid-link.test.ts b/test/commands/hybrid-link.test.ts index 87852505..5d8d5469 100644 --- a/test/commands/hybrid-link.test.ts +++ b/test/commands/hybrid-link.test.ts @@ -95,6 +95,48 @@ test('routing policy uses linked project and resolves an explicit slug', async ( await server.close() } }) + +test('flag-only QuickLink creation uses the linked project', async () => { + const server = await startHttpSequenceServer([ + { + body: { + success: true, + data: { + id: 'ql-1', + session: 'existing', + url: 'https://example.test/ql-1', + expiresAt: null, + }, + }, + }, + ]) + const directory = await mkdtemp(join(homedir(), '.polymorfa-cli-quicklink-')) + const previousDirectory = process.cwd() + try { + await writeFile( + join(directory, 'polymorfa.json'), + JSON.stringify({project, version: 1}), + ) + process.chdir(directory) + await withEnvironment(env(server.baseUrl), async () => { + const result = await runCommand( + 'quicklink create --purpose add_connection --session existing --add-connection official_api --json', + ) + expect(result.error, result.stderr).toBeUndefined() + }) + expect(server.requests).toHaveLength(1) + expect(JSON.parse(server.requests[0]!.body)).toMatchObject({ + projectId: project, + purpose: 'add_connection', + session: 'existing', + addConnection: 'official_api', + }) + } finally { + process.chdir(previousDirectory) + await rm(directory, {recursive: true, force: true}) + await server.close() + } +}) test('availability output names existing connections and resumable QuickLink', async () => { const server = await startHttpSequenceServer([ { diff --git a/test/commands/quicklinks.test.ts b/test/commands/quicklinks.test.ts index ab7a8423..a93fb3aa 100644 --- a/test/commands/quicklinks.test.ts +++ b/test/commands/quicklinks.test.ts @@ -42,24 +42,24 @@ test('creates a QuickLink and uploads separate test history with exact project s }, async () => { const created = await runCommand( - `quicklink create --project project/1 --input ${input} --json`, + `quicklink create --project ${projectId} --input ${input} --json`, ) expect(created.error).toBeUndefined() expect(JSON.parse(created.stdout)).toMatchObject({ data: {id: 'ql_test', expiresAt: null}, }) const uploaded = await runCommand( - `testing history-fixture create --project project/1 --input ${history} --json`, + `testing history-fixture create --project ${projectId} --input ${history} --json`, ) expect(uploaded.error).toBeUndefined() }, ) expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ 'POST /messaging/quicklinks', - 'POST /messaging/testing/project%2F1/history-fixtures', + `POST /messaging/testing/${projectId}/history-fixtures`, ]) expect(JSON.parse(server.requests[0]!.body)).toEqual({ - projectId: 'project/1', + projectId, configuration, }) } finally { From e18cea8d9f4b0c21e18aadd88ec36420ba588453 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 16:45:39 +0200 Subject: [PATCH 16/29] Pin Hybrid Link CLI CI to current SDK source --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c2b8b377..27ca1032 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v6 with: repository: polymorfa/sdks - ref: df7e6aadb1b7eef2a06fe803666036c85de3fade + ref: 63c7270c9fd221675b393b3c0b3a27fcf0f70cc0 path: .sdk-source - run: npm ci working-directory: .sdk-source From d9049fd4cbb11d69069758439e0d01b2da042fbe Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 16:57:30 +0200 Subject: [PATCH 17/29] Ignore project links for organization commands and repin SDK --- .github/workflows/ci.yml | 2 +- src/base-command.ts | 5 ++- src/context/environment.ts | 16 +++++++- src/developer/client.ts | 4 +- test/commands/project-context.test.ts | 53 ++++++++++++++++++++++++++- test/developer/client.test.ts | 25 ++++++++++++- test/services/projects.test.ts | 25 +++++++++++++ 7 files changed, 121 insertions(+), 9 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27ca1032..3932e296 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v6 with: repository: polymorfa/sdks - ref: 63c7270c9fd221675b393b3c0b3a27fcf0f70cc0 + ref: 33fa3d619c12b2dbf991af42a18b1b12cad7a39d path: .sdk-source - run: npm ci working-directory: .sdk-source diff --git a/src/base-command.ts b/src/base-command.ts index 6cae01bf..1f2c027f 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -93,7 +93,7 @@ export abstract class BaseCommand extends Command { const session = await createDeveloperSession( flags, (secret) => this.registerSecret(secret), - await this.linkedProject(flags), + () => this.linkedProject(flags), ) this.errorContext = session.context return session @@ -225,7 +225,8 @@ export abstract class BaseCommand extends Command { flags: GlobalFlags, surface: ResolvedEnvironment['surface'], ): Promise { - const linkedProject = await this.linkedProject(flags) + const linkedProject = + surface === 'messaging' ? await this.linkedProject(flags) : undefined const environment = await resolveCommandEnvironment({ env: process.env, surface, diff --git a/src/context/environment.ts b/src/context/environment.ts index 6ddac6f6..69658769 100644 --- a/src/context/environment.ts +++ b/src/context/environment.ts @@ -72,6 +72,7 @@ export interface ResolveDeveloperEnvironmentInput { readonly env: Readonly> /** Project from the nearest `polymorfa link` file; below --project. */ readonly linkedProject?: string | undefined + readonly loadLinkedProject?: (() => Promise) | undefined readonly owner?: string | undefined readonly profile?: string | undefined readonly project?: string | undefined @@ -101,7 +102,9 @@ export async function resolveDeveloperEnvironment( nonEmpty(input.env.POLYMORFA_BASE_URL) ?? 'https://api.polymorfa.com' const requestedProject = nonEmpty(input.project) ?? - (input.owner === 'project' ? nonEmpty(input.linkedProject) : undefined) + (input.owner === 'project' + ? await developerLinkedProject(input) + : undefined) const unresolvedContext = resolveOwnerContext({ principalType: environmentCredential.principalType, ...(input.owner === undefined ? {} : {owner: input.owner}), @@ -177,7 +180,10 @@ export async function resolveDeveloperEnvironment( 'project', ) } - const linkedProject = nonEmpty(input.linkedProject) + const linkedProject = + owner === 'project' && explicitProject === undefined + ? await developerLinkedProject(input) + : undefined if ( principalType === 'project_token' && owner === 'project' && @@ -229,6 +235,12 @@ export async function resolveDeveloperEnvironment( } } +async function developerLinkedProject( + input: ResolveDeveloperEnvironmentInput, +): Promise { + return nonEmpty(input.linkedProject ?? (await input.loadLinkedProject?.())) +} + export async function resolveListenerEnvironment( input: ResolveListenerEnvironmentInput, profiles: ProfileRepositoryLike = new ProfileRepository(), diff --git a/src/developer/client.ts b/src/developer/client.ts index a5c4affd..d2390950 100644 --- a/src/developer/client.ts +++ b/src/developer/client.ts @@ -26,11 +26,11 @@ export interface DeveloperSession { export async function createDeveloperSession( flags: GlobalFlags, registerSecret?: (secret: string) => void, - linkedProject?: string, + loadLinkedProject?: () => Promise, ): Promise { const environment = await resolveDeveloperEnvironment({ env: process.env, - ...(linkedProject === undefined ? {} : {linkedProject}), + ...(loadLinkedProject === undefined ? {} : {loadLinkedProject}), ...(flags['api-version'] === undefined ? {} : {apiVersion: flags['api-version']}), diff --git a/test/commands/project-context.test.ts b/test/commands/project-context.test.ts index 4179965e..9092e7a4 100644 --- a/test/commands/project-context.test.ts +++ b/test/commands/project-context.test.ts @@ -1,4 +1,4 @@ -import {mkdtemp, readFile, rm} from 'node:fs/promises' +import {mkdir, mkdtemp, readFile, rm, writeFile} from 'node:fs/promises' import {tmpdir} from 'node:os' import {join} from 'node:path' @@ -87,4 +87,55 @@ describe('link and project commands', () => { expect(result.error).toBeUndefined() expect(result.stdout).toContain('No project selected') }) + + test.each(['malformed', 'unreadable'])( + 'organization commands ignore a %s project link', + async (kind) => { + const link = join(directory, 'polymorfa.json') + if (kind === 'malformed') await writeFile(link, '{invalid json') + else await mkdir(link) + server = await startHttpServer({body: {data: []}}) + + const result = await withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_CONFIG_DIR: join(directory, 'config'), + POLYMORFA_PROJECT_TOKEN: undefined, + }, + () => runCommand('key list --json --verbose'), + ) + + expect(result.error, result.stderr).toBeUndefined() + expect(JSON.parse(result.stdout)).toMatchObject({data: []}) + expect(result.stderr).not.toContain('Using project') + expect( + server.requests.map(({method, url}) => `${method} ${url}`), + ).toEqual(['GET /platform/keys']) + }, + ) + + test('organization developer commands ignore a malformed project link', async () => { + await writeFile(join(directory, 'polymorfa.json'), '{invalid json') + server = await startHttpServer({body: {data: []}}) + + const result = await withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_CONFIG_DIR: join(directory, 'config'), + POLYMORFA_PROJECT_TOKEN: undefined, + }, + () => runCommand('webhook list --owner organization --json --verbose'), + ) + + expect(result.error, result.stderr).toBeUndefined() + expect(JSON.parse(result.stdout)).toMatchObject({data: []}) + expect(result.stderr).not.toContain('Using project') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/webhooks?limit=25', + ]) + }) }) diff --git a/test/developer/client.test.ts b/test/developer/client.test.ts index 2fd5f7f8..6abdfa6c 100644 --- a/test/developer/client.test.ts +++ b/test/developer/client.test.ts @@ -1,10 +1,33 @@ import {Client} from '@polymorfa/sdk' -import {describe, expect, test} from 'vitest' +import {describe, expect, test, vi} from 'vitest' import {createDeveloperSession} from '../../src/developer/client.js' import {withEnvironment} from '../helpers/http-server.js' describe('createDeveloperSession', () => { + test('ignores a broken project link for organization-owned resources', async () => { + const loadLinkedProject = vi.fn(() => + Promise.reject(new Error('Broken project link')), + ) + const session = await withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + }, + () => + createDeveloperSession( + {owner: 'organization'}, + undefined, + loadLinkedProject, + ), + ) + + expect(session.context.owner).toBe('organization') + expect(loadLinkedProject).not.toHaveBeenCalled() + }) + test('creates an immutable project view from an organization key', async () => { const session = await withEnvironment( { diff --git a/test/services/projects.test.ts b/test/services/projects.test.ts index c0a824df..b27158da 100644 --- a/test/services/projects.test.ts +++ b/test/services/projects.test.ts @@ -269,6 +269,31 @@ describe('project context', () => { expect(environment.context.projectId).toBeUndefined() }) + test('organization-owned profiles do not read a project link', async () => { + const loadLinkedProject = vi.fn(() => + Promise.reject(new Error('Broken project link')), + ) + const environment = await resolveDeveloperEnvironment( + {env: {}, loadLinkedProject}, + { + load: () => + Promise.resolve({ + ...config, + profiles: { + ...config.profiles, + work: {...config.profiles.work!, owner: 'organization'}, + }, + }), + save: () => Promise.resolve(), + }, + credentials, + lookup, + ) + + expect(environment.context.owner).toBe('organization') + expect(loadLinkedProject).not.toHaveBeenCalled() + }) + test('a project-token profile fails closed on a different linked project', async () => { await expect( resolveDeveloperEnvironment( From 651e16c168c1b8c418606c83d2461ed505dc654a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 17:05:20 +0200 Subject: [PATCH 18/29] Describe asynchronous QuickLink cancellation in CLI --- README.md | 7 ++++-- docs/command-research/quicklinks.md | 15 +++++++----- src/commands/quicklink/cancel.ts | 6 ++++- test/commands/quicklinks.test.ts | 37 ++++++++++++++++++++++++++++- 4 files changed, 55 insertions(+), 10 deletions(-) diff --git a/README.md b/README.md index e25f91dc..7e6b8b2b 100644 --- a/README.md +++ b/README.md @@ -148,8 +148,11 @@ polymorfa quicklink cancel ql_123 \ Creation prints the hosted URL. Treat it as a credential and share it only with the person holding the phone. The CLI never retrieves direct session QR payloads -or pairing codes. Cancel requires `--yes`, removes the pending session, and -fails for a link that has already connected. The API does not accept an +or pairing codes. Cancel requires `--yes` and invalidates an unconnected link. +For an initial real link, the API returns `202` after accepting cancellation; +Number and session cleanup can continue afterward. Supplementary and test +links return `200` when cancellation completes. Cancellation fails for a link +that has already connected. The API does not accept an idempotency key for cancellation. The API exposes no QuickLink list, recovery, or history operation, so the CLI does not synthesize them. diff --git a/docs/command-research/quicklinks.md b/docs/command-research/quicklinks.md index a18e9c83..6afffae7 100644 --- a/docs/command-research/quicklinks.md +++ b/docs/command-research/quicklinks.md @@ -15,11 +15,11 @@ on the hosted page and preserves entitlement boundaries around direct pairing. ## Command tree -| Command | Typed SDK operation | Route | Effect | -| ------------------ | ------------------------------------- | ----------------------------------- | ---------------------------------------------- | -| `quicklink create` | `MessagingClient.quickLinks.create` | `POST /messaging/quicklinks` | Create a persistent hosted onboarding URL | -| `quicklink view` | `MessagingClient.quickLinks.retrieve` | `GET /messaging/quicklinks/{id}` | Read one link's status and onboarding progress | -| `quicklink cancel` | `MessagingClient.quickLinks.cancel` | `DELETE /messaging/quicklinks/{id}` | Invalidate an unconnected link and its session | +| Command | Typed SDK operation | Route | Effect | +| ------------------ | ------------------------------------- | ----------------------------------- | -------------------------------------------------------------------------------------------- | +| `quicklink create` | `MessagingClient.quickLinks.create` | `POST /messaging/quicklinks` | Create a persistent hosted onboarding URL | +| `quicklink view` | `MessagingClient.quickLinks.retrieve` | `GET /messaging/quicklinks/{id}` | Read one link's status and onboarding progress | +| `quicklink cancel` | `MessagingClient.quickLinks.cancel` | `DELETE /messaging/quicklinks/{id}` | Invalidate an unconnected link; initial Number/session cleanup can continue after acceptance | All three routes require `quicklink:manage`. The API accepts organization keys and exact-project tokens and scopes project tokens to their own project. @@ -35,7 +35,10 @@ exact-project token and require explicit project context. Browser client tokens fail before transport. Cancellation requires TTY confirmation or `--yes` before a client is constructed. The route does not read an `Idempotency-Key`, so the command offers no idempotency flag. The API rejects cancellation of a connected -link and returns 409 while simulated test setup is still starting. +link and returns 409 while simulated test setup is still starting. An initial +real link returns `202` after durable cancellation admission and link +invalidation; Number and session cleanup can continue afterward. Supplementary +and test links return `200`. The returned URL is credential material. Human and explicit JSON output return it because it is the purpose of creation; verbose diagnostics, profiles, and diff --git a/src/commands/quicklink/cancel.ts b/src/commands/quicklink/cancel.ts index a8610c87..6008e7d5 100644 --- a/src/commands/quicklink/cancel.ts +++ b/src/commands/quicklink/cancel.ts @@ -23,6 +23,10 @@ export default class QuickLinkCancel extends BaseCommand { }) const {client} = await this.quickLinksClient(flags) const response = await client.quickLinks.cancel(args.quicklink) - this.writeValue(response, `Cancelled QuickLink ${args.quicklink}.`, flags) + const message = + response.metadata.status === 202 + ? `QuickLink cancellation requested for ${args.quicklink}. The link is invalidated; cleanup may continue.` + : `Cancelled QuickLink ${args.quicklink}.` + this.writeValue(response, message, flags) } } diff --git a/test/commands/quicklinks.test.ts b/test/commands/quicklinks.test.ts index a93fb3aa..d47e77d8 100644 --- a/test/commands/quicklinks.test.ts +++ b/test/commands/quicklinks.test.ts @@ -118,7 +118,10 @@ test('retrieves QuickLink status with an exact-project token and encoded ID', as test('cancels a QuickLink only after confirmation', async () => { const server = await startHttpSequenceServer([ - {body: {message: 'QuickLink cancelled', success: true}}, + { + status: 202, + body: {message: 'QuickLink cancellation requested', success: true}, + }, ]) try { await withEnvironment( @@ -146,6 +149,17 @@ test('cancels a QuickLink only after confirmation', async () => { `quicklink cancel ql_123 --project ${projectId} --yes --json`, ) expect(cancelled.error, cancelled.stderr).toBeUndefined() + expect(JSON.parse(cancelled.stdout)).toMatchObject({ + data: {message: 'QuickLink cancellation requested', success: true}, + metadata: {status: 202}, + }) + + const requested = await runCommand( + `quicklink cancel ql_123 --project ${projectId} --yes`, + ) + expect(requested.error, requested.stderr).toBeUndefined() + expect(requested.stdout).toContain('QuickLink cancellation requested') + expect(requested.stdout).toContain('cleanup may continue') }, ) expect(server.requests[0]).toMatchObject({ @@ -158,6 +172,27 @@ test('cancels a QuickLink only after confirmation', async () => { } }) +test('reports completed QuickLink cancellation after a synchronous response', async () => { + const server = await startHttpSequenceServer([ + {status: 200, body: {message: 'QuickLink cancelled', success: true}}, + ]) + try { + const result = await withEnvironment( + { + POLYMORFA_API_KEY: apiKey, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + }, + () => runCommand(`quicklink cancel ql_123 --project ${projectId} --yes`), + ) + expect(result.error, result.stderr).toBeUndefined() + expect(result.stdout).toContain('Cancelled QuickLink ql_123.') + } finally { + await server.close() + } +}) + function exitCode(error: unknown): number | undefined { return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit } From f3bd128f035563a14c6b2f4c112f8a3d9d199a7e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 17:16:56 +0200 Subject: [PATCH 19/29] Ignore project links for team Hybrid routing policies --- README.md | 3 +- src/base-command.ts | 14 +++++++-- src/commands/routing-policy/get.ts | 1 + src/commands/routing-policy/set.ts | 1 + test/commands/hybrid-link.test.ts | 50 +++++++++++++++++++++++++++++- 5 files changed, 64 insertions(+), 5 deletions(-) diff --git a/README.md b/README.md index 7e6b8b2b..3597b50c 100644 --- a/README.md +++ b/README.md @@ -1725,7 +1725,8 @@ polymorfa hybrid-link resume --session NUMBER --expected-revision REVISION For project and session routing scopes, `--project`, the nearest linked project, and the selected profile determine the project in that order. A project-token -profile fails when its project differs from the linked directory. `quicklink +profile fails when its project differs from the linked directory. Team routing +scope uses an organization API key and ignores directory project links. `quicklink availability` shows existing connection status and any QuickLink that can be resumed. diff --git a/src/base-command.ts b/src/base-command.ts index 3e88d3eb..02001805 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -132,11 +132,15 @@ export abstract class BaseCommand extends Command { protected async messagingClient( flags: GlobalFlags, options: { + readonly ignoreProjectLink?: boolean readonly serverApiKeyOnly?: string readonly serverCredentialOnly?: string } = {}, ): Promise { - const environment = await this.messagingEnvironment(flags) + const environment = await this.messagingEnvironment( + flags, + options.ignoreProjectLink, + ) if ( options.serverApiKeyOnly !== undefined && environment.credential.type !== 'apiKey' @@ -214,8 +218,9 @@ export abstract class BaseCommand extends Command { /** Resolve Messaging credentials once, for commands that also open sockets. */ protected async messagingEnvironment( flags: GlobalFlags, + ignoreProjectLink = false, ): Promise { - return this.resolveEnvironment(flags, 'messaging') + return this.resolveEnvironment(flags, 'messaging', ignoreProjectLink) } protected async platformClient( @@ -254,9 +259,12 @@ export abstract class BaseCommand extends Command { private async resolveEnvironment( flags: GlobalFlags, surface: ResolvedEnvironment['surface'], + ignoreProjectLink = false, ): Promise { const linkedProject = - surface === 'messaging' ? await this.linkedProject(flags) : undefined + surface === 'messaging' && !ignoreProjectLink + ? await this.linkedProject(flags) + : undefined const environment = await resolveCommandEnvironment({ env: process.env, surface, diff --git a/src/commands/routing-policy/get.ts b/src/commands/routing-policy/get.ts index f70a0d7f..be9ebdcf 100644 --- a/src/commands/routing-policy/get.ts +++ b/src/commands/routing-policy/get.ts @@ -13,6 +13,7 @@ export default class RoutingPolicyGet extends BaseCommand { flags.scope === 'team' ? { client: await this.messagingClient(flags, { + ignoreProjectLink: true, serverApiKeyOnly: 'Team Hybrid routing', }), projectId: undefined, diff --git a/src/commands/routing-policy/set.ts b/src/commands/routing-policy/set.ts index 0ccc2b2a..bd3dd4db 100644 --- a/src/commands/routing-policy/set.ts +++ b/src/commands/routing-policy/set.ts @@ -61,6 +61,7 @@ export default class RoutingPolicySet extends BaseCommand { flags.scope === 'team' ? { client: await this.messagingClient(flags, { + ignoreProjectLink: true, serverApiKeyOnly: 'Team Hybrid routing', }), projectId: undefined, diff --git a/test/commands/hybrid-link.test.ts b/test/commands/hybrid-link.test.ts index 5d8d5469..71f0dfce 100644 --- a/test/commands/hybrid-link.test.ts +++ b/test/commands/hybrid-link.test.ts @@ -1,5 +1,5 @@ import {runCommand} from '@oclif/test' -import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises' import {homedir} from 'node:os' import {join} from 'node:path' import {expect, test} from 'vitest' @@ -96,6 +96,54 @@ test('routing policy uses linked project and resolves an explicit slug', async ( } }) +test.each(['malformed', 'unreadable'])( + 'team routing policies ignore a %s project link', + async (kind) => { + const policy = { + scope: 'team', + revision: 'team-1', + prefer: null, + allowedTransports: ['linked_devices'], + } + const server = await startHttpSequenceServer([ + {body: {success: true, data: policy}}, + {body: {success: true, data: {...policy, revision: 'team-2'}}}, + ]) + const directory = await mkdtemp( + join(homedir(), '.polymorfa-cli-team-policy-'), + ) + const previousDirectory = process.cwd() + try { + const link = join(directory, 'polymorfa.json') + if (kind === 'malformed') await writeFile(link, '{invalid json') + else await mkdir(link) + process.chdir(directory) + await withEnvironment(env(server.baseUrl), async () => { + const get = await runCommand( + 'routing-policy get --scope team --verbose', + ) + expect(get.error, get.stderr).toBeUndefined() + expect(get.stderr).not.toContain('Using project') + const set = await runCommand( + 'routing-policy set --scope team --expected-revision team-1 --prefer inherit --allow linked_devices --verbose', + ) + expect(set.error, set.stderr).toBeUndefined() + expect(set.stderr).not.toContain('Using project') + }) + expect( + server.requests.map(({method, url}) => `${method} ${url}`), + ).toEqual([ + 'GET /messaging/routing/hybrid?scope=team', + 'PUT /messaging/routing/hybrid?scope=team', + ]) + } finally { + process.chdir(previousDirectory) + await rm(directory, {recursive: true, force: true}) + await server.close() + } + }, +) + test('flag-only QuickLink creation uses the linked project', async () => { const server = await startHttpSequenceServer([ { From cae094193a77a7adc24df4e7ea481174ab02cc0e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 17:24:27 +0200 Subject: [PATCH 20/29] Pin CLI Hybrid verification to final SDK source --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3932e296..c008eac3 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v6 with: repository: polymorfa/sdks - ref: 33fa3d619c12b2dbf991af42a18b1b12cad7a39d + ref: 424c47ea5e38808487f148e35f4004957153e67b path: .sdk-source - run: npm ci working-directory: .sdk-source From 333828b0e5ad1ea51d07855cd17aa88bc5eb73f3 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 18:57:17 +0200 Subject: [PATCH 21/29] fix(cli): defer project link reads and honor QuickLink input slugs --- .github/workflows/ci.yml | 2 +- README.md | 2 +- src/base-command.ts | 8 ++--- src/commands/quicklink/create.ts | 8 +++-- src/context/environment.ts | 13 ++++---- test/commands/hybrid-link.test.ts | 55 +++++++++++++++++++++++++++++++ test/context/environment.test.ts | 22 +++++++++++++ 7 files changed, 94 insertions(+), 16 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 8dbf8cb0..7b229170 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v6 with: repository: polymorfa/sdks - ref: 5f6044eec78eda42cf22c9a3d2ba23bf48bd4931 + ref: 8607a06bdb3ab2517dfed23cd7dd9780a07fed4f path: .sdk-source - run: npm ci working-directory: .sdk-source diff --git a/README.md b/README.md index 9492a6c8..1eefec7f 100644 --- a/README.md +++ b/README.md @@ -1945,7 +1945,7 @@ Fix the access or routing cause before creating a new operation with a new idempotency key. The rejected operation itself cannot be resumed. Policy writes require the revision returned by `routing-policy get` (`default` -for an unsaved policy). Pause/resume requires the UUID returned by +for an unsaved policy). Pause/resume requires the revision returned by `hybrid-link state`. A stale write fails without replacing later changes. `--prefer inherit` sends no local preference. `--deny-all` disallows both transports at the selected scope. Repeating `--allow` permits both transports within the restrictions inherited from the team and project. diff --git a/src/base-command.ts b/src/base-command.ts index 91c1c866..16567413 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -282,15 +282,13 @@ export abstract class BaseCommand extends Command { readonly readProjectLink?: boolean } = {}, ): Promise { - const linkedProject = - surface === 'messaging' && options.readProjectLink === true - ? await this.linkedProject(flags) - : undefined const environment = await resolveCommandEnvironment({ env: process.env, surface, ...(options.allowProjectToken === true ? {allowProjectToken: true} : {}), - ...(linkedProject === undefined ? {} : {linkedProject}), + ...(surface === 'messaging' && options.readProjectLink === true + ? {loadLinkedProject: () => this.linkedProject(flags)} + : {}), ...(flags.project === undefined ? {} : {project: flags.project}), ...(flags.profile === undefined ? {} : {profile: flags.profile}), ...(flags['api-version'] === undefined diff --git a/src/commands/quicklink/create.ts b/src/commands/quicklink/create.ts index 2cfef519..345efe7e 100644 --- a/src/commands/quicklink/create.ts +++ b/src/commands/quicklink/create.ts @@ -60,12 +60,14 @@ export default class QuickLinkCreate extends BaseCommand { 'input', ) const linkedProject = await this.linkedProject(flags) - const selectedFlags = + const useInputProject = flags.project === undefined && linkedProject === undefined && typeof body.projectId === 'string' - ? {...flags, project: body.projectId} - : flags + const selectedFlags = useInputProject + ? {...flags, project: body.projectId as string} + : flags + if (useInputProject) delete body.projectId const {client, projectId} = await this.quickLinksClient(selectedFlags) const result = await createQuickLink(client, body, projectId) this.writeValue(result, result.data.url, flags) diff --git a/src/context/environment.ts b/src/context/environment.ts index ddc79ed4..36481dc0 100644 --- a/src/context/environment.ts +++ b/src/context/environment.ts @@ -36,6 +36,7 @@ export interface ResolveEnvironmentInput { export interface ResolveCommandEnvironmentInput extends ResolveEnvironmentInput { readonly linkedProject?: string | undefined + readonly loadLinkedProject?: (() => Promise) | undefined readonly profile?: string | undefined readonly project?: string | undefined } @@ -478,14 +479,14 @@ export async function resolveCommandEnvironment( ) } - if ( + const linkedProject = profile.credential.kind === 'projectToken' && - nonEmpty(input.project) === undefined && - nonEmpty(input.linkedProject) !== undefined && - profile.project !== nonEmpty(input.linkedProject) - ) { + nonEmpty(input.project) === undefined + ? nonEmpty(input.linkedProject ?? (await input.loadLinkedProject?.())) + : undefined + if (linkedProject !== undefined && profile.project !== linkedProject) { throw new PolymorfaConfigurationError( - `This directory is linked to project ${input.linkedProject}, but project-token profile ${selected} is bound to ${profile.project ?? 'another project'}. Pass --project or run polymorfa link again.`, + `This directory is linked to project ${linkedProject}, but project-token profile ${selected} is bound to ${profile.project ?? 'another project'}. Pass --project or run polymorfa link again.`, 'project', ) } diff --git a/test/commands/hybrid-link.test.ts b/test/commands/hybrid-link.test.ts index 71f0dfce..37fc4a14 100644 --- a/test/commands/hybrid-link.test.ts +++ b/test/commands/hybrid-link.test.ts @@ -185,6 +185,61 @@ test('flag-only QuickLink creation uses the linked project', async () => { await server.close() } }) +test('QuickLink creation accepts a project slug from the input file', async () => { + const server = await startHttpSequenceServer([ + { + body: { + success: true, + data: [ + { + _creationTime: 0, + _id: project, + name: 'App', + orgId: 'org_1', + slug: 'app', + stage: 'development', + }, + ], + }, + }, + { + body: { + success: true, + data: { + id: 'ql-1', + session: 'existing', + url: 'https://example.test/ql-1', + expiresAt: null, + }, + }, + }, + ]) + const directory = await mkdtemp(join(homedir(), '.polymorfa-cli-quicklink-')) + const input = join(directory, 'quicklink.json') + try { + await writeFile( + input, + JSON.stringify({projectId: 'app', purpose: 'initial'}), + ) + await withEnvironment(env(server.baseUrl), async () => { + const result = await runCommand( + `quicklink create --input ${input} --json`, + ) + expect(result.error, result.stderr).toBeUndefined() + }) + expect(server.requests.map(({url}) => url)).toEqual([ + '/platform/projects', + '/messaging/quicklinks', + ]) + expect(JSON.parse(server.requests[1]!.body)).toMatchObject({ + projectId: project, + purpose: 'initial', + }) + } finally { + await rm(directory, {recursive: true, force: true}) + await server.close() + } +}) test('availability output names existing connections and resumable QuickLink', async () => { const server = await startHttpSequenceServer([ { diff --git a/test/context/environment.test.ts b/test/context/environment.test.ts index 8a657e04..3c9d48cf 100644 --- a/test/context/environment.test.ts +++ b/test/context/environment.test.ts @@ -344,12 +344,16 @@ describe('resolveEnvironment', () => { test('uses an environment project token before the active API-key profile', async () => { const credentials = fakeCredentials('unused') + const loadLinkedProject = vi.fn(() => + Promise.reject(new Error('invalid link')), + ) await expect( resolveCommandEnvironment( { env: {POLYMORFA_PROJECT_TOKEN: PROJECT_TOKEN}, surface: 'messaging', allowProjectToken: true, + loadLinkedProject, }, fakeProfiles(), credentials, @@ -359,6 +363,24 @@ describe('resolveEnvironment', () => { source: 'environment', }) expect(credentials.get).not.toHaveBeenCalled() + expect(loadLinkedProject).not.toHaveBeenCalled() + }) + + test('loads a link only to check a project-token profile binding', async () => { + const loadLinkedProject = vi.fn(() => Promise.resolve(OTHER_PROJECT)) + await expect( + resolveCommandEnvironment( + { + env: {}, + surface: 'messaging', + allowProjectToken: true, + loadLinkedProject, + }, + projectProfiles(), + fakeCredentials(PROJECT_TOKEN), + ), + ).rejects.toThrow('This directory is linked to project') + expect(loadLinkedProject).toHaveBeenCalledOnce() }) test.each([ From f89bf219e614fb8cc83552df4d48eee965112156 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 19:03:17 +0200 Subject: [PATCH 22/29] chore(ci): pin corrected Hybrid SDK source --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b229170..83f2a0cc 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v6 with: repository: polymorfa/sdks - ref: 8607a06bdb3ab2517dfed23cd7dd9780a07fed4f + ref: 98e4d97aebfd3ed9021e11c56147e595aba3b3c8 path: .sdk-source - run: npm ci working-directory: .sdk-source From e19a96a1625fc498559fda3c5a1fad2c46d6b685 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 19:13:57 +0200 Subject: [PATCH 23/29] chore(ci): pin indexed event SDK source --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 83f2a0cc..6ed1d205 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v6 with: repository: polymorfa/sdks - ref: 98e4d97aebfd3ed9021e11c56147e595aba3b3c8 + ref: a91afb12df87ae7b384bef3ef25267893e002658 path: .sdk-source - run: npm ci working-directory: .sdk-source From c4a5ac8b88dcc0a96173be964ab7708636f05e38 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 19:22:47 +0200 Subject: [PATCH 24/29] chore(ci): pin final Hybrid SDK preflight source --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6ed1d205..f383bec6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -31,7 +31,7 @@ jobs: - uses: actions/checkout@v6 with: repository: polymorfa/sdks - ref: a91afb12df87ae7b384bef3ef25267893e002658 + ref: 029e0e742692e20a3f88e957b66831fb5e94038b path: .sdk-source - run: npm ci working-directory: .sdk-source From 55975a3531f1f7b1de3b58d2fbdefda3e082ba9a Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 19:48:09 +0200 Subject: [PATCH 25/29] fix(cli): accept project tokens for raw Messaging requests --- .github/workflows/ci.yml | 3 +++ src/commands/api.ts | 1 + src/context/environment.ts | 4 +++- test/commands/api.test.ts | 35 ++++++++++++++++++++++++++++++++ test/context/environment.test.ts | 10 +++++++++ 5 files changed, 52 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f383bec6..74c06427 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -21,6 +21,8 @@ jobs: - 22 steps: - uses: actions/checkout@v6 + with: + persist-credentials: false - uses: actions/setup-node@v6 with: node-version: ${{ matrix.node-version }} @@ -30,6 +32,7 @@ jobs: - run: npm ci --ignore-scripts - uses: actions/checkout@v6 with: + persist-credentials: false repository: polymorfa/sdks ref: 029e0e742692e20a3f88e957b66831fb5e94038b path: .sdk-source diff --git a/src/commands/api.ts b/src/commands/api.ts index c5763f03..c9431af8 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -123,6 +123,7 @@ export default class Api extends BaseCommand { const environment = await resolveCommandEnvironment({ env: process.env, surface: flags.surface, + allowProjectToken: flags.surface === 'messaging', ...(flags.profile === undefined ? {} : {profile: flags.profile}), ...(flags['api-version'] === undefined ? {} diff --git a/src/context/environment.ts b/src/context/environment.ts index 36481dc0..0f64c622 100644 --- a/src/context/environment.ts +++ b/src/context/environment.ts @@ -463,7 +463,9 @@ export async function resolveCommandEnvironment( ) ) { throw new PolymorfaConfigurationError( - `Profile ${selected} contains a project token; this Platform command requires an organization API key.`, + input.surface === 'platform' + ? `Profile ${selected} contains a project token; this Platform command requires an organization API key.` + : `Profile ${selected} contains a project token; this Messaging command requires an organization API key or a Messaging client token.`, 'credential', ) } diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 2dff96dc..4723921c 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -14,6 +14,41 @@ describe('api', () => { let server: TestHttpServer | undefined const tempDirectories: string[] = [] + test('sends raw Messaging requests with a project token', async () => { + server = await startHttpServer({body: {success: true, data: {ready: true}}}) + const projectToken = `pmfa_pt_${'A'.repeat(94)}` + const result = await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: projectToken, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => runCommand('api /messaging/health --surface messaging --json'), + ) + expect(result.error, result.stderr).toBeUndefined() + expect(server.requests).toHaveLength(1) + expect(server.requests[0]?.headers.authorization).toBe( + `Bearer ${projectToken}`, + ) + }) + + test('rejects ambiguous raw Messaging credentials before transport', async () => { + server = await startHttpServer({body: {success: true}}) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: `pmfa_${'A'.repeat(72)}`, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: `pmfa_pt_${'A'.repeat(94)}`, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => runCommand('api /messaging/health --surface messaging --json'), + ) + expect(result.error).toBeDefined() + expect(result.stderr).toContain('Messaging credentials are ambiguous') + expect(server.requests).toHaveLength(0) + }) + test('preserves fractional prepaid balances in JSON output', async () => { server = await startHttpServer({ body: {data: {balanceCents: 17.500001, preferredCurrency: 'USD'}}, diff --git a/test/context/environment.test.ts b/test/context/environment.test.ts index 3c9d48cf..0d6a8518 100644 --- a/test/context/environment.test.ts +++ b/test/context/environment.test.ts @@ -455,6 +455,16 @@ describe('resolveEnvironment', () => { expect(credentials.get).not.toHaveBeenCalled() }) + test('names the Messaging credential boundary for a project-token profile', async () => { + await expect( + resolveCommandEnvironment( + {env: {}, surface: 'messaging'}, + projectProfiles(), + fakeCredentials(PROJECT_TOKEN), + ), + ).rejects.toThrow('this Messaging command requires') + }) + test('prefers an environment credential over the active profile', async () => { const credentials = fakeCredentials( 'pmfa_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', From 3fbd7442771a18074cdb7b28a9551df40f685e25 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 19:55:06 +0200 Subject: [PATCH 26/29] chore(ci): pin reviewed Hybrid SDK source --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74c06427..6121e124 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: with: persist-credentials: false repository: polymorfa/sdks - ref: 029e0e742692e20a3f88e957b66831fb5e94038b + ref: 42d389710ac5a8102d9554ae87eef489454fc8e1 path: .sdk-source - run: npm ci working-directory: .sdk-source From 9e5afc34d360e3a41735ffd587ed0ae7bd37fc23 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 20:02:49 +0200 Subject: [PATCH 27/29] chore(ci): pin Hybrid safety SDK source --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6121e124..1ea547f5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,7 +34,7 @@ jobs: with: persist-credentials: false repository: polymorfa/sdks - ref: 42d389710ac5a8102d9554ae87eef489454fc8e1 + ref: 705f76eb82035a8ae752546d3a8d6c8b5f4156b3 path: .sdk-source - run: npm ci working-directory: .sdk-source From 10419a361c00f90c17edd3bda4f24489f55e0435 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 20:14:59 +0200 Subject: [PATCH 28/29] fix(cli): validate raw project-token profile context --- README.md | 4 +- src/commands/api.ts | 6 +++ test/commands/api.test.ts | 96 +++++++++++++++++++++++++++++++++++++-- 3 files changed, 102 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 1eefec7f..69c4d7ce 100644 --- a/README.md +++ b/README.md @@ -1726,7 +1726,9 @@ Messaging accepts exactly one of `POLYMORFA_API_KEY`, `POLYMORFA_PROJECT_TOKEN`, or `POLYMORFA_CLIENT_TOKEN`. Project tokens remain confined to their project; individual commands enforce their credential requirements. Raw Platform requests accept only `POLYMORFA_API_KEY`. Supplying multiple Messaging credentials fails -before a request is sent. +before a request is sent. For a project-token profile, `--project` or the +nearest `polymorfa.json` must match the profile's project. Raw requests with an +API key or client token do not read the directory link. Examples: diff --git a/src/commands/api.ts b/src/commands/api.ts index c9431af8..94297d7f 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -124,6 +124,12 @@ export default class Api extends BaseCommand { env: process.env, surface: flags.surface, allowProjectToken: flags.surface === 'messaging', + ...(flags.surface === 'messaging' + ? { + project: flags.project, + loadLinkedProject: () => this.linkedProject(flags), + } + : {}), ...(flags.profile === undefined ? {} : {profile: flags.profile}), ...(flags['api-version'] === undefined ? {} diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 4723921c..a0237750 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -1,8 +1,9 @@ import {runCommand} from '@oclif/test' -import {mkdtemp, rm, writeFile} from 'node:fs/promises' -import {tmpdir} from 'node:os' +import {AsyncEntry} from '@napi-rs/keyring' +import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises' +import {homedir, tmpdir} from 'node:os' import {join} from 'node:path' -import {afterEach, describe, expect, test} from 'vitest' +import {afterEach, describe, expect, test, vi} from 'vitest' import { startHttpServer, @@ -49,6 +50,95 @@ describe('api', () => { expect(server.requests).toHaveLength(0) }) + test('raw Messaging rejects a project-token profile in a different project context', async () => { + server = await startHttpServer({body: {success: true}}) + const directory = await mkdtemp(join(homedir(), '.polymorfa-cli-raw-')) + const configDirectory = join(directory, 'config') + const previousDirectory = process.cwd() + const keyring = vi + .spyOn(AsyncEntry.prototype, 'getPassword') + .mockResolvedValue(`pmfa_pt_${'A'.repeat(94)}`) + tempDirectories.push(directory) + try { + await mkdir(configDirectory) + await writeFile( + join(configDirectory, 'config.json'), + JSON.stringify({ + version: 2, + activeProfile: 'project', + profiles: { + project: { + credential: { + kind: 'projectToken', + account: 'profile:project:projectToken', + }, + owner: 'project', + project: '11111111-1111-4111-8111-111111111111', + }, + }, + }), + ) + await writeFile( + join(directory, 'polymorfa.json'), + JSON.stringify({ + project: '22222222-2222-4222-8222-222222222222', + version: 1, + }), + ) + process.chdir(directory) + await withEnvironment( + { + POLYMORFA_CONFIG_DIR: configDirectory, + POLYMORFA_API_KEY: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + }, + async () => { + const explicit = await runCommand( + 'api /messaging/quicklinks --surface messaging --project 22222222-2222-4222-8222-222222222222 -X POST --json', + ) + expect(explicit.error).toBeDefined() + expect(explicit.stderr).toContain('different --project') + const linked = await runCommand( + 'api /messaging/quicklinks --surface messaging -X POST --json', + ) + expect(linked.error).toBeDefined() + expect(linked.stderr).toContain('This directory is linked to project') + }, + ) + expect(server.requests).toHaveLength(0) + expect(keyring).not.toHaveBeenCalled() + } finally { + process.chdir(previousDirectory) + keyring.mockRestore() + } + }) + + test('raw Messaging API keys ignore malformed directory links', async () => { + server = await startHttpServer({body: {success: true}}) + const directory = await mkdtemp(join(homedir(), '.polymorfa-cli-raw-')) + const previousDirectory = process.cwd() + tempDirectories.push(directory) + try { + await writeFile(join(directory, 'polymorfa.json'), '{invalid') + process.chdir(directory) + const result = await withEnvironment( + { + POLYMORFA_API_KEY: `pmfa_${'A'.repeat(72)}`, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + }, + () => runCommand('api /messaging/health --surface messaging --json'), + ) + expect(result.error, result.stderr).toBeUndefined() + expect(server.requests).toHaveLength(1) + } finally { + process.chdir(previousDirectory) + } + }) + test('preserves fractional prepaid balances in JSON output', async () => { server = await startHttpServer({ body: {data: {balanceCents: 17.500001, preferredCurrency: 'USD'}}, From 7e412107daf567aa9850b09ad47e7f0aec12bfa1 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 23 Sep 2026 22:29:21 +0200 Subject: [PATCH 29/29] chore(cli): pin published Hybrid SDK prerelease --- .github/workflows/ci.yml | 16 ---------------- package-lock.json | 8 ++++---- package.json | 2 +- 3 files changed, 5 insertions(+), 21 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1ea547f5..144b7c92 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -27,21 +27,5 @@ jobs: with: node-version: ${{ matrix.node-version }} cache: npm - # The Hybrid Link SDK contract is in a companion PR. Verify this PR against - # that immutable source revision until its package is published. - - run: npm ci --ignore-scripts - - uses: actions/checkout@v6 - with: - persist-credentials: false - repository: polymorfa/sdks - ref: 705f76eb82035a8ae752546d3a8d6c8b5f4156b3 - path: .sdk-source - run: npm ci - working-directory: .sdk-source - - run: npm run build -w @polymorfa/sdk - working-directory: .sdk-source - - run: npm pack -w @polymorfa/sdk --pack-destination .. --ignore-scripts - working-directory: .sdk-source - - run: npm install --no-save --package-lock=false --ignore-scripts ./polymorfa-sdk-*.tgz - - run: npm run build - run: npm run verify diff --git a/package-lock.json b/package-lock.json index f1cee5eb..c52dfd2a 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.20260923171224", + "@polymorfa/sdk": "0.1.0-dev.20260923202417", "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.20260923171224", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923171224.tgz", - "integrity": "sha512-0TNIG/kFoNmHXwCPjDbPUERMzW5c8P0j362sUyvX2U9R0W7FtEKGbWVswFGnN4AL8YSjJM9NiPWM7BaX69DJ/w==", + "version": "0.1.0-dev.20260923202417", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923202417.tgz", + "integrity": "sha512-XSaxRvSrYGhfrsdlCZfXUqAYOdDfYpcofUTEYiY0tONwuYJSaeTdSP1PJhPqZ8NYuQUAbEnzBRjiF1WodLo4MQ==", "license": "MIT", "engines": { "node": ">=20" diff --git a/package.json b/package.json index d2c84e2d..1f6bfb2e 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.20260923171224", + "@polymorfa/sdk": "0.1.0-dev.20260923202417", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" },