diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 74f07b00..144b7c92 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 }} 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/README.md b/README.md index 42038085..69c4d7ce 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. @@ -264,6 +267,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 @@ -1715,9 +1722,13 @@ 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. 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: @@ -1890,6 +1901,57 @@ Read `GET /platform/sessions/{sessionId}/tier-quotes/{quoteId}` until the result 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 `d5245f76d5d6bc7b9af439571bd1f157185b1b87`. 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 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 +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 +``` + +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. 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. + +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. +`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 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. + ### Functions Functions are project-owned. Select `--project` or use the linked project/profile context. The commands accept an organization API key or a project token; browser client tokens are refused. Access also depends on the team's enabled Functions release and the configured execution region. 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/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/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" }, diff --git a/src/base-command.ts b/src/base-command.ts index e4e66001..16567413 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -95,7 +95,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 @@ -131,15 +131,35 @@ export abstract class BaseCommand extends Command { protected async messagingClient( flags: GlobalFlags, - options: {readonly serverApiKeyOnly?: string} = {}, + options: { + readonly ignoreProjectLink?: boolean + readonly serverApiKeyOnly?: string + readonly serverCredentialOnly?: string + } = {}, ): Promise { - const environment = await this.messagingEnvironment(flags) + const environment = await this.messagingEnvironment(flags, { + allowProjectToken: + options.serverCredentialOnly !== undefined || + options.serverApiKeyOnly !== undefined, + readProjectLink: + options.serverCredentialOnly !== undefined && + options.ignoreProjectLink !== true, + }) if ( options.serverApiKeyOnly !== undefined && 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', ) } @@ -151,7 +171,9 @@ export abstract class BaseCommand extends Command { flags: GlobalFlags, ): Promise { return createMessagingClient( - await this.resolveEnvironment(flags, 'messaging', true), + await this.resolveEnvironment(flags, 'messaging', { + allowProjectToken: true, + }), ) } @@ -211,8 +233,12 @@ export abstract class BaseCommand extends Command { /** Resolve Messaging credentials once, for commands that also open sockets. */ protected async messagingEnvironment( flags: GlobalFlags, + options: { + readonly allowProjectToken?: boolean + readonly readProjectLink?: boolean + } = {}, ): Promise { - return this.resolveEnvironment(flags, 'messaging') + return this.resolveEnvironment(flags, 'messaging', options) } protected async platformClient( @@ -251,12 +277,19 @@ export abstract class BaseCommand extends Command { private async resolveEnvironment( flags: GlobalFlags, surface: ResolvedEnvironment['surface'], - allowProjectToken = false, + options: { + readonly allowProjectToken?: boolean + readonly readProjectLink?: boolean + } = {}, ): Promise { const environment = await resolveCommandEnvironment({ env: process.env, surface, - ...(allowProjectToken ? {allowProjectToken: true} : {}), + ...(options.allowProjectToken === true ? {allowProjectToken: true} : {}), + ...(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 ? {} @@ -297,6 +330,9 @@ export abstract class BaseCommand extends Command { ? {} : {docUrl: presented.docUrl}), retryable: presented.retryable, + ...(presented.operationId === undefined + ? {} + : {operationId: presented.operationId}), ...(presented.retryAfterSeconds === undefined ? {} : {retryAfterSeconds: presented.retryAfterSeconds}), diff --git a/src/commands/api.ts b/src/commands/api.ts index c5763f03..94297d7f 100644 --- a/src/commands/api.ts +++ b/src/commands/api.ts @@ -123,6 +123,13 @@ export default class Api extends BaseCommand { const environment = await resolveCommandEnvironment({ 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/src/commands/hybrid-link/pause.ts b/src/commands/hybrid-link/pause.ts new file mode 100644 index 00000000..75428161 --- /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, { + serverCredentialOnly: '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..7309b45a --- /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, { + serverCredentialOnly: '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..1afb2ccb --- /dev/null +++ b/src/commands/hybrid-link/state.ts @@ -0,0 +1,31 @@ +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, { + 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'}\t${connections || 'no connections'}`, + flags, + ) + } +} diff --git a/src/commands/message/delete.ts b/src/commands/message/delete.ts index 4fb7cff5..b6d76d73 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, @@ -46,7 +51,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 3c36c198..2d998255 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.', @@ -39,7 +44,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..0ae4613f --- /dev/null +++ b/src/commands/message/operation-status.ts @@ -0,0 +1,44 @@ +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, { + serverCredentialOnly: 'Message operation status', + }) + const response = await client.messages.operationStatus( + flags.session, + 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}` : ''}${guidance}`, + flags, + ) + } +} diff --git a/src/commands/message/react.ts b/src/commands/message/react.ts index ee9c7032..40fed363 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' @@ -13,6 +14,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, @@ -37,6 +39,9 @@ export default class MessageReact extends BaseCommand { conversation: conversationReference(flags.chat), id: args.message, reaction: flags.emoji, + ...(flags.transport === undefined + ? {} + : {transport: messageTransport(flags.transport)}), }, {idempotencyKey: idempotencyKey(flags['idempotency-key'])}, ) diff --git a/src/commands/message/send/text.ts b/src/commands/message/send/text.ts index 9e61a298..958016f3 100644 --- a/src/commands/message/send/text.ts +++ b/src/commands/message/send/text.ts @@ -4,12 +4,14 @@ import type {SendTextMessageRequest} from '@polymorfa/sdk' import {BaseCommand} from '../../../base-command.js' import {idempotencyKey} from '../../../input/idempotency.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, @@ -44,6 +46,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..b38c2150 --- /dev/null +++ b/src/commands/quicklink/availability.ts @@ -0,0 +1,41 @@ +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 + 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.', + `Connections: ${connections || 'none'}`, + ...(data.resumeQuickLinkId + ? [`Resume QuickLink: ${data.resumeQuickLinkId}`] + : []), + ].join('\n'), + flags, + ) + } +} diff --git a/src/commands/quicklink/cancel.ts b/src/commands/quicklink/cancel.ts index 2cf7addb..6008e7d5 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.'}), @@ -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/src/commands/quicklink/create.ts b/src/commands/quicklink/create.ts index b817de45..345efe7e 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,19 +8,68 @@ 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 result = await createQuickLink( - await this.messagingClient(flags, {serverApiKeyOnly: 'QuickLink'}), - await readJsonInput(flags.input), - flags.project, + 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 linkedProject = await this.linkedProject(flags) + const useInputProject = + flags.project === undefined && + linkedProject === undefined && + typeof body.projectId === 'string' + 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/commands/routing-policy/get.ts b/src/commands/routing-policy/get.ts new file mode 100644 index 00000000..be9ebdcf --- /dev/null +++ b/src/commands/routing-policy/get.ts @@ -0,0 +1,31 @@ +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, projectId} = + flags.scope === 'team' + ? { + client: await this.messagingClient(flags, { + ignoreProjectLink: true, + 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( + 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..bd3dd4db --- /dev/null +++ b/src/commands/routing-policy/set.ts @@ -0,0 +1,82 @@ +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, projectId} = + flags.scope === 'team' + ? { + client: await this.messagingClient(flags, { + ignoreProjectLink: true, + 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, + allowedTransports, + }) + this.writeValue( + response, + `Routing policy updated: ${response.data.data.revision}`, + flags, + ) + } +} diff --git a/src/context/environment.ts b/src/context/environment.ts index 657984ec..0f64c622 100644 --- a/src/context/environment.ts +++ b/src/context/environment.ts @@ -35,7 +35,10 @@ export interface ResolveEnvironmentInput { } export interface ResolveCommandEnvironmentInput extends ResolveEnvironmentInput { + readonly linkedProject?: string | undefined + readonly loadLinkedProject?: (() => Promise) | undefined readonly profile?: string | undefined + readonly project?: string | undefined } export interface ListenerEnvironment { @@ -78,6 +81,7 @@ export interface ResolveDeveloperEnvironmentInput { readonly inferOwner?: boolean | undefined /** 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 @@ -116,7 +120,7 @@ export async function resolveDeveloperEnvironment( : undefined) const requestedProject = environmentOwner === 'project' - ? (explicitProject ?? nonEmpty(input.linkedProject)) + ? (explicitProject ?? (await developerLinkedProject(input))) : explicitProject const unresolvedContext = resolveOwnerContext({ principalType: environmentCredential.principalType, @@ -197,7 +201,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' && @@ -249,6 +256,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(), @@ -450,15 +463,40 @@ export async function resolveCommandEnvironment( ) ) { throw new PolymorfaConfigurationError( - `Profile ${selected} contains a project token, which is accepted only by project-owned developer commands and polymorfa listen.`, + 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', ) } - 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 linkedProject = + profile.credential.kind === 'projectToken' && + nonEmpty(input.project) === undefined + ? nonEmpty(input.linkedProject ?? (await input.loadLinkedProject?.())) + : undefined + if (linkedProject !== undefined && profile.project !== linkedProject) { throw new PolymorfaConfigurationError( - `Profile ${selected} has no credential in the operating-system keyring. Run polymorfa auth login --with-api-key --profile ${selected}.`, + `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', + ) + } + + 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 ${profile.credential.kind === 'projectToken' ? '--with-project-token' : '--with-api-key'} --profile ${selected}.`, 'keyring', ) } @@ -468,8 +506,8 @@ export async function resolveCommandEnvironment( apiVersion: input.apiVersion ?? profile.apiVersion, env: { ...(profile.credential.kind === 'projectToken' - ? {POLYMORFA_PROJECT_TOKEN: apiKey} - : {POLYMORFA_API_KEY: apiKey}), + ? {POLYMORFA_PROJECT_TOKEN: storedCredential} + : {POLYMORFA_API_KEY: storedCredential}), POLYMORFA_BASE_URL: nonEmpty(input.env.POLYMORFA_BASE_URL) ?? profile.baseUrl, }, @@ -605,7 +643,9 @@ export function resolveEnvironment( } throw new PolymorfaConfigurationError( - 'Messaging API requests require POLYMORFA_API_KEY or POLYMORFA_CLIENT_TOKEN.', + input.allowProjectToken === true + ? 'Messaging API requests require POLYMORFA_API_KEY, POLYMORFA_PROJECT_TOKEN, or POLYMORFA_CLIENT_TOKEN.' + : 'Messaging API requests require POLYMORFA_API_KEY or POLYMORFA_CLIENT_TOKEN.', 'credential', ) } 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/src/errors/present.ts b/src/errors/present.ts index dce5e0bd..ca78f295 100644 --- a/src/errors/present.ts +++ b/src/errors/present.ts @@ -44,6 +44,7 @@ export interface PresentedError { readonly exitCode: number readonly message: string readonly requestId?: string + readonly operationId?: string /** Console request log for this request, when the API returned one. */ readonly requestLogUrl?: string /** Documentation for the error code. */ @@ -93,12 +94,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}), ...(retryAfterSeconds === undefined ? {} : {retryAfterSeconds}), ...(requestLogUrl === undefined ? {} : {requestLogUrl}), @@ -134,6 +142,9 @@ export const CODE_EXIT_CODES: ReadonlyMap< export function renderErrorDetails(presented: PresentedError): string { return [ `Error: ${presented.message}`, + ...(presented.operationId === undefined + ? [] + : [`Operation ID: ${presented.operationId}`]), ...(presented.requestId === undefined ? [] : [`Request ID: ${presented.requestId}`]), diff --git a/src/input/hybrid-routing.ts b/src/input/hybrid-routing.ts new file mode 100644 index 00000000..80c3043f --- /dev/null +++ b/src/input/hybrid-routing.ts @@ -0,0 +1,39 @@ +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 + session?: string | undefined + }, + projectId?: string, +): HybridRoutingPolicyScope { + if (flags.scope === 'team') return {scope: 'team'} + if (!projectId) + throw new PolymorfaConfigurationError( + 'This scope requires a project context.', + 'project', + ) + if (flags.scope === 'project') return {scope: 'project', projectId} + 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, session: flags.session} +} diff --git a/src/input/message-send.ts b/src/input/message-send.ts index 23b3e165..c30b2c21 100644 --- a/src/input/message-send.ts +++ b/src/input/message-send.ts @@ -1,11 +1,23 @@ 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' import {idempotencyKey} from './idempotency.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.', }), @@ -31,6 +43,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 @@ -40,6 +53,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 @@ -52,6 +68,19 @@ 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', + ) +} + /** Request options for `message send `: always carries an idempotency key, generated when omitted. */ export function sendIdempotencyOptions( key: string | undefined, 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/api.test.ts b/test/commands/api.test.ts index 2dff96dc..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, @@ -14,6 +15,130 @@ 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('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'}}, diff --git a/test/commands/hybrid-credentials.test.ts b/test/commands/hybrid-credentials.test.ts new file mode 100644 index 00000000..1fa6818f --- /dev/null +++ b/test/commands/hybrid-credentials.test.ts @@ -0,0 +1,446 @@ +import {runCommand} from '@oclif/test' +import {AsyncEntry} from '@napi-rs/keyring' +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' +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('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}) => { + 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/commands/hybrid-link.test.ts b/test/commands/hybrid-link.test.ts new file mode 100644 index 00000000..37fc4a14 --- /dev/null +++ b/test/commands/hybrid-link.test.ts @@ -0,0 +1,443 @@ +import {runCommand} from '@oclif/test' +import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises' +import {homedir} from 'node:os' +import {join} from 'node:path' +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('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('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.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([ + { + 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('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([ + { + 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([ + { + 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() + } +}) +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() + } +}) diff --git a/test/commands/message-sends.test.ts b/test/commands/message-sends.test.ts index c829bc85..bf09f49d 100644 --- a/test/commands/message-sends.test.ts +++ b/test/commands/message-sends.test.ts @@ -623,7 +623,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 88d04fd1..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_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', 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/commands/quicklinks.test.ts b/test/commands/quicklinks.test.ts index ab7a8423..d47e77d8 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 { @@ -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 } 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('') }) diff --git a/test/context/environment.test.ts b/test/context/environment.test.ts index f1a8a43e..0d6a8518 100644 --- a/test/context/environment.test.ts +++ b/test/context/environment.test.ts @@ -339,9 +339,130 @@ describe('resolveEnvironment', () => { }, surface: 'messaging', }), - ).toThrow( - 'Messaging credentials are ambiguous; set only POLYMORFA_API_KEY or POLYMORFA_CLIENT_TOKEN.', + ).toThrow('Messaging credentials are ambiguous') + }) + + 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, + ), + ).resolves.toMatchObject({ + credential: {type: 'projectToken', value: PROJECT_TOKEN}, + 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([ + {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', allowProjectToken: true}), + ).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', + allowProjectToken: true, + 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, + allowProjectToken: true, + }, + 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('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 () => { @@ -556,3 +677,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()} +} 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(