diff --git a/README.md b/README.md index 1917dc86..ce0a44e1 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ access until `@polymorfa/cli` is released publicly. ## Requirements - Node.js 20 or newer -- A Polymorfa server API key for Platform requests +- A Polymorfa organization API key or exact-project token for developer requests - A server API key or short-lived client token for Messaging requests ## Development installation @@ -39,7 +39,7 @@ polymorfa/0.1.0-dev.0 sdk/0.1.0-dev.0 ## Authentication profiles Until the backend exposes a revocable CLI device-authorization flow, login -accepts a server API key only through standard input: +accepts an organization API key or exact-project token through standard input: ```shell printf '%s' "$POLYMORFA_API_KEY" | \ @@ -48,9 +48,12 @@ printf '%s' "$POLYMORFA_API_KEY" | \ polymorfa auth list polymorfa auth status --profile work polymorfa auth use work + +printf '%s' "$POLYMORFA_PROJECT_TOKEN" | \ + polymorfa auth login --with-project-token --project project_123 --profile project-work ``` -Profile metadata is written to the platform configuration directory. API keys +Profile metadata records its explicit project or organization owner. Credentials are stored separately in macOS Keychain, Windows Credential Manager, or Linux Secret Service. A keyring failure stops the command; the CLI never silently falls back to plaintext. `auth status` reports local secure-store availability @@ -59,6 +62,41 @@ in this development slice and does not yet claim remote identity validation. Environment credentials take precedence over the active profile for automation. An explicit `--profile ` takes precedence over environment credentials. +## QuickLinks + +A QuickLink is a persistent hosted link that connects one WhatsApp number to a +new session in a project. Create one from a JSON request with an organization +API key that has `quicklink:manage`: + +```shell +polymorfa quicklink create \ + --project 11111111-1111-4111-8111-111111111111 \ + --input quicklink.json +``` + +The request accepts `customerId`, `externalId`, and `configuration`. See +[Guided session creation and configuration](#guided-session-creation-and-configuration) +for the configuration fields. + +Inspect or cancel a link with an organization API key or an exact-project +token that has `quicklink:manage`: + +```shell +polymorfa quicklink view ql_123 \ + --project 11111111-1111-4111-8111-111111111111 + +polymorfa quicklink cancel ql_123 \ + --project 11111111-1111-4111-8111-111111111111 \ + --yes +``` + +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 +idempotency key for cancellation. The API exposes no QuickLink list, recovery, +or history operation, so the CLI does not synthesize them. + ## Customers Manage Customers through the Platform API with an organization server key and @@ -505,8 +543,8 @@ support for project credentials, client tokens, or Cloud API sessions. ## Platform access and security Organization server API keys can inspect the credential, membership, audit, -ban, incident, durable-operation, and project-token metadata made available by -the Platform API: +ban, incident, saved QuickLink settings, and project-token metadata made +available by the Platform API: ```shell polymorfa key list @@ -546,8 +584,8 @@ returns `null` in JSON when no settings row has been saved. Update accepts one bounded JSON object from `--input`; the target project is selected separately with `--project` and cannot be hidden in that input. Omitting the project targets organization defaults. Widget logo fields are existing storage -identifiers, not file, URL, base64, upload, or download operations. Both -commands require the `quicklink:manage` scope. +identifiers, not file, URL, base64, upload, or download operations. Widget +access requires the `quicklink:manage` scope. Settings accept `successCallbackUrl` and `failureCallbackUrl`: project-only HTTPS destinations that the API copies into each link when it is issued. Set @@ -609,6 +647,122 @@ The source exposes no campaign update, delete, validate, archive, duplicate, recipient, event, history, replay, watch, or operation-cancel route. The CLI does not substitute similarly named Platform campaign methods for these gaps. +## Developer events and webhooks + +Developer resources always have an explicit owner. Project-owned commands need +`--owner project --project `. Organization-owned commands use +`--owner organization` and reject a project override. A stored profile can +provide the owner, but the CLI never guesses it from a credential or retries a +request against a different owner. + +```shell +polymorfa event list \ + --owner project \ + --project project_123 \ + --type message.received + +polymorfa event view event_123 \ + --owner project \ + --project project_123 \ + --include-payload + +polymorfa webhook create \ + --owner project \ + --project project_123 \ + --input webhook.json \ + --secret-out ./webhook.secret + +polymorfa webhook test webhook_123 \ + --owner project \ + --project project_123 \ + --event polymorfa.test + +polymorfa webhook delivery list \ + --owner project \ + --project project_123 \ + --status failed + +polymorfa webhook delivery attempt list delivery_123 \ + --owner project \ + --project project_123 +``` + +Webhook creation and secret rotation return a signing secret once. JSON and +noninteractive runs require `--secret-out`; the CLI preflights a new path and +writes it atomically with mode `0600`. Interactive runs can reveal the secret +once on the terminal. The secret never appears in JSON, MCP output, debug +messages, profiles, or command arguments. A server success followed by a local +write failure exits with `partial_success` and retains the safe mutation +receipt. It never retries rotation on its own. + +List commands return opaque cursors. `--paginate` requires `--max-items`, which +is capped at 10,000. The CLI shrinks the last request to the remaining item +budget instead of fetching records it will discard. + +Replay, delivery retry, and webhook test return an idempotency receipt and an +operation ID in the JSON `operation.id` field. Operation inspection is +console-only, so the CLI has no command to poll or cancel that operation. Use +`webhook delivery view` and `webhook delivery attempt list` to follow the +resulting delivery. + +`event view` prints the retained payload in human output only with +`--include-payload`. `webhook delivery attempt view` prints the redacted +response excerpt that the API records for a failed HTTP response. Human output +escapes control characters in remote text. + +## Local event listening and forwarding + +`polymorfa listen` opens the project listener stream and can forward the exact +event body to local webhook handlers: + +```shell +polymorfa listen \ + --project project_123 \ + --event 'message.*' \ + --forward-to http://127.0.0.1:3000/webhooks \ + --forward-secret-out ./local-forwarding.secret \ + --checkpoint-file ./.polymorfa-listener-checkpoint +``` + +The CLI signs forwarded bodies with a separate local secret. Remote forwarding +is disabled unless `--allow-remote-forward` is set. Redirects are disabled by +default; explicit redirect following revalidates each hop. The listener writes +checkpoints only after stdout and every forwarding destination have accepted an +event. A cursor gap stops with a nonzero exit unless `--on-gap continue` was +selected. + +## MCP listener controls + +The package includes a standards-based stdio MCP server for Codex and Claude: + +```shell +polymorfa mcp install codex +polymorfa mcp install claude +polymorfa mcp status +polymorfa mcp tools +polymorfa mcp doctor +``` + +The MCP tools `polymorfa_listen_start`, `polymorfa_listen_status`, and +`polymorfa_listen_stop` control listeners inside that stdio server process. +They do not create detached daemons. MCP responses contain listener IDs, +status, and safe counters, never primary credentials, listener credentials, +forwarding secrets, or unrestricted payloads. + +## Agent skills + +The npm package includes two portable skills under `skills/`: + +- `polymorfa` establishes profile, owner, project, JSON, and mutation-safety + conventions for CLI and MCP use. +- `polymorfa-webhooks` covers event inspection, webhook testing, replay and + retry, local listening and forwarding, and one-time secret handling. + +Copy or link the relevant skill directory into the skills folder used by your +agent host. The skill treats the installed command help as authoritative, so it +stays useful as the command surface grows without teaching agents to invent +routes or use raw requests for missing typed developer operations. + ## Raw API requests `polymorfa api` is the complete-access escape hatch inspired by `gh api`. It @@ -661,6 +815,10 @@ SDK request IDs are preserved and active credentials are redacted. `POLYMORFA_BASE_URL` exists only for development and controlled testing. It overrides a profile endpoint and must not be placed in project files. +The raw command does not bypass owner, principal, or scope boundaries. Events, +webhooks, deliveries, and attempts use their typed commands; the CLI does not use `api` to synthesize a missing developer +workflow. + ## Verification ```shell diff --git a/docs/command-research/campaigns.md b/docs/command-research/campaigns.md index c63eb7ec..5085d0ba 100644 --- a/docs/command-research/campaigns.md +++ b/docs/command-research/campaigns.md @@ -33,13 +33,9 @@ campaign resume --project-slug stop --project-slug --yes requeue --project-slug [--include-skipped-error] - -operation - view --surface messaging ``` -The CLI uses only `MessagingClient.campaigns` and -`MessagingClient.operations.retrieve`. Every campaign command requires an +The CLI uses only `MessagingClient.campaigns`. Every campaign command requires an organization Messaging API key. Reads require `campaigns:read`; creation and lifecycle mutations require `campaigns:manage`. The CLI rejects browser client tokens before transport. It does not broaden the SDK to project tokens even @@ -64,12 +60,11 @@ distinct commands because the server enforces different source states. Stop is a terminal transition and requires `--yes`. Launch, pause, resume, and stop append durable commands and return an operation -ID plus the campaign's present state. Human output says “Accepted”, does not say -the transition completed, and gives the exact follow-up command: -`polymorfa operation view --surface messaging`. The existing -Platform operation lookup remains the default when `--surface` is omitted. -Polymorfa exposes a singular operation read, not a wait or watch API, so the CLI -does not reproduce `gh run watch` with hidden polling semantics. +ID plus the campaign's present state. Human output says “Accepted” and does not +say the transition completed. The returned IDs belong to the Messaging campaign +API. Operation inspection is console-only, so the CLI cannot poll them. The +unpublished `--surface messaging` selector was removed rather than retained as +a compatibility alias. Requeue is different: it is a direct transaction and returns only the number of recipients moved. Failed recipients are always considered; @@ -95,11 +90,9 @@ or operation cancel route. It also exposes no response cursor. The CLI does not substitute similarly named Platform campaign operations or use `raw.request` to invent those workflows. -Stripe `listen` and Wrangler `tail` are valuable models for the later -Polymorfa event-development workflow, but both are backed by a real connected -stream. A campaign `watch`, general `listen`, or webhook forwarder remains -reserved until Polymorfa exposes the corresponding listener/session or event -history API. +Stripe `listen` and Wrangler `tail` informed the separate Polymorfa developer +event listener. It is not a campaign watch command and does not change the +campaign API gaps above. At this exact baseline the SDK ledger records 331 total / 207 covered / 58 missing / 66 excluded / 0 partial / 0 changed operations. diff --git a/docs/command-research/developer-events-webhooks.md b/docs/command-research/developer-events-webhooks.md new file mode 100644 index 00000000..086d26e4 --- /dev/null +++ b/docs/command-research/developer-events-webhooks.md @@ -0,0 +1,118 @@ +# Developer events, webhooks, and listener research + +- **Status:** Implementation complete against the accepted contract and exact + SDK pin; deployed Staging acceptance remains a separate gate +- **Reviewed:** 2026-09-16 +- **Monorepo baseline:** `1600403db9dcf7996dbbb882375b1165859829d3` +- **SDK baseline:** `08452992a32a392de5fc3d0fa3954ba6addd7f32` +- **Listener protocol SHA-256:** + `0f324f0ce4aa6b39e9af9f4aed51fcce42aa818abae00f9e3e03eb551f35d1ee` + +## Sources reviewed + +- [GitHub CLI API](https://cli.github.com/manual/gh_api) for a complete-access + escape hatch that remains separate from curated workflows. +- [GitHub CLI run watch](https://cli.github.com/manual/gh_run_watch) for terminal + polling, interval handling, and exit behavior. +- [Stripe CLI listen](https://docs.stripe.com/stripe-cli/use-cli) and the + open-source `stripe-cli` proxy implementation for foreground event filtering, + local signing, and forwarding. Live transport belongs to the CLI rather than + the public language SDK. +- [Wrangler tail](https://developers.cloudflare.com/workers/wrangler/commands/#tail) + for explicit live-stream filters and reconnect-oriented operator feedback. +- The accepted Polymorfa developer API RFC, authoritative OpenAPI documents, + listener protocol, backend handlers, and public TypeScript SDK contract. + +## Command tree + +```text +event list|view|replay +webhook list|view|create|update|delete|test +webhook secret rotate +webhook delivery list|view|retry +webhook delivery attempt list|view +listen +mcp serve|install|uninstall|status|tools|doctor +``` + +Developer resources have an explicit `project` or `organization` owner. A +stored profile may fix that choice; otherwise `--owner` is required before any +transport. Project ownership also requires exact project context. The CLI never +probes one owner after another and never treats a credential prefix, resource +ID, or failed request as ownership evidence. + +All REST commands use the unified SDK `Client` with its typed `events`, +`webhooks`, and `webhookDeliveries` resources. API contract +`1600403db9dcf7996dbbb882375b1165859829d3` serves developer operations only +under `/console/*` with a dashboard session and marks them unavailable to +machine credentials, and SDK `08452992a32a392de5fc3d0fa3954ba6addd7f32` has no +`operations` resource. The CLI therefore ships no `operation` commands; mutation +envelopes report only `operation.id`. `listen` is +different by design: the CLI requests a one-use, short-lived listener +authorization from the configured trusted API origin and opens the Hono SSE +stream itself. No public SDK listener or raw-request workaround is introduced. + +## Output and automation + +Reads and lists emit schema-versioned envelopes with resource data, owner +context, request metadata, and final-page metadata. Mutations add resource, +operation, and idempotency receipts; inapplicable values are explicit `null`. +Errors are stderr-only, typed, and retain their symbolic condition. + +API contract 1600403 adds a nullable, redacted `response` excerpt to delivery +attempts. The pinned SDK passes it through untyped; JSON output keeps it and +human attempt output prints it with control characters escaped. + +List collection requires both `--paginate` and `--max-items`; the maximum is +10,000. Each page request shrinks to the remaining budget. Cursors are opaque, +owner- and filter-bound, and cannot be combined with new filters. + +## Safety and secret delivery + +Delete, rotation, replay, and delivery retry require a +TTY confirmation or `--yes`. Validation and confirmation happen before the +idempotency key is generated or the transport begins. Every mutation accepts a +caller key up to 255 UTF-8 bytes and preserves its receipt. + +Webhook create and secret rotation may return one secret once. Interactive TTY +output may reveal it once. JSON, noninteractive, and MCP workflows require an +exclusive atomic mode-0600 file; secret material is omitted from output and +diagnostics. API success followed by a local file failure is `partial_success` +and rotation is not retried automatically. + +## Listener and forwarding + +The listener validates filter grammar, project and cursor identifiers, +authorization binding, trusted origins, negotiated limits, every SSE field, and +every typed frame before acknowledging data. It supports UTF-8 and CRLF across +arbitrary chunks, rejects duplicate `event`, `id`, or `data` fields, and +enforces negotiated whole-record and partial-buffer limits. Invalid frames fail +closed and remain unacknowledged. + +Events are processed sequentially, which is a stricter bound than negotiated +`maxInFlight`. Acknowledgement occurs only after all required sinks succeed: +stdout for JSONL-only, every destination for forwarding-only, or both stdout +and every destination for combined mode. Checkpoints store only the last +acknowledged cursor. + +Forwarding uses ordinary unauthenticated fetch rather than SDK transport. +Loopback HTTP is accepted for development. Remote forwarding requires explicit +opt-in and HTTPS. Redirects are off by default; explicit redirect mode +revalidates the scheme, DNS result, and each address on every hop. Durable and +listener credentials are never forwarded. Local bodies receive an ephemeral +signature suitable for the SDK's exact-byte webhook verifier. + +## Implementation-time follow-up + +Before each later command extension, recheck the current SDK method and backend +contract, then compare the corresponding maintained OSS command. Record new +filters, pagination, idempotency, confirmation, redaction, and terminal-state +semantics here before adding flags. Do not bend the command tree around an +accidental backend gap; bring a missing product contract to cross-stack design +review before proposing a monorepo change. + +The remaining acceptance gate is a deployed Staging E2E at exact Monorepo, SDK, +and CLI commits. It must cover ownership, webhook create/test, delivery and +attempt inspection, wait/watch, replay, forwarding, reconnect/resume, gap +handling, secret non-disclosure, and clean SIGINT. Local and hosted CI do not +substitute for that evidence. diff --git a/docs/command-research/platform-management.md b/docs/command-research/platform-management.md index be1d9bc7..7ee1b727 100644 --- a/docs/command-research/platform-management.md +++ b/docs/command-research/platform-management.md @@ -1,8 +1,9 @@ # Platform management command research -- **Status:** Complete fourteen-operation organization-key implementation slice -- **Reviewed:** 2026-08-20 -- **SDK baseline:** `c36d151c102d7812977635e677341141721cac9a` +- **Status:** Organization-key management commands reconciled with the unified + client; operation inspection is console-only +- **Reviewed:** 2026-09-16 +- **SDK baseline:** `08452992a32a392de5fc3d0fa3954ba6addd7f32` ## Sources reviewed @@ -45,26 +46,23 @@ security-incident acknowledge operation - view [--surface platform|messaging] + list|view|wait|watch|cancel --owner project-token list --project -widget - view [--project ] - update [--project ] --input - session stop [--project ] delete [--project ] --yes ``` -The CLI uses the public typed `PlatformClient` resources only. All commands -require an organization server API key. Reads require `sessions:read`; key -revocation and incident acknowledgement require `sessions:manage`. Widget reads -and updates require `widget:create`; batch session stop/delete require -`sessions:manage`. Platform project tokens and Messaging browser client tokens -are rejected before transport. +The management commands use the public typed organization resources on the +unified SDK `Client`. They require an organization server API key. Reads require +`sessions:read`; key revocation and incident acknowledgement require +`sessions:manage`; batch session stop/delete also require `sessions:manage`. +Project tokens and Messaging browser client tokens are rejected before +transport. Developer operations separately accept the principal allowed by +their explicit owner and context. ## Command decisions @@ -89,22 +87,14 @@ SDK's distinct active-only operation. Incident acknowledgement is repeatable, has no confirmation gate, and sends no body. `acknowledgedBy` may be empty when an organization key performed the acknowledgement. -`operation view` is deliberately singular. It defaults to the Platform surface; -the later Messaging campaign slice adds an explicit `--surface messaging` -selection. Either form performs one durable state read and does not imply -polling, waiting, watching, cancellation, or a list route. +The original singular Platform operation view was removed. Operation +inspection is console-only at API contract 1600403, and the pinned SDK has no +operations resource, so the CLI ships no operation commands. The unpublished +`--surface` selector was removed. Project-token listing requires an explicit project because the live organization-key handler rejects an absent project even though its OpenAPI query field is optional. Only metadata is returned. -Widget settings keep target selection outside the JSON patch: `--project` -selects one project row, while its omission selects organization defaults. The -CLI rejects `projectId` inside input so a reviewed settings document cannot hide -its mutation target. A missing saved row remains `null` in JSON and has explicit -human output. Empty patches remain representable because bodyless PUT is a live -compatibility no-op. Logo fields are storage identifiers only; the CLI does not -invent file, URL, base64, multipart, upload, attach, or download behavior. - Batch session commands use positional IDs because they are the primary action targets. The CLI trims and validates 1..100 unique IDs before transport instead of relying on the handler's silent deduplication. `session stop` reports the @@ -122,11 +112,13 @@ without claiming stronger server behavior. There is no organization-key member mutation, API-key creation or rotation, project-token minting or revocation, raw bearer material, cross-project token -aggregate, incident detail, pagination, operation list/watch/stream/cancel, or -security-state aggregation, widget logo transfer, batch per-item result, -operation ID, completion wait, watch, stream, cancel, or history in this slice. +aggregate, incident detail, management-list pagination, security-state +aggregation, batch per-item result, or machine-credential operation inspection +in this slice. Operation list, view, transitions, and cancellation are +console-only routes. The CLI does not invent those workflows or use the SDK raw escape hatch to cross dashboard/staff boundaries. -At this exact baseline the SDK ledger records 331 total / 198 covered / 67 -missing / 66 excluded / 0 partial / 0 changed operations. +At this exact baseline the SDK ledger records 403 total / 262 covered typed / +106 deliberately excluded / 35 missing operations. The missing entries are +account-health and safe-mode routes outside this command family. diff --git a/docs/command-research/quicklinks.md b/docs/command-research/quicklinks.md new file mode 100644 index 00000000..a18e9c83 --- /dev/null +++ b/docs/command-research/quicklinks.md @@ -0,0 +1,46 @@ +# QuickLink command contract + +- **Status:** Implemented against the typed hosted QuickLink SDK resource; + deployed Staging acceptance remains a separate gate +- **Reviewed:** 2026-09-16 +- **Backend baseline:** `1600403db9dcf7996dbbb882375b1165859829d3` +- **SDK baseline:** `08452992a32a392de5fc3d0fa3954ba6addd7f32` + +## Why this command family exists + +QuickLink is the standard Polymorfa number-pairing workflow. The CLI creates a +persistent hosted URL, inspects its status, or cancels it. It does not fetch +direct session QR payloads or pairing codes. This keeps the phone-holder flow +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 | + +All three routes require `quicklink:manage`. The API accepts organization keys +and exact-project tokens and scopes project tokens to their own project. + +`quicklink create` takes one JSON object from `--input` with `projectId`, +`customerId`, `externalId`, and `configuration`, and rejects other top-level +fields before transport. Presentation and callbacks belong in saved settings +(`widget view|update`). It uses an organization API key; project-token support +for creation is not exposed by this command. + +`quicklink view` and `quicklink cancel` accept an organization API key or an +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. + +The returned URL is credential material. Human and explicit JSON output return +it because it is the purpose of creation; verbose diagnostics, profiles, and +error output never copy it. + +The API exposes no list, recover, history, QR-rendering, direct-code, or status +stream operation. Future extensions must recheck the maintained API and typed +SDK rather than fill those gaps with `raw.request`. diff --git a/docs/feature-videos/developer-workflows.mp4 b/docs/feature-videos/developer-workflows.mp4 new file mode 100644 index 00000000..541d920d Binary files /dev/null and b/docs/feature-videos/developer-workflows.mp4 differ diff --git a/docs/superpowers/plans/2026-08-19-cli-foundation-and-api.md b/docs/superpowers/plans/2026-08-19-cli-foundation-and-api.md index 47162683..5a820bb0 100644 --- a/docs/superpowers/plans/2026-08-19-cli-foundation-and-api.md +++ b/docs/superpowers/plans/2026-08-19-cli-foundation-and-api.md @@ -14,7 +14,7 @@ - The source repository remains private while the npm package name is public: `@polymorfa/cli` with binary `polymorfa`. - Runtime is ESM on Node.js 20 or newer. -- Development pins `@polymorfa/sdk` to `github:polymorfa/sdks#3324a1d5ae840ed96d1a5f987880587cab2312fb`. +- Development pins `@polymorfa/sdk` to `github:polymorfa/sdks#08452992a32a392de5fc3d0fa3954ba6addd7f32`. - The first npm release is blocked until the SDK is available as a public npm semver dependency. - The CLI imports only the SDK package root and never duplicates its HTTP transport, authentication validation, retries, cancellation, metadata, pagination, or typed errors. - Secrets are never accepted as command-line arguments, written to project files, printed in verbose logs, or included in error context. @@ -103,7 +103,7 @@ Run: `npm install` Run: `npm run typecheck` -Expected: PASS with no TypeScript diagnostics and a lockfile whose SDK resolution ends at commit `b6a808366e84eb049c396a524b563ac600284826`. +Expected: PASS with no TypeScript diagnostics and a lockfile whose SDK resolution ends at commit `08452992a32a392de5fc3d0fa3954ba6addd7f32`. - [ ] **Step 3: Write the failing version command test** @@ -229,7 +229,7 @@ export function renderValue( } ``` -Define `BaseCommand` with inherited flags `--json`, `--quiet`, `--no-color`, `--profile`, `--organization`, `--project`, `--api-version`, `--timeout`, and `--verbose`. Make `--json` and `--quiet` mutually exclusive. Add `outputMode(flags)` and `writeValue(value, human)` helpers that write only to stdout. +Define `BaseCommand` with inherited flags `--json`, `--quiet`, `--no-color`, `--profile`, `--project`, `--api-version`, `--timeout`, and `--verbose`. Make `--json` and `--quiet` mutually exclusive. Organization-owned requests use the organization bound to the selected key; do not expose a cosmetic organization override. Add `outputMode(flags)` and `writeValue(value, human)` helpers that write only to stdout. Change `version` to extend `BaseCommand`, parse its flags, and render `{cli, sdk}` in JSON or the existing literal in human mode. @@ -332,7 +332,7 @@ git commit -m "Standardize CLI output and errors" **Interfaces:** -- Consumes: public `MessagingClient`, `PlatformClient`, `RawClient`, `MessagingCredential`, and SDK validation. +- Consumes: public unified `Client`, `MessagingClient`, `RawClient`, `MessagingCredential`, and SDK validation. - Produces: `ApiSurface`, `ResolvedEnvironment`, `resolveEnvironment(input)`, and `createRawClient(environment)`. - [ ] **Step 1: Write failing environment-resolution tests** @@ -406,7 +406,7 @@ Expected: PASS. - [ ] **Step 5: Write failing raw-client boundary tests** -Create a real `PlatformClient` and `MessagingClient` through `createRawClient`, then assert each return value is an SDK `RawClient`. Add a negative test proving the SDK rejects `pmfa_ct_` when passed to Platform; do not mock constructors. +Create a real unified `Client` and `MessagingClient` through `createRawClient`, then assert each return value is an SDK `RawClient`. Add a negative test proving the SDK rejects `pmfa_ct_` on the Platform surface; do not mock constructors. - [ ] **Step 6: Run the raw-client tests and verify RED** @@ -431,8 +431,13 @@ export function createRawClient(environment: ResolvedEnvironment): RawClient { } if (environment.surface === 'platform') { - return new PlatformClient({apiKey: environment.credential.value, ...shared}) - .raw + return new Client({ + credential: { + type: 'organizationApiKey', + value: environment.credential.value, + }, + ...shared, + }).raw } return new MessagingClient({credential: environment.credential, ...shared}) diff --git a/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md b/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md index 1284624d..34159942 100644 --- a/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md +++ b/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md @@ -1,6 +1,8 @@ # Polymorfa CLI design -- **Status:** Draft for user review +- **Status:** Living design; the accepted 2026-08-20 developer API RFC supersedes + this document for events, webhooks, deliveries, attempts, operations, local + listening, forwarding, and MCP listener controls - **Repository:** `polymorfa/cli` - **Package:** `@polymorfa/cli` - **Primary executable:** `polymorfa` @@ -100,15 +102,15 @@ event forwarding, MCP presentation, and skill installation. MCP tools call the same TypeScript application services used by commands; they never execute the `polymorfa` binary or parse terminal output. -### Initial SDK baseline +### Current SDK baseline The development SDK gate is satisfied by `@polymorfa/sdk@0.1.0-dev.0` at exact -commit `b4d5f3c8ce51bb0c171264eec8961797314cb172`. It is ESM-only, requires Node.js +commit `08452992a32a392de5fc3d0fa3954ba6addd7f32`. It is ESM-only, requires Node.js 20 or newer, and has no runtime dependencies. Until the SDK is published to npm, development installs pin the exact Git revision: ```shell -npm install github:polymorfa/sdks#b4d5f3c8ce51bb0c171264eec8961797314cb172 +npm install github:polymorfa/sdks#08452992a32a392de5fc3d0fa3954ba6addd7f32 ``` The CLI must not publish an npm release against a moving Git branch or an @@ -117,32 +119,29 @@ with a compatible npm semver dependency on `@polymorfa/sdk` and verify the packed CLI installs without private or Git-only runtime dependencies. The SDK's committed coverage ledger records Polymorfa monorepo source commit -`f156af2dda13e62b6b106a542fdedb39524bdb66`. Hosted Node.js 20 and 22 CI passed -for the pinned SDK revision in [GitHub Actions run 32313662730](https://github.com/polymorfa/sdks/actions/runs/32313662730). -The run exercised 46 test files and 195 tests across the repository, built the -root and every workspace, passed package dry-runs, and reported zero production -dependency vulnerabilities. - -The handwritten coverage ledger contains 331 Messaging and Platform operations: -207 curated typed methods, 58 explicitly missing operations available through -`raw.request` or `raw.paginate`, and 66 deliberately excluded dashboard or staff -routes. It records zero structurally changed signatures. Graph-compatible APIs -are outside this baseline, and the CLI must not claim typed API parity. - -The curated resources cover Messaging sessions, pairing QR and codes, operation -polling, messages, contacts, chats, channels, groups, labels, presence, calls, +`1600403db9dcf7996dbbb882375b1165859829d3`. Hosted CI passed for the pinned SDK +revision in [GitHub Actions run 34971283972](https://github.com/polymorfa/sdks/actions/runs/34971283972). + +The ledger contains 403 Messaging and Management operations: 262 covered by +typed methods, 106 deliberately excluded (including every console-only +operation route), and 35 missing. The missing entries are account-health and +safe-mode routes that the CLI does not expose. Graph-compatible APIs remain +outside this baseline, and the CLI does not claim support for excluded +dashboard, staff, or browser-only routes. + +The curated resources cover Messaging sessions, QuickLinks, +messages, contacts, chats, channels, groups, labels, presence, calls, LID resolution, user security codes, business profiles, catalogs, products, collections, orders, compliance, observation policies, webhooks, client tokens, project templates, project-scoped campaigns, and all 33 signed event payload schemas plus Platform organizations, projects, sessions, billing, campaigns, audiences, opt-outs, media, organization API keys, members, audit logs, session bans, security -incidents, management operations, project-token metadata, widget settings, and -batch session stop/delete. A CLI command outside that set uses -the SDK's raw escape hatch rather than recreating authentication, transport, -retry, pagination, or error behavior. Its command-research record must identify -the exact raw operation and track promotion to a curated SDK method when the -workflow benefits from one. +incidents, project-token metadata, saved QuickLink (widget) settings, and batch +session stop/delete. Curated commands do not use the SDK's raw escape +hatch to paper over a missing public contract. The explicit `api` command is +the user-directed low-level escape hatch and keeps the SDK's authentication, +transport, retry, pagination, and error behavior. The CLI depends only on the root server package. Browser, React, Next.js, UI, Elements, and Devtools SDK workspaces are not CLI runtime dependencies. @@ -190,7 +189,9 @@ choosing the first result. ### Global context flags - `--profile ` selects stored authentication and defaults. -- `--organization ` overrides the active organization. +- Organization-owned API requests use the organization bound to the selected + key. The CLI does not accept an organization override that could relabel a + request without changing its authorization. - `--project ` overrides the linked or profile project. - `--api-version ` overrides the SDK default when the API permits it. - `--no-interactive` forbids prompts and interactive selection. @@ -361,10 +362,6 @@ billing reminders set portal - -widget - view - update ``` Key and client-token creation print secret material once and never include it @@ -401,16 +398,18 @@ session list view create - connect reconnect disconnect stop delete - qr - pairing-code status logs +quicklink + create + view + cancel + message list view @@ -569,19 +568,18 @@ group leave ``` -`session connect` chooses QR or pairing-code presentation based on explicit -flags and terminal capability. QR output has terminal, plain-text URL, and JSON -forms. Pairing state is streamed with a timeout and cancellable cleanly. - -The typed pairing source returns the JSON QR representation; the SDK does not -claim binary `image/png` transport. The CLI may render that data in a terminal -or hand it to another renderer without downloading an undocumented binary -response. `session status` uses `sessions.retrieve`, while start, stop, restart, -logout, and delete return operation IDs that are followed through -`operations.retrieve`. The source contract has no separate connection-status or -session-log route. `session logs` therefore remains reserved and unshipped; -`/api/info/status` is unauthenticated API runtime health and is not presented as -session state. +Number pairing uses a persistent hosted QuickLink. `quicklink create` accepts a +JSON request with an organization API key and returns the link URL, +`quicklink view` reads its status, and `quicklink cancel` invalidates a link that +has not connected. View and cancel also accept an exact-project token. Saved +QuickLink settings are managed with `widget view` and `widget update`; the CLI +ships no separate `quicklink settings` commands. The CLI does not retrieve or render +direct QR payloads or pairing codes from session routes. `session status` uses +`sessions.retrieve`. Operation inspection is console-only at the pinned API +contract, so the CLI does not poll operation IDs. The source contract has no separate +connection-status or session-log route. `session logs` therefore remains +reserved and unshipped; `/api/info/status` is unauthenticated API runtime health +and is not presented as session state. Message content uses typed subcommands instead of a single flag-heavy command. Each type validates mutually exclusive content locally, then relies on the SDK @@ -805,24 +803,22 @@ separate Messaging send operation. Campaign creation accepts one bounded JSON object. Launch accepts epoch milliseconds or a timezone-qualified ISO instant, then returns a durable operation ID. Launch, pause, resume, and terminal stop report accepted work and -direct the user to `operation view --surface messaging`; they do not imply that -the transition completed. Stop requires `--yes`. Requeue is a direct +do not imply that the transition completed. These Messaging campaign operation +IDs cannot be polled with machine credentials; check campaign state with +`campaign view`. Stop requires `--yes`. Requeue is a direct transaction and reports its moved-recipient count. The source exposes no campaign update, delete, validation, archive, duplicate, recipient, event, history, replay, watch, or cancel route, so those commands remain reserved. -Webhook tests generate a traceable test event. Event replay creates a new -delivery attempt linked to the original event and does not rewrite event -history. Logs and event payloads apply redaction before rendering. - -The pinned credential-compatible contract does not yet support those webhook -test and event replay workflows. Messaging webhook subscription management is -typed and uses `webhooks:manage`; inbound raw-body verification and all 33 -signed event payload schemas are typed. Platform organization keys can inspect -campaign events only. General event history, webhook delivery-attempt -inspection, replay/redrive, and test-delivery operations remain reserved and -unshipped. Dashboard and staff webhook routes are excluded and must not be -reached with `raw.request`. +Webhook tests generate a synthetic event and a traceable delivery. Event replay +creates a new delivery for a retained event; delivery retry creates a new +immutable attempt on the same delivery. Neither operation rewrites history. +General event history, webhook ownership, delivery and attempt inspection, +secret rotation, test delivery, replay, and retry are now typed developer +resources. Their receipts report an operation ID, but operation inspection is +console-only at the pinned API contract, so the CLI exposes no operation +commands. The CLI uses only those typed SDK resources and +never reaches dashboard or staff routes through raw requests. Commands remain hidden or clearly unavailable until their underlying API contract exists. The CLI must never advertise a command that only fails with a @@ -887,8 +883,8 @@ registry and schemas; it is not a prerequisite for the local server. Tools are curated and typed rather than mechanically generated from OpenAPI. They are registered from the same application services as commands and grouped -into selectable toolsets such as `core`, `messaging`, `webhooks`, `campaigns`, -and `operations`. The default toolset stays small enough for reliable tool +into selectable toolsets such as `core`, `messaging`, `webhooks`, and +`campaigns`. The default toolset stays small enough for reliable tool selection; clients can request broader sets explicitly. The server also exposes non-secret resources for active context, capabilities, @@ -917,7 +913,6 @@ polymorfa polymorfa-messaging polymorfa-webhooks polymorfa-campaigns -polymorfa-operations ``` The core skill teaches authentication, context, output, safe mutation, and when @@ -1015,7 +1010,8 @@ maintenance work. ## Development implementation status -The private `dev` branch now contains working, unpublished vertical slices: +The current implementation branch contains working, unpublished vertical +slices: - the installable `polymorfa` ESM executable on Node.js 20 or newer; - `polymorfa version` with human, JSON, and quiet output; @@ -1050,19 +1046,28 @@ The private `dev` branch now contains working, unpublished vertical slices: - complete incoming-call rejection, singular LID resolution, and no-store user security-code inspection commands; - organization-key API-key list/revocation, member and audit lists, session-ban - filtering, security-incident list/acknowledgement, operation inspection, and - project-token metadata commands; -- organization/project widget settings inspection and JSON patching, plus - bounded batch session stop and confirmed management deletion commands; and + filtering, security-incident list/acknowledgement, and project-token metadata + commands; +- bounded batch session stop and confirmed management deletion commands; - complete Messaging campaign list/create/view/analytics and lifecycle - commands, with explicit accepted-operation polling and direct requeue; and -- real local HTTP integration tests that exercise the SDK transport. + commands, with accepted-work receipts and direct requeue; +- explicit owner-scoped event list/view/replay, webhook CRUD/test/secret + rotation, delivery and attempt inspection, and retry commands; +- persistent QuickLink create with organization keys, view/cancel with + organization keys or exact-project tokens, and no direct session QR fallback; +- the bounded SSE listener with JSONL and human sinks, fail-closed gap handling, + cumulative acknowledgement, checkpointed reconnects, exact-byte local + signing, and redirect-safe forwarding; +- the stdio MCP server with in-process listener start/status/stop controls and + install, status, doctor, and uninstall helpers; +- packaged CLI, webhook, and operations skills; and +- real local HTTP integration tests that exercise the SDK and listener + transports. This status does not claim an npm release, browser/device authorization, a real operating-system keyring smoke test, remote identity validation from `auth -status`, project linking, the remaining curated resource commands, MCP, skills, -or `listen`. -Those remain planned work in the delivery phases above. +status`, project linking, unimplemented commands in the broader command map, or +deployed Staging acceptance. Those remain later delivery gates. ## Verification diff --git a/package-lock.json b/package-lock.json index 57478d3c..c7b2c17d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,9 +9,11 @@ "version": "0.1.0-dev.0", "license": "MIT", "dependencies": { + "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#8803f0554314214519971cb3306e0c0efe21cb76" + "@polymorfa/sdk": "github:polymorfa/sdks#8803f0554314214519971cb3306e0c0efe21cb76", + "zod": "4.2.0" }, "bin": { "polymorfa": "bin/run.js" @@ -704,6 +706,29 @@ "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", "dev": true }, + "node_modules/@modelcontextprotocol/core": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", + "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "dependencies": { + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/@modelcontextprotocol/server": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", + "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "dependencies": { + "@modelcontextprotocol/core": "2.0.0", + "zod": "^4.2.0" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/@napi-rs/keyring": { "version": "1.3.0", "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", @@ -3884,6 +3909,14 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zod": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.2.0.tgz", + "integrity": "sha512-Bd5fw9wlIhtqCCxotZgdTOMwGm1a0u75wARVEY9HMs1X17trvA/lMi4+MGK5EUfYkXVTbX8UDiDKW4OgzHVUZw==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/package.json b/package.json index 367a0905..397e2145 100644 --- a/package.json +++ b/package.json @@ -13,12 +13,14 @@ "files": [ "bin/run.js", "dist", + "skills", "README.md", "LICENSE" ], "scripts": { "audit:prod": "npm audit --omit=dev", - "build": "tsc -p tsconfig.build.json", + "build": "npm run clean && tsc -p tsconfig.build.json", + "clean": "node scripts/clean-dist.js", "format": "prettier --write .", "format:check": "prettier --check .", "lint": "eslint .", @@ -28,9 +30,11 @@ "verify": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run build && npm pack --dry-run --ignore-scripts && npm run audit:prod" }, "dependencies": { + "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "github:polymorfa/sdks#8803f0554314214519971cb3306e0c0efe21cb76" + "@polymorfa/sdk": "github:polymorfa/sdks#8803f0554314214519971cb3306e0c0efe21cb76", + "zod": "4.2.0" }, "devDependencies": { "@eslint/js": "^9.39.5", @@ -121,6 +125,9 @@ "customer:pairing-link": { "description": "Create, inspect, and revoke Customer pairing links." }, + "event": { + "description": "Inspect and replay durable developer events." + }, "group": { "description": "Inspect and manage Messaging groups." }, @@ -148,9 +155,6 @@ "member": { "description": "Inspect organization members." }, - "operation": { - "description": "Inspect durable Platform and Messaging operations." - }, "profile": { "description": "View and manage the connected account profile." }, @@ -181,6 +185,9 @@ "quick-reply": { "description": "Manage observed business quick replies." }, + "quicklink": { + "description": "Create, inspect, and cancel persistent onboarding QuickLinks." + }, "security-incident": { "description": "Inspect and acknowledge organization security incidents." }, @@ -194,10 +201,7 @@ "description": "Inspect Linked Device user identity data." }, "widget": { - "description": "Inspect and update saved Connect widget settings." - }, - "quicklink": { - "description": "Create persistent onboarding invitations." + "description": "Inspect and update saved QuickLink widget settings." }, "session:configuration": { "description": "Inspect and update inherited session configuration." @@ -207,6 +211,18 @@ }, "testing:history-fixture": { "description": "Upload project-scoped test history." + }, + "webhook": { + "description": "Inspect and manage developer webhooks." + }, + "webhook:delivery": { + "description": "Inspect webhook deliveries and request explicit retries." + }, + "webhook:delivery:attempt": { + "description": "Inspect immutable webhook delivery attempts." + }, + "webhook:secret": { + "description": "Manage one-time webhook signing-secret rotation." } } }, diff --git a/scripts/clean-dist.js b/scripts/clean-dist.js new file mode 100644 index 00000000..e2988302 --- /dev/null +++ b/scripts/clean-dist.js @@ -0,0 +1,8 @@ +import {rmSync} from 'node:fs' +import {fileURLToPath, URL} from 'node:url' + +const distributionDirectory = fileURLToPath( + new URL('../dist/', import.meta.url), +) + +rmSync(distributionDirectory, {force: true, recursive: true}) diff --git a/skills/polymorfa-webhooks/SKILL.md b/skills/polymorfa-webhooks/SKILL.md new file mode 100644 index 00000000..62c8fa19 --- /dev/null +++ b/skills/polymorfa-webhooks/SKILL.md @@ -0,0 +1,39 @@ +--- +name: polymorfa-webhooks +description: Develop and troubleshoot Polymorfa events and webhooks with the CLI or MCP, including local listen and forwarding, delivery and attempt inspection, tests, replay, retry, and signing-secret rotation. +--- + +# Polymorfa webhook development + +Keep these actions distinct: + +- `webhook test` creates a synthetic event and traceable delivery. +- `event replay` creates a new delivery of a retained event to a selected webhook. +- `webhook delivery retry` creates a new immutable attempt on the same delivery. +- `listen` receives the project event stream and may forward locally; it does not create webhook history or alter a delivery. + +## Inspect before changing + +1. Resolve `--owner` and project context explicitly. +2. Use `event list/view`, `webhook list/view`, `webhook delivery list/view`, and `webhook delivery attempt list/view` to identify exact resources. +3. Use `--paginate --max-items N` together for bounded collection. Never request unbounded history. +4. Request event payloads only when needed. Do not include payloads in diagnostics or summaries by default. + +## Listen and forward + +Use `polymorfa listen --event ...` for a foreground stream. Repeat `--event` to select more than one pattern. Add `--forward-to` only for a destination the user controls. + +- Keep remote redirects disabled. +- Never forward durable API credentials or listener authorization material. +- Treat stream gaps, dropped events, and forwarding failures as explicit nonzero conditions. +- Acknowledge an event only after every configured sink required by the mode has accepted it. +- On reconnect, resume from the last acknowledged checkpoint. Do not skip malformed or oversized frames. +- Stop with SIGINT and verify the listener closes cleanly. + +Forwarded requests use a local ephemeral signing secret. Store it only through the CLI's exclusive mode-0600 file option when persistence is required. Application handlers should verify the exact raw request bytes with the Polymorfa SDK. + +## Handle one-time webhook secrets + +Secret creation and rotation must never put secrets in JSON, JSONL, MCP results, logs, pipes, profiles, or debug output. For noninteractive or JSON use, always pass `--secret-out PATH`; the CLI writes a new mode-0600 file without overwriting. If the API succeeds but local secret delivery fails, record the safe `partial_success` receipt and do not retry rotation automatically. + +Destructive or delivery-producing actions require an exact target and the command's confirmation policy. Keep the mutation receipt and operation ID. When the outcome matters, inspect the resulting delivery with `polymorfa webhook delivery view` and its attempts. diff --git a/skills/polymorfa/SKILL.md b/skills/polymorfa/SKILL.md new file mode 100644 index 00000000..d7d00122 --- /dev/null +++ b/skills/polymorfa/SKILL.md @@ -0,0 +1,37 @@ +--- +name: polymorfa +description: Use the Polymorfa CLI or its local MCP server to inspect and operate Polymorfa resources with explicit profiles, owners, project context, safe machine output, and credential handling. Use for Polymorfa command discovery, authentication, scripting, or agent integrations. +--- + +# Polymorfa CLI + +Use `polymorfa --help` and `polymorfa --help` as the installed command authority. Do not guess a command or fall back to an authenticated raw request when a typed command is absent. + +## Establish context + +1. Run `polymorfa auth status --json` before a mutation or a project-scoped read. +2. Select an explicit profile with `--profile` when the active profile is not already unambiguous. +3. For resources that may be project- or organization-owned, supply `--owner project|organization` unless the stored profile fixes the owner. +4. Supply the exact project context for project-owned resources. Never infer ownership from an ID, credential shape, failed request, or route. + +Organization API keys and exact-project tokens are durable principals. Browser client tokens and listener credentials are not general CLI credentials. Do not print, log, persist in project files, or pass credentials in command arguments. + +Use `polymorfa quicklink create --project PROJECT --input request.json` with an organization API key for standard number pairing. Treat the returned hosted URL as credential material; do not request direct session QR payloads or pairing codes. + +## Choose an interface + +- Use the CLI for human work, shell automation, and JSON/JSONL pipelines. +- Use `polymorfa mcp serve` for an agent that needs structured tools. Inspect the exposed surface with `polymorfa mcp tools` and diagnose setup with `polymorfa mcp doctor`. +- MCP `listen` start/status/stop controls streams owned by that MCP server process. It does not create a detached daemon. + +Use `--json` when another program consumes the result. Treat `schemaVersion`, `context`, `request`, pagination metadata, mutation receipts, and symbolic errors as the stable automation contract. Do not scrape human tables or prose. + +## Mutate safely + +1. Validate input locally before calling the API. +2. Pass a stable `--idempotency-key` when a workflow may retry. +3. Use `--yes` only after the exact target has been resolved and displayed or recorded. +4. Never interpret a returned operation ID as completion. The CLI cannot poll operations; inspect the affected resource, such as the webhook delivery, to confirm the outcome. +5. A local timeout or interruption must not cancel remote work unless an explicit cancel command is issued. + +When a command fails, preserve the symbolic error condition and stderr envelope. Do not reduce failures to an exit code alone. diff --git a/src/base-command.ts b/src/base-command.ts index cf5e601f..c0d6c9f4 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -1,20 +1,28 @@ import {Command, Flags, type Interfaces} from '@oclif/core' import { PolymorfaConfigurationError, + type ClientInstance, type MessagingClient, - type Client as PlatformClient, } from '@polymorfa/sdk' -import {resolveCommandEnvironment} from './context/environment.js' +import { + resolveCommandEnvironment, + resolveListenerEnvironment, +} from './context/environment.js' import {presentError} from './errors/present.js' +import {errorEnvelope, type OutputContext} from './output/contracts.js' import {renderValue, type OutputMode} from './output/render.js' import {createMessagingClient, createPlatformClient} from './sdk/client.js' +import { + createDeveloperSession, + type DeveloperSession, +} from './developer/client.js' export interface GlobalFlags { readonly 'api-version'?: string | undefined readonly json?: boolean | undefined readonly 'no-color'?: boolean | undefined - readonly organization?: string | undefined + readonly owner?: string | undefined readonly profile?: string | undefined readonly project?: string | undefined readonly quiet?: boolean | undefined @@ -23,6 +31,7 @@ export interface GlobalFlags { } export abstract class BaseCommand extends Command { + private errorContext: Partial = {} private readonly registeredSecrets: string[] = [] static override baseFlags = { @@ -39,9 +48,10 @@ export abstract class BaseCommand extends Command { description: 'Disable color output.', helpGroup: 'GLOBAL', }), - organization: Flags.string({ - description: 'Use an organization other than the active context.', + owner: Flags.string({ + description: 'Select project-owned or organization-owned resources.', helpGroup: 'GLOBAL', + options: ['project', 'organization'], }), profile: Flags.string({ description: 'Use a named authentication profile.', @@ -73,6 +83,16 @@ export abstract class BaseCommand extends Command { return 'human' } + protected async developerSession( + flags: GlobalFlags, + ): Promise { + const session = await createDeveloperSession(flags, (secret) => + this.registerSecret(secret), + ) + this.errorContext = session.context + return session + } + protected async messagingClient( flags: GlobalFlags, options: {readonly serverApiKeyOnly?: string} = {}, @@ -99,7 +119,45 @@ export abstract class BaseCommand extends Command { return createMessagingClient(environment) } - protected async platformClient(flags: GlobalFlags): Promise { + protected async quickLinksClient(flags: GlobalFlags): Promise<{ + client: MessagingClient + projectId: string + }> { + const environment = await resolveListenerEnvironment({ + env: process.env, + ...(flags.profile === undefined ? {} : {profile: flags.profile}), + ...(flags.project === undefined ? {} : {project: flags.project}), + ...(flags['api-version'] === undefined + ? {} + : {apiVersion: flags['api-version']}), + ...(flags.timeout === undefined ? {} : {timeoutMs: flags.timeout}), + }) + this.registerSecret(environment.credential) + return { + client: createMessagingClient({ + ...(environment.apiVersion === undefined + ? {} + : {apiVersion: environment.apiVersion}), + baseUrl: environment.baseUrl, + credential: { + type: + environment.principalType === 'project_token' + ? 'projectToken' + : 'apiKey', + value: environment.credential, + }, + surface: 'messaging', + ...(environment.timeoutMs === undefined + ? {} + : {timeoutMs: environment.timeoutMs}), + }), + projectId: environment.projectId, + } + } + + protected async platformClient( + flags: GlobalFlags, + ): Promise> { const environment = await resolveCommandEnvironment({ env: process.env, surface: 'platform', @@ -121,11 +179,25 @@ export abstract class BaseCommand extends Command { const presented = presentError(error, [ process.env.POLYMORFA_API_KEY ?? '', process.env.POLYMORFA_CLIENT_TOKEN ?? '', + process.env.POLYMORFA_PROJECT_TOKEN ?? '', ...this.registeredSecrets, ]) if (this.argv.includes('--json')) { - process.stderr.write(`${JSON.stringify(presented)}\n`) + process.stderr.write( + `${JSON.stringify( + errorEnvelope({ + category: presented.category, + code: presented.code, + context: this.errorContext, + message: presented.message, + ...(presented.requestId === undefined + ? {} + : {requestId: presented.requestId}), + retryable: presented.retryable, + }), + )}\n`, + ) } else { process.stderr.write(`Error: ${presented.message}\n`) if (presented.requestId !== undefined) { diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index a6d33db2..0292b780 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -7,29 +7,48 @@ import {readSecretFromStdin} from '../../input/stdin.js' export default class AuthLogin extends BaseCommand { static override description = - 'Store an API key in the operating-system keyring.' + 'Store an organization key or project token in the operating-system keyring.' static override flags = { 'with-api-key': Flags.boolean({ description: 'Read a Polymorfa API key from standard input.', }), + 'with-project-token': Flags.boolean({ + description: 'Read an exact-project token from standard input.', + }), } async run(): Promise { const {flags} = await this.parse(AuthLogin) - if (flags['with-api-key'] !== true) { + if (flags['with-api-key'] === flags['with-project-token']) { throw new PolymorfaConfigurationError( - 'Browser login requires the upcoming CLI device-authorization API. For now, pipe an API key to polymorfa auth login --with-api-key; the key is read only from standard input.', + 'Choose exactly one of --with-api-key or --with-project-token; the credential is read only from standard input.', 'authentication', ) } + if ( + flags['with-project-token'] && + (flags.project === undefined || flags.project.trim().length === 0) + ) { + throw new PolymorfaConfigurationError( + '--with-project-token requires --project before a credential is read.', + 'project', + ) + } - const apiKey = await readSecretFromStdin() - this.registerSecret(apiKey) - const result = await createAuthService().login({ - apiKey, - profile: flags.profile ?? 'default', - }) + const credential = await readSecretFromStdin() + this.registerSecret(credential) + const service = createAuthService() + const result = flags['with-project-token'] + ? await service.loginProjectToken({ + profile: flags.profile ?? 'default', + project: requiredProject(flags.project), + projectToken: credential, + }) + : await service.login({ + apiKey: credential, + profile: flags.profile ?? 'default', + }) this.writeValue( result, `Logged in as profile ${result.name}. Credentials are stored in the operating-system keyring.`, @@ -37,3 +56,13 @@ export default class AuthLogin extends BaseCommand { ) } } + +function requiredProject(value: string | undefined): string { + if (value === undefined || value.trim().length === 0) { + throw new PolymorfaConfigurationError( + '--with-project-token requires --project before a credential is read.', + 'project', + ) + } + return value +} diff --git a/src/commands/auth/status.ts b/src/commands/auth/status.ts index eb41aaac..e89d48e3 100644 --- a/src/commands/auth/status.ts +++ b/src/commands/auth/status.ts @@ -1,5 +1,6 @@ import {BaseCommand} from '../../base-command.js' import {createAuthService} from '../../config/auth-service.js' +import {validateProjectToken} from '../../config/credentials.js' import { PolymorfaConfigurationError, validateMessagingCredential, @@ -41,7 +42,7 @@ function environmentStatus( | { readonly result: { readonly credentialFormatValid: true - readonly credentialKind: 'apiKey' | 'clientToken' + readonly credentialKind: 'apiKey' | 'clientToken' | 'projectToken' readonly credentialPresent: true readonly remoteIdentityValidated: false readonly source: 'environment' @@ -52,9 +53,10 @@ function environmentStatus( if (requestedProfile !== undefined) return undefined const apiKey = nonEmpty(env.POLYMORFA_API_KEY) const clientToken = nonEmpty(env.POLYMORFA_CLIENT_TOKEN) - if (apiKey !== undefined && clientToken !== undefined) { + const projectToken = nonEmpty(env.POLYMORFA_PROJECT_TOKEN) + if ([apiKey, clientToken, projectToken].filter(Boolean).length > 1) { throw new PolymorfaConfigurationError( - 'Authentication status is ambiguous; set only POLYMORFA_API_KEY or POLYMORFA_CLIENT_TOKEN, or select --profile .', + 'Authentication status is ambiguous; set only one of POLYMORFA_API_KEY, POLYMORFA_CLIENT_TOKEN, or POLYMORFA_PROJECT_TOKEN, or select --profile .', 'credential', ) } @@ -63,13 +65,19 @@ function environmentStatus( if (clientToken !== undefined) { validateMessagingCredential({type: 'clientToken', value: clientToken}) } - const secret = apiKey ?? clientToken + if (projectToken !== undefined) validateProjectToken(projectToken) + const secret = apiKey ?? clientToken ?? projectToken if (secret === undefined) return undefined return { secret, result: { credentialFormatValid: true, - credentialKind: apiKey === undefined ? 'clientToken' : 'apiKey', + credentialKind: + apiKey !== undefined + ? 'apiKey' + : projectToken !== undefined + ? 'projectToken' + : 'clientToken', credentialPresent: true, remoteIdentityValidated: false, source: 'environment', diff --git a/src/commands/event/list.ts b/src/commands/event/list.ts new file mode 100644 index 00000000..9d5540e3 --- /dev/null +++ b/src/commands/event/list.ts @@ -0,0 +1,67 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {paginationFlags} from '../../developer/flags.js' +import {developerRead} from '../../developer/output.js' +import { + assertCursorNotCombined, + listResources, + paginationInput, +} from '../../developer/support.js' +import {optionalTimeRange} from '../../input/time.js' + +export default class EventList extends BaseCommand { + static override description = 'List retained events for an explicit owner.' + + static override flags = { + ...paginationFlags, + since: Flags.string({ + description: 'Include events at or after this RFC 3339 time.', + }), + type: Flags.string({description: 'Filter by one exact event type.'}), + until: Flags.string({ + description: 'Include events before this RFC 3339 time.', + }), + } + + async run(): Promise { + const {flags} = await this.parse(EventList) + assertCursorNotCombined(flags.cursor, [ + flags.since, + flags.type, + flags.until, + ]) + const time = optionalTimeRange(flags.since, flags.until) + const session = await this.developerSession(flags) + const result = await listResources( + paginationInput(flags), + ({cursor, limit}) => + session.client.events.list( + { + ...(cursor === undefined ? {} : {cursor}), + limit, + ...time, + ...(flags.type === undefined ? {} : {type: flags.type}), + }, + session.requestOptions, + ), + ) + this.writeValue( + developerRead( + result.items, + result.metadata, + session.context, + result.page, + ), + renderEvents(result.items), + flags, + ) + } +} + +function renderEvents( + events: readonly {readonly id: string; readonly type: string}[], +): string { + if (events.length === 0) return 'No events found.' + return events.map((event) => `${event.id}\t${event.type}`).join('\n') +} diff --git a/src/commands/event/replay.ts b/src/commands/event/replay.ts new file mode 100644 index 00000000..82369135 --- /dev/null +++ b/src/commands/event/replay.ts @@ -0,0 +1,52 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {idempotencyFlag, yesFlag} from '../../developer/flags.js' +import {developerMutation} from '../../developer/output.js' +import {record, stringField} from '../../developer/support.js' +import {confirmDangerous} from '../../input/confirmation.js' +import {idempotencyKey} from '../../input/idempotency.js' + +export default class EventReplay extends BaseCommand { + static override args = { + event: Args.string({description: 'Event ID.', required: true}), + } + + static override description = 'Replay one retained event to a webhook.' + + static override flags = { + 'idempotency-key': idempotencyFlag, + webhook: Flags.string({ + description: 'Destination webhook ID.', + required: true, + }), + yes: yesFlag, + } + + async run(): Promise { + const {args, flags} = await this.parse(EventReplay) + await confirmDangerous({ + prompt: `Replay event ${args.event} to webhook ${flags.webhook}?`, + yes: flags.yes, + }) + const session = await this.developerSession(flags) + const key = idempotencyKey(flags['idempotency-key']) + const response = await session.client.events.replay( + args.event, + {webhookId: flags.webhook}, + {...session.requestOptions, idempotencyKey: key}, + ) + const receipt = record(response.data, 'Event replay') + const deliveryId = stringField(receipt, 'deliveryId') + this.writeValue( + developerMutation({ + context: session.context, + metadata: response.metadata, + receipt, + resource: {id: deliveryId, type: 'webhook_delivery'}, + }), + `Accepted replay as delivery ${deliveryId}; operation ${stringField(receipt, 'operationId')}.`, + flags, + ) + } +} diff --git a/src/commands/event/view.ts b/src/commands/event/view.ts new file mode 100644 index 00000000..dd2430d4 --- /dev/null +++ b/src/commands/event/view.ts @@ -0,0 +1,34 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {developerResponse} from '../../developer/output.js' +import {renderEventDetail} from '../../developer/render.js' + +export default class EventView extends BaseCommand { + static override args = { + event: Args.string({description: 'Event ID.', required: true}), + } + + static override description = 'View one retained event.' + + static override flags = { + 'include-payload': Flags.boolean({ + description: 'Include the retained payload when it is available.', + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(EventView) + const session = await this.developerSession(flags) + const response = await session.client.events.retrieve( + args.event, + {includePayload: flags['include-payload']}, + session.requestOptions, + ) + this.writeValue( + developerResponse(response, session.context), + renderEventDetail(response.data, flags['include-payload']), + flags, + ) + } +} diff --git a/src/commands/listen.ts b/src/commands/listen.ts new file mode 100644 index 00000000..3d8f2dad --- /dev/null +++ b/src/commands/listen.ts @@ -0,0 +1,260 @@ +import {Flags} from '@oclif/core' +import {randomUUID} from 'node:crypto' +import {lstat} from 'node:fs/promises' + +import {BaseCommand} from '../base-command.js' +import {CliConditionError} from '../errors/conditions.js' +import {loadCheckpoint} from '../listener/checkpoint.js' +import { + createForwardingSecret, + forwardEvent, + validateForwardDestination, +} from '../listener/forwarding.js' +import {ListenerProtocolClient} from '../listener/protocol.js' +import {runListener, type ListenLine} from '../listener/session.js' +import {prepareSecretFile} from '../security/secret-file.js' + +export default class Listen extends BaseCommand { + static override description = + 'Listen for project events and optionally forward their exact payloads.' + + static override flags = { + 'allow-remote-forward': Flags.boolean({ + dependsOn: ['forward-to'], + description: 'Allow validated public HTTPS forwarding destinations.', + }), + 'checkpoint-file': Flags.string({ + description: 'Resume from and atomically update a private checkpoint.', + exclusive: ['from'], + }), + event: Flags.string({ + default: ['*'], + description: 'Listen for an event type or trailing-wildcard pattern.', + multiple: true, + }), + 'follow-redirects': Flags.boolean({ + dependsOn: ['forward-to'], + description: 'Follow at most three validated 307 or 308 redirects.', + }), + 'forward-secret-out': Flags.string({ + dependsOn: ['forward-to'], + description: + 'Write the one-time local forwarding secret to a new mode-0600 file.', + }), + 'forward-to': Flags.string({ + description: 'Forward exact event payloads to this destination.', + multiple: true, + }), + from: Flags.string({ + description: 'Resume after this opaque listener cursor.', + exclusive: ['checkpoint-file'], + }), + 'max-buffer': Flags.integer({ + default: 256, + description: 'Bound locally pending events.', + max: 4096, + min: 1, + }), + 'on-gap': Flags.string({ + default: 'stop', + description: 'Stop safely or continue explicitly after a cursor gap.', + options: ['stop', 'continue'], + }), + project: Flags.string({ + description: + 'Use a project UUID; organization API keys may use a unique slug.', + required: true, + }), + } + + async run(): Promise { + const {flags} = await this.parse(Listen) + const {resolveListenerEnvironment} = + await import('../context/environment.js') + const environment = await resolveListenerEnvironment({ + env: process.env, + ...(flags.profile === undefined ? {} : {profile: flags.profile}), + project: flags.project, + }) + this.registerSecret(environment.credential) + + const events = flags.event + let resumeCursor = flags.from ?? null + if (flags['checkpoint-file'] && (await exists(flags['checkpoint-file']))) { + const checkpoint = await loadCheckpoint( + flags['checkpoint-file'], + environment.projectId, + events, + ) + if (Date.parse(checkpoint.cursorExpiresAt) <= Date.now()) { + if (flags.json === true) { + await writeListenLine( + { + cursorExpiresAt: checkpoint.cursorExpiresAt, + listenerId: randomUUID(), + observedAt: new Date().toISOString(), + oldestAvailableCursor: null, + reason: 'checkpoint_expired', + recoverable: false, + requestedCursor: checkpoint.lastAcknowledgedCursor, + schemaVersion: 1, + sequence: 0, + streamId: null, + type: 'gap', + }, + true, + false, + ) + } + throw new CliConditionError( + 'stream_gap', + 'The listener checkpoint expired; no listener authorization was attempted.', + ) + } + resumeCursor = checkpoint.lastAcknowledgedCursor + } + + const targets = flags['forward-to'] ?? [] + let forwardingSecret: string | undefined + if (targets.length > 0) { + await Promise.all( + targets.map((target) => + validateForwardDestination( + target, + flags['allow-remote-forward'] ?? false, + ), + ), + ) + const interactive = + process.stdin.isTTY === true && process.stderr.isTTY === true + if ( + !flags['forward-secret-out'] && + (flags.json === true || !interactive) + ) { + throw new CliConditionError( + 'action_required', + 'JSON and noninteractive forwarding require --forward-secret-out.', + ) + } + const prepared = flags['forward-secret-out'] + ? await prepareSecretFile(flags['forward-secret-out']) + : undefined + forwardingSecret = createForwardingSecret() + this.registerSecret(forwardingSecret) + if (prepared) await prepared.write(forwardingSecret) + else + process.stderr.write( + `Local forwarding secret (shown once): ${forwardingSecret}\n`, + ) + } + + const controller = new AbortController() + const interrupt = () => controller.abort(new Error('Interrupted.')) + process.once('SIGINT', interrupt) + process.once('SIGTERM', interrupt) + try { + const protocol = new ListenerProtocolClient({ + apiOrigin: environment.baseUrl, + credential: environment.credential, + version: this.config.version, + }) + const result = await runListener({ + events, + ...(flags['checkpoint-file'] + ? {checkpointFile: flags['checkpoint-file']} + : {}), + ...(forwardingSecret + ? { + forward: async ({body, event}) => + forwardEvent({ + allowRemote: flags['allow-remote-forward'], + body, + eventId: stringField(event, 'id'), + eventType: stringField(event, 'type'), + followRedirects: flags['follow-redirects'], + secret: forwardingSecret, + targets, + signal: controller.signal, + }), + } + : {}), + onGap: flags['on-gap'] as 'stop' | 'continue', + maxBuffer: flags['max-buffer'], + output: (line) => + writeListenLine(line, flags.json === true, targets.length > 0), + projectId: environment.projectId, + protocol, + resumeCursor, + signal: controller.signal, + }) + if ( + result.condition !== 'success' && + result.condition !== 'interrupted' + ) { + throw new CliConditionError( + result.condition, + `Listener stopped: ${result.condition}.`, + ) + } + } finally { + process.off('SIGINT', interrupt) + process.off('SIGTERM', interrupt) + } + } +} + +interface ListenWriter { + write( + value: string, + encoding: BufferEncoding, + callback: (error?: Error | null) => void, + ): unknown +} + +export function writeListenLine( + line: ListenLine, + json: boolean, + forwarding: boolean, + writer: ListenWriter = process.stdout, +): Promise { + if (forwarding && !json) return Promise.resolve() + const value = json ? JSON.stringify(line) : humanLine(line) + return new Promise((resolve, reject) => { + writer.write(`${value}\n`, 'utf8', (error) => + error ? reject(error) : resolve(), + ) + }) +} + +function humanLine(line: ListenLine): string { + if ( + line.type === 'event' && + typeof line.event === 'object' && + line.event !== null + ) { + const event = line.event as Record + return `${stringField(event, 'type')} ${stringField(event, 'id')}` + } + if (line.type === 'ready') + return `Listening on project ${String(line.projectId)}.` + return `Listener ${line.type}.` +} + +function stringField(value: Record, key: string): string { + const field = value[key] + if (typeof field !== 'string' || field.length === 0) { + throw new Error(`Listener event ${key} must be a nonempty string.`) + } + return field +} + +async function exists(path: string): Promise { + try { + await lstat(path) + return true + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') + return false + throw error + } +} diff --git a/src/commands/mcp/doctor.ts b/src/commands/mcp/doctor.ts new file mode 100644 index 00000000..c2fc69d1 --- /dev/null +++ b/src/commands/mcp/doctor.ts @@ -0,0 +1,28 @@ +import {BaseCommand} from '../../base-command.js' +import { + LISTENER_PROTOCOL_SHA256, + LISTENER_PROTOCOL_VERSION, +} from '../../listener/protocol.js' +import {MCP_TOOL_NAMES} from '../../mcp/server.js' + +export default class McpDoctor extends BaseCommand { + static override description = + 'Check the local MCP runtime and listener protocol pin.' + + async run(): Promise { + const {flags} = await this.parse(McpDoctor) + const major = Number(process.versions.node.split('.')[0]) + const result = { + listenerProtocolSha256: LISTENER_PROTOCOL_SHA256, + listenerProtocolVersion: LISTENER_PROTOCOL_VERSION, + node: process.versions.node, + nodeSupported: major >= 20, + tools: MCP_TOOL_NAMES.length, + } + this.writeValue( + result, + `Node ${result.node}: ${result.nodeSupported ? 'supported' : 'unsupported'}\nListener protocol ${result.listenerProtocolVersion}: ${result.listenerProtocolSha256}`, + flags, + ) + } +} diff --git a/src/commands/mcp/install.ts b/src/commands/mcp/install.ts new file mode 100644 index 00000000..ed9ecf11 --- /dev/null +++ b/src/commands/mcp/install.ts @@ -0,0 +1,25 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {parseMcpClient, runMcpClientCommand} from '../../mcp/client-command.js' + +export default class McpInstall extends BaseCommand { + static override args = { + client: Args.string({options: ['codex', 'claude'], required: true}), + } + static override description = + 'Install the Polymorfa stdio MCP server in a supported client.' + + async run(): Promise { + const {args, flags} = await this.parse(McpInstall) + const result = await runMcpClientCommand( + parseMcpClient(args.client), + 'install', + ) + this.writeValue( + result, + `Installed Polymorfa MCP for ${result.client}.`, + flags, + ) + } +} diff --git a/src/commands/mcp/serve.ts b/src/commands/mcp/serve.ts new file mode 100644 index 00000000..ff92bd7a --- /dev/null +++ b/src/commands/mcp/serve.ts @@ -0,0 +1,24 @@ +import {BaseCommand} from '../../base-command.js' +import {createListenerManager} from '../../mcp/factory.js' +import {serveMcp} from '../../mcp/server.js' + +export default class McpServe extends BaseCommand { + static override description = + 'Serve Polymorfa listener controls over MCP stdio.' + + async run(): Promise { + await this.parse(McpServe) + const manager = createListenerManager(this.config.version) + const handle = serveMcp(manager, this.config.version) + const stop = () => { + handle.close().catch((error: unknown) => { + process.exitCode = 1 + process.stderr.write( + `MCP shutdown error: ${error instanceof Error ? error.message : String(error)}\n`, + ) + }) + } + process.once('SIGINT', stop) + process.once('SIGTERM', stop) + } +} diff --git a/src/commands/mcp/status.ts b/src/commands/mcp/status.ts new file mode 100644 index 00000000..797b2d25 --- /dev/null +++ b/src/commands/mcp/status.ts @@ -0,0 +1,70 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {parseMcpClient, runMcpClientCommand} from '../../mcp/client-command.js' + +export default class McpStatus extends BaseCommand { + static override args = { + client: Args.string({options: ['codex', 'claude'], required: false}), + } + static override description = + 'Inspect Polymorfa MCP configuration in supported clients.' + + async run(): Promise { + const {args, flags} = await this.parse(McpStatus) + const clients = args.client + ? [parseMcpClient(args.client)] + : (['codex', 'claude'] as const) + const results = await Promise.allSettled( + clients.map((client) => runMcpClientCommand(client, 'status')), + ) + const status = results.map((result, index) => { + const client = clients[index] + if (client === undefined) + throw new Error('MCP status result was unbound.') + if (result.status === 'fulfilled') { + return {client, configured: true, output: result.value.output} + } + const reason = result.reason as unknown + if (isMissingClient(client, reason)) { + return {client, configured: false, reason: 'client_not_found'} + } + if (isNotConfigured(client, reason)) { + return {client, configured: false} + } + throw reason + }) + this.writeValue( + status, + status + .map( + (item) => + `${item.client}: ${ + item.configured + ? 'configured' + : 'reason' in item + ? `not configured (${item.client} command not found)` + : 'not configured' + }`, + ) + .join('\n'), + flags, + ) + } +} + +function isMissingClient(client: 'codex' | 'claude', error: unknown): boolean { + if (!(error instanceof Error)) return false + const {code, syscall} = error as NodeJS.ErrnoException + return code === 'ENOENT' && syscall === `spawn ${client}` +} + +function isNotConfigured(client: 'codex' | 'claude', error: unknown): boolean { + if (!(error instanceof Error)) return false + if (client === 'codex') { + return /(?:^|\n)Error: No MCP server named 'polymorfa' found\.$/.test( + error.message, + ) + } + return error.message.startsWith('No MCP server named "polymorfa".') +} diff --git a/src/commands/mcp/tools.ts b/src/commands/mcp/tools.ts new file mode 100644 index 00000000..b87e17df --- /dev/null +++ b/src/commands/mcp/tools.ts @@ -0,0 +1,12 @@ +import {BaseCommand} from '../../base-command.js' +import {MCP_TOOL_NAMES} from '../../mcp/server.js' + +export default class McpTools extends BaseCommand { + static override description = + 'List tools exposed by the local Polymorfa MCP server.' + + async run(): Promise { + const {flags} = await this.parse(McpTools) + this.writeValue({tools: MCP_TOOL_NAMES}, MCP_TOOL_NAMES.join('\n'), flags) + } +} diff --git a/src/commands/mcp/uninstall.ts b/src/commands/mcp/uninstall.ts new file mode 100644 index 00000000..6fb018ce --- /dev/null +++ b/src/commands/mcp/uninstall.ts @@ -0,0 +1,25 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {parseMcpClient, runMcpClientCommand} from '../../mcp/client-command.js' + +export default class McpUninstall extends BaseCommand { + static override args = { + client: Args.string({options: ['codex', 'claude'], required: true}), + } + static override description = + 'Remove the Polymorfa MCP server from a supported client.' + + async run(): Promise { + const {args, flags} = await this.parse(McpUninstall) + const result = await runMcpClientCommand( + parseMcpClient(args.client), + 'uninstall', + ) + this.writeValue( + result, + `Removed Polymorfa MCP from ${result.client}.`, + flags, + ) + } +} diff --git a/src/commands/quicklink/cancel.ts b/src/commands/quicklink/cancel.ts new file mode 100644 index 00000000..2cf7addb --- /dev/null +++ b/src/commands/quicklink/cancel.ts @@ -0,0 +1,28 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {confirmDangerous} from '../../input/confirmation.js' + +export default class QuickLinkCancel extends BaseCommand { + static override args = { + quicklink: Args.string({description: 'QuickLink ID.', required: true}), + } + + static override description = + 'Cancel a QuickLink that has not connected and remove its pending session.' + + static override flags = { + yes: Flags.boolean({description: 'Confirm QuickLink cancellation.'}), + } + + async run(): Promise { + const {args, flags} = await this.parse(QuickLinkCancel) + await confirmDangerous({ + prompt: `Cancel QuickLink ${args.quicklink}?`, + yes: flags.yes, + }) + const {client} = await this.quickLinksClient(flags) + const response = await client.quickLinks.cancel(args.quicklink) + this.writeValue(response, `Cancelled QuickLink ${args.quicklink}.`, flags) + } +} diff --git a/src/commands/quicklink/view.ts b/src/commands/quicklink/view.ts new file mode 100644 index 00000000..fbd5b5b9 --- /dev/null +++ b/src/commands/quicklink/view.ts @@ -0,0 +1,23 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' + +export default class QuickLinkView extends BaseCommand { + static override args = { + quicklink: Args.string({description: 'QuickLink ID.', required: true}), + } + + static override description = 'View the status of one hosted QuickLink.' + + async run(): Promise { + const {args, flags} = await this.parse(QuickLinkView) + const {client} = await this.quickLinksClient(flags) + const response = await client.quickLinks.retrieve(args.quicklink) + const quickLink = response.data.data + this.writeValue( + response, + `${quickLink.id}\t${quickLink.status}\t${quickLink.session}`, + flags, + ) + } +} diff --git a/src/commands/webhook/create.ts b/src/commands/webhook/create.ts new file mode 100644 index 00000000..b2ff9642 --- /dev/null +++ b/src/commands/webhook/create.ts @@ -0,0 +1,92 @@ +import {Flags} from '@oclif/core' +import type {CreateProjectWebhookInput} from '@polymorfa/sdk' + +import {readJsonInput} from '../../api/request.js' +import {BaseCommand} from '../../base-command.js' +import {idempotencyFlag} from '../../developer/flags.js' +import {developerMutation, sanitizedResult} from '../../developer/output.js' +import {prepareOneTimeSecretDelivery} from '../../developer/secrets.js' +import {record, stringField} from '../../developer/support.js' +import {CliConditionError} from '../../errors/conditions.js' +import {idempotencyKey} from '../../input/idempotency.js' + +export default class WebhookCreate extends BaseCommand { + static override description = 'Create a webhook from one JSON object.' + + static override flags = { + 'idempotency-key': idempotencyFlag, + input: Flags.string({ + description: 'Read the webhook JSON object from a file or stdin (-).', + required: true, + }), + 'secret-out': Flags.string({ + description: 'Write the one-time signing secret to a new mode-0600 file.', + }), + } + + async run(): Promise { + const {flags} = await this.parse(WebhookCreate) + const delivery = await prepareOneTimeSecretDelivery({ + json: flags.json === true, + ...(flags['secret-out'] === undefined ? {} : {path: flags['secret-out']}), + }) + const session = await this.developerSession(flags) + const input = record( + await readJsonInput(flags.input), + 'Webhook input', + ) as unknown as CreateProjectWebhookInput + const response = await session.client.webhooks.create(input, { + ...session.requestOptions, + idempotencyKey: idempotencyKey(flags['idempotency-key']), + }) + const receipt = record(response.data, 'Webhook creation receipt') + const webhook = record(receipt.webhook, 'Webhook') + const secret = + receipt.secret === null || receipt.secret === undefined + ? null + : stringField(receipt, 'secret') + if (secret !== null) this.registerSecret(secret) + let secretDelivery + try { + secretDelivery = await delivery.deliver(secret) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + const output = developerMutation({ + context: session.context, + metadata: response.metadata, + receipt, + resource: {id: stringField(webhook, 'id'), type: 'webhook'}, + result: { + ...sanitizedResult(receipt), + secretDelivery: { + mode: delivery.mode, + ...(delivery.path === undefined ? {} : {path: delivery.path}), + written: false, + }, + }, + }) + this.writeValue( + output, + `Webhook created, but its one-time signing secret could not be written: ${reason}`, + flags, + ) + throw new CliConditionError( + 'partial_success', + `Webhook created, but its one-time signing secret could not be written: ${reason}. Rotate the secret with a new idempotency key.`, + {cause: error}, + ) + } + const output = developerMutation({ + context: session.context, + metadata: response.metadata, + receipt, + resource: {id: stringField(webhook, 'id'), type: 'webhook'}, + result: {...sanitizedResult(receipt), secretDelivery}, + }) + this.writeValue( + output, + `Created webhook ${stringField(webhook, 'id')}.`, + flags, + ) + } +} diff --git a/src/commands/webhook/delete.ts b/src/commands/webhook/delete.ts new file mode 100644 index 00000000..e34eefbc --- /dev/null +++ b/src/commands/webhook/delete.ts @@ -0,0 +1,43 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {idempotencyFlag, yesFlag} from '../../developer/flags.js' +import {developerMutation} from '../../developer/output.js' +import {record, stringField} from '../../developer/support.js' +import {confirmDangerous} from '../../input/confirmation.js' +import {idempotencyKey} from '../../input/idempotency.js' + +export default class WebhookDelete extends BaseCommand { + static override args = { + webhook: Args.string({description: 'Webhook ID.', required: true}), + } + + static override description = 'Delete one webhook.' + + static override flags = { + 'idempotency-key': idempotencyFlag, + yes: yesFlag, + } + + async run(): Promise { + const {args, flags} = await this.parse(WebhookDelete) + await confirmDangerous({ + prompt: `Delete webhook ${args.webhook}?`, + yes: flags.yes, + }) + const session = await this.developerSession(flags) + const response = await session.client.webhooks.delete(args.webhook, { + ...session.requestOptions, + idempotencyKey: idempotencyKey(flags['idempotency-key']), + }) + const receipt = record(response.data, 'Webhook deletion receipt') + const webhookId = stringField(receipt, 'webhookId') + const output = developerMutation({ + context: session.context, + metadata: response.metadata, + receipt, + resource: {id: webhookId, type: 'webhook'}, + }) + this.writeValue(output, `Deleted webhook ${webhookId}.`, flags) + } +} diff --git a/src/commands/webhook/delivery/attempt/list.ts b/src/commands/webhook/delivery/attempt/list.ts new file mode 100644 index 00000000..04df1f75 --- /dev/null +++ b/src/commands/webhook/delivery/attempt/list.ts @@ -0,0 +1,38 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../../base-command.js' +import {paginationFlags} from '../../../../developer/flags.js' +import {developerRead} from '../../../../developer/output.js' +import {listResources, paginationInput} from '../../../../developer/support.js' + +export default class WebhookDeliveryAttemptList extends BaseCommand { + static override args = { + delivery: Args.string({description: 'Delivery ID.', required: true}), + } + + static override description = + 'List immutable attempts for a webhook delivery.' + static override flags = paginationFlags + + async run(): Promise { + const {args, flags} = await this.parse(WebhookDeliveryAttemptList) + const session = await this.developerSession(flags) + const result = await listResources(paginationInput(flags), (page) => + session.client.webhookDeliveries.listAttempts( + args.delivery, + page, + session.requestOptions, + ), + ) + this.writeValue( + developerRead( + result.items, + result.metadata, + session.context, + result.page, + ), + `${result.items.length} delivery attempt(s).`, + flags, + ) + } +} diff --git a/src/commands/webhook/delivery/attempt/view.ts b/src/commands/webhook/delivery/attempt/view.ts new file mode 100644 index 00000000..3940df7c --- /dev/null +++ b/src/commands/webhook/delivery/attempt/view.ts @@ -0,0 +1,29 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../../base-command.js' +import {developerResponse} from '../../../../developer/output.js' +import {renderAttemptDetail} from '../../../../developer/render.js' + +export default class WebhookDeliveryAttemptView extends BaseCommand { + static override args = { + delivery: Args.string({description: 'Delivery ID.', required: true}), + attempt: Args.string({description: 'Attempt ID.', required: true}), + } + + static override description = 'View one immutable webhook delivery attempt.' + + async run(): Promise { + const {args, flags} = await this.parse(WebhookDeliveryAttemptView) + const session = await this.developerSession(flags) + const response = await session.client.webhookDeliveries.retrieveAttempt( + args.delivery, + args.attempt, + session.requestOptions, + ) + this.writeValue( + developerResponse(response, session.context), + renderAttemptDetail(response.data), + flags, + ) + } +} diff --git a/src/commands/webhook/delivery/list.ts b/src/commands/webhook/delivery/list.ts new file mode 100644 index 00000000..d649e9b1 --- /dev/null +++ b/src/commands/webhook/delivery/list.ts @@ -0,0 +1,69 @@ +import {Flags} from '@oclif/core' +import type {WebhookDeliveryStatus} from '@polymorfa/sdk' + +import {BaseCommand} from '../../../base-command.js' +import {paginationFlags} from '../../../developer/flags.js' +import {developerRead} from '../../../developer/output.js' +import { + assertCursorNotCombined, + listResources, + paginationInput, +} from '../../../developer/support.js' +import {optionalTimeRange} from '../../../input/time.js' + +export default class WebhookDeliveryList extends BaseCommand { + static override description = 'List webhook deliveries.' + + static override flags = { + ...paginationFlags, + event: Flags.string({description: 'Filter by event ID.'}), + since: Flags.string({ + description: 'Include deliveries at or after this RFC 3339 time.', + }), + status: Flags.string({ + description: 'Filter by delivery status.', + options: ['pending', 'delivering', 'retrying', 'succeeded', 'failed'], + }), + until: Flags.string({ + description: 'Exclude deliveries at or after this RFC 3339 time.', + }), + webhook: Flags.string({description: 'Filter by webhook ID.'}), + } + + async run(): Promise { + const {flags} = await this.parse(WebhookDeliveryList) + assertCursorNotCombined(flags.cursor, [ + flags.event, + flags.since, + flags.status, + flags.until, + flags.webhook, + ]) + const session = await this.developerSession(flags) + const time = optionalTimeRange(flags.since, flags.until) + const result = await listResources(paginationInput(flags), (page) => + session.client.webhookDeliveries.list( + { + ...page, + ...time, + ...(flags.event === undefined ? {} : {eventId: flags.event}), + ...(flags.status === undefined + ? {} + : {status: flags.status as WebhookDeliveryStatus}), + ...(flags.webhook === undefined ? {} : {webhookId: flags.webhook}), + }, + session.requestOptions, + ), + ) + this.writeValue( + developerRead( + result.items, + result.metadata, + session.context, + result.page, + ), + `${result.items.length} webhook delivery or deliveries.`, + flags, + ) + } +} diff --git a/src/commands/webhook/delivery/retry.ts b/src/commands/webhook/delivery/retry.ts new file mode 100644 index 00000000..863cd20a --- /dev/null +++ b/src/commands/webhook/delivery/retry.ts @@ -0,0 +1,48 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {idempotencyFlag, yesFlag} from '../../../developer/flags.js' +import {developerMutation} from '../../../developer/output.js' +import {record, stringField} from '../../../developer/support.js' +import {confirmDangerous} from '../../../input/confirmation.js' +import {idempotencyKey} from '../../../input/idempotency.js' + +export default class WebhookDeliveryRetry extends BaseCommand { + static override args = { + delivery: Args.string({description: 'Delivery ID.', required: true}), + } + + static override description = + 'Append a new immutable attempt to a webhook delivery.' + + static override flags = { + 'idempotency-key': idempotencyFlag, + yes: yesFlag, + } + + async run(): Promise { + const {args, flags} = await this.parse(WebhookDeliveryRetry) + await confirmDangerous({ + prompt: `Retry webhook delivery ${args.delivery}?`, + yes: flags.yes, + }) + const session = await this.developerSession(flags) + const response = await session.client.webhookDeliveries.retry( + args.delivery, + {}, + { + ...session.requestOptions, + idempotencyKey: idempotencyKey(flags['idempotency-key']), + }, + ) + const receipt = record(response.data, 'Webhook delivery retry receipt') + const deliveryId = stringField(receipt, 'deliveryId') + const output = developerMutation({ + context: session.context, + metadata: response.metadata, + receipt, + resource: {id: deliveryId, type: 'webhook_delivery'}, + }) + this.writeValue(output, `Queued retry for delivery ${deliveryId}.`, flags) + } +} diff --git a/src/commands/webhook/delivery/view.ts b/src/commands/webhook/delivery/view.ts new file mode 100644 index 00000000..9f9cdca0 --- /dev/null +++ b/src/commands/webhook/delivery/view.ts @@ -0,0 +1,27 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {developerResponse} from '../../../developer/output.js' +import {renderDeliveryDetail} from '../../../developer/render.js' + +export default class WebhookDeliveryView extends BaseCommand { + static override args = { + delivery: Args.string({description: 'Delivery ID.', required: true}), + } + + static override description = 'View one webhook delivery.' + + async run(): Promise { + const {args, flags} = await this.parse(WebhookDeliveryView) + const session = await this.developerSession(flags) + const response = await session.client.webhookDeliveries.retrieve( + args.delivery, + session.requestOptions, + ) + this.writeValue( + developerResponse(response, session.context), + renderDeliveryDetail(response.data), + flags, + ) + } +} diff --git a/src/commands/webhook/list.ts b/src/commands/webhook/list.ts new file mode 100644 index 00000000..03df6f47 --- /dev/null +++ b/src/commands/webhook/list.ts @@ -0,0 +1,25 @@ +import {BaseCommand} from '../../base-command.js' +import {paginationFlags} from '../../developer/flags.js' +import {developerRead} from '../../developer/output.js' +import {listResources, paginationInput} from '../../developer/support.js' + +export default class WebhookList extends BaseCommand { + static override description = 'List webhooks for an explicit resource owner.' + + static override flags = paginationFlags + + async run(): Promise { + const {flags} = await this.parse(WebhookList) + const session = await this.developerSession(flags) + const result = await listResources(paginationInput(flags), (page) => + session.client.webhooks.list(page, session.requestOptions), + ) + const output = developerRead( + result.items, + result.metadata, + session.context, + result.page, + ) + this.writeValue(output, `${result.items.length} webhook(s).`, flags) + } +} diff --git a/src/commands/webhook/secret/rotate.ts b/src/commands/webhook/secret/rotate.ts new file mode 100644 index 00000000..88cb7d89 --- /dev/null +++ b/src/commands/webhook/secret/rotate.ts @@ -0,0 +1,98 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {idempotencyFlag, yesFlag} from '../../../developer/flags.js' +import {developerMutation, sanitizedResult} from '../../../developer/output.js' +import {prepareOneTimeSecretDelivery} from '../../../developer/secrets.js' +import {record, stringField} from '../../../developer/support.js' +import {CliConditionError} from '../../../errors/conditions.js' +import {confirmDangerous} from '../../../input/confirmation.js' +import {parseBoundedDurationSeconds} from '../../../input/duration.js' +import {idempotencyKey} from '../../../input/idempotency.js' + +export default class WebhookSecretRotate extends BaseCommand { + static override args = { + webhook: Args.string({description: 'Webhook ID.', required: true}), + } + + static override description = 'Rotate a webhook signing secret.' + + static override flags = { + 'idempotency-key': idempotencyFlag, + overlap: Flags.string({ + description: 'Keep the previous secret valid for this duration.', + }), + 'secret-out': Flags.string({ + description: 'Write the one-time signing secret to a new mode-0600 file.', + }), + yes: yesFlag, + } + + async run(): Promise { + const {args, flags} = await this.parse(WebhookSecretRotate) + await confirmDangerous({ + prompt: `Rotate the signing secret for webhook ${args.webhook}?`, + yes: flags.yes, + }) + const delivery = await prepareOneTimeSecretDelivery({ + json: flags.json === true, + ...(flags['secret-out'] === undefined ? {} : {path: flags['secret-out']}), + }) + const session = await this.developerSession(flags) + const response = await session.client.webhooks.rotateSecret( + args.webhook, + flags.overlap === undefined + ? {} + : {overlapSeconds: parseBoundedDurationSeconds(flags.overlap, 86_400)}, + { + ...session.requestOptions, + idempotencyKey: idempotencyKey(flags['idempotency-key']), + }, + ) + const receipt = record(response.data, 'Webhook secret rotation receipt') + const secret = + receipt.secret === null || receipt.secret === undefined + ? null + : stringField(receipt, 'secret') + if (secret !== null) this.registerSecret(secret) + const webhookId = stringField(receipt, 'webhookId') + let secretDelivery + try { + secretDelivery = await delivery.deliver(secret) + } catch (error) { + const reason = error instanceof Error ? error.message : String(error) + const output = developerMutation({ + context: session.context, + metadata: response.metadata, + receipt, + resource: {id: webhookId, type: 'webhook'}, + result: { + ...sanitizedResult(receipt), + secretDelivery: { + mode: delivery.mode, + ...(delivery.path === undefined ? {} : {path: delivery.path}), + written: false, + }, + }, + }) + this.writeValue( + output, + `Webhook rotated, but its one-time signing secret could not be written: ${reason}`, + flags, + ) + throw new CliConditionError( + 'partial_success', + `Webhook rotated, but its one-time signing secret could not be written: ${reason}. Rotate again with a new idempotency key.`, + {cause: error}, + ) + } + const output = developerMutation({ + context: session.context, + metadata: response.metadata, + receipt, + resource: {id: webhookId, type: 'webhook'}, + result: {...sanitizedResult(receipt), secretDelivery}, + }) + this.writeValue(output, `Rotated webhook ${webhookId}.`, flags) + } +} diff --git a/src/commands/webhook/test.ts b/src/commands/webhook/test.ts new file mode 100644 index 00000000..0cecbf90 --- /dev/null +++ b/src/commands/webhook/test.ts @@ -0,0 +1,50 @@ +import {Args, Flags} from '@oclif/core' + +import {readJsonInput} from '../../api/request.js' +import {BaseCommand} from '../../base-command.js' +import {idempotencyFlag} from '../../developer/flags.js' +import {developerMutation} from '../../developer/output.js' +import {record, stringField} from '../../developer/support.js' +import {idempotencyKey} from '../../input/idempotency.js' + +export default class WebhookTest extends BaseCommand { + static override args = { + webhook: Args.string({description: 'Webhook ID.', required: true}), + } + + static override description = 'Create a durable synthetic webhook delivery.' + + static override flags = { + event: Flags.string({description: 'Synthetic event type.', required: true}), + 'idempotency-key': idempotencyFlag, + input: Flags.string({ + description: + 'Read an optional TestWebhookInput JSON object from a file or stdin (-).', + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(WebhookTest) + const session = await this.developerSession(flags) + const input = + flags.input === undefined + ? {eventType: flags.event} + : { + ...record(await readJsonInput(flags.input), 'Webhook test input'), + eventType: flags.event, + } + const response = await session.client.webhooks.test(args.webhook, input, { + ...session.requestOptions, + idempotencyKey: idempotencyKey(flags['idempotency-key']), + }) + const receipt = record(response.data, 'Webhook test receipt') + const deliveryId = stringField(receipt, 'deliveryId') + const output = developerMutation({ + context: session.context, + metadata: response.metadata, + receipt, + resource: {id: deliveryId, type: 'webhook_delivery'}, + }) + this.writeValue(output, `Created test delivery ${deliveryId}.`, flags) + } +} diff --git a/src/commands/webhook/update.ts b/src/commands/webhook/update.ts new file mode 100644 index 00000000..3bfb9b46 --- /dev/null +++ b/src/commands/webhook/update.ts @@ -0,0 +1,51 @@ +import {Args, Flags} from '@oclif/core' + +import {readJsonInput} from '../../api/request.js' +import {BaseCommand} from '../../base-command.js' +import {idempotencyFlag} from '../../developer/flags.js' +import {developerMutation} from '../../developer/output.js' +import {record, stringField} from '../../developer/support.js' +import {idempotencyKey} from '../../input/idempotency.js' + +export default class WebhookUpdate extends BaseCommand { + static override args = { + webhook: Args.string({description: 'Webhook ID.', required: true}), + } + + static override description = 'Update a webhook without rotating its secret.' + + static override flags = { + 'idempotency-key': idempotencyFlag, + input: Flags.string({ + description: + 'Read the webhook update JSON object from a file or stdin (-).', + required: true, + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(WebhookUpdate) + const session = await this.developerSession(flags) + const response = await session.client.webhooks.update( + args.webhook, + record(await readJsonInput(flags.input), 'Webhook input'), + { + ...session.requestOptions, + idempotencyKey: idempotencyKey(flags['idempotency-key']), + }, + ) + const receipt = record(response.data, 'Webhook mutation receipt') + const webhook = record(receipt.webhook, 'Webhook') + const output = developerMutation({ + context: session.context, + metadata: response.metadata, + receipt, + resource: {id: stringField(webhook, 'id'), type: 'webhook'}, + }) + this.writeValue( + output, + `Updated webhook ${stringField(webhook, 'id')}.`, + flags, + ) + } +} diff --git a/src/commands/webhook/view.ts b/src/commands/webhook/view.ts new file mode 100644 index 00000000..27ec4c3f --- /dev/null +++ b/src/commands/webhook/view.ts @@ -0,0 +1,27 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import {developerResponse} from '../../developer/output.js' +import {renderWebhookDetail} from '../../developer/render.js' + +export default class WebhookView extends BaseCommand { + static override args = { + webhook: Args.string({description: 'Webhook ID.', required: true}), + } + + static override description = 'View one webhook.' + + async run(): Promise { + const {args, flags} = await this.parse(WebhookView) + const session = await this.developerSession(flags) + const response = await session.client.webhooks.retrieve( + args.webhook, + session.requestOptions, + ) + this.writeValue( + developerResponse(response, session.context), + renderWebhookDetail(response.data), + flags, + ) + } +} diff --git a/src/config/credentials.ts b/src/config/credentials.ts index c3e49ae8..a8fe4a18 100644 --- a/src/config/credentials.ts +++ b/src/config/credentials.ts @@ -1,5 +1,8 @@ import {AsyncEntry} from '@napi-rs/keyring' -import {PolymorfaConfigurationError} from '@polymorfa/sdk' +import { + PolymorfaConfigurationError, + validateClientCredential, +} from '@polymorfa/sdk' const KEYRING_SERVICE = 'com.polymorfa.cli.credentials' @@ -56,8 +59,16 @@ export class SecureCredentialStore implements CredentialStore { } } -export function profileCredentialAccount(profile: string): string { - return `profile:${profile}:apiKey` +export function profileCredentialAccount( + profile: string, + kind: 'apiKey' | 'projectToken' = 'apiKey', +): string { + return `profile:${profile}:${kind}` +} + +export function validateProjectToken(value: string): string { + const token = value.trim() + return validateClientCredential({type: 'projectToken', value: token}).value } function keyringError( diff --git a/src/config/profiles.ts b/src/config/profiles.ts index c0f152e8..b0a791c4 100644 --- a/src/config/profiles.ts +++ b/src/config/profiles.ts @@ -14,13 +14,14 @@ import {defaultConfigDirectory} from './paths.js' export interface CredentialReference { readonly account: string - readonly kind: 'apiKey' + readonly kind: 'apiKey' | 'projectToken' } export interface ProfileMetadata { readonly apiVersion?: string readonly baseUrl?: string readonly credential: CredentialReference + readonly owner?: 'organization' | 'project' readonly organization?: string readonly project?: string } @@ -28,10 +29,10 @@ export interface ProfileMetadata { export interface ProfileConfig { readonly activeProfile?: string readonly profiles: Readonly> - readonly version: 1 + readonly version: 2 } -const EMPTY_CONFIG: ProfileConfig = {profiles: {}, version: 1} +const EMPTY_CONFIG: ProfileConfig = {profiles: {}, version: 2} export class ProfileRepository { readonly path: string @@ -94,7 +95,7 @@ export class ProfileRepository { function validateConfig(value: unknown, path: string): ProfileConfig { if (!isRecord(value)) return malformed(path) - if (value.version !== 1) { + if (value.version !== 1 && value.version !== 2) { const version = typeof value.version === 'number' || typeof value.version === 'string' ? String(value.version) @@ -108,7 +109,8 @@ function validateConfig(value: unknown, path: string): ProfileConfig { if (!isRecord(value.profiles)) return malformed(path) const profiles: Record = {} for (const [name, profile] of Object.entries(value.profiles)) { - if (!validProfileName(name) || !isProfile(profile)) return malformed(path) + if (!validProfileName(name) || !isProfile(profile, value.version)) + return malformed(path) profiles[name] = profile } @@ -121,7 +123,7 @@ function validateConfig(value: unknown, path: string): ProfileConfig { } return { - version: 1, + version: 2, profiles, ...(value.activeProfile === undefined ? {} @@ -129,12 +131,13 @@ function validateConfig(value: unknown, path: string): ProfileConfig { } } -function isProfile(value: unknown): value is ProfileMetadata { +function isProfile(value: unknown, version: 1 | 2): value is ProfileMetadata { if (!isRecord(value) || !isRecord(value.credential)) return false const profileKeys = new Set([ 'apiVersion', 'baseUrl', 'credential', + 'owner', 'organization', 'project', ]) @@ -147,12 +150,31 @@ function isProfile(value: unknown): value is ProfileMetadata { return false } if ( - value.credential.kind !== 'apiKey' || + (value.credential.kind !== 'apiKey' && + (version === 1 || value.credential.kind !== 'projectToken')) || typeof value.credential.account !== 'string' ) { return false } + if ( + value.owner !== undefined && + value.owner !== 'organization' && + value.owner !== 'project' + ) { + return false + } + if ( + value.credential.kind === 'projectToken' && + (value.owner !== 'project' || + typeof value.project !== 'string' || + value.project.length === 0) + ) { + return false + } + if (value.owner === 'organization' && value.project !== undefined) + return false + return ['apiVersion', 'baseUrl', 'organization', 'project'].every( (key) => value[key] === undefined || typeof value[key] === 'string', ) diff --git a/src/context/environment.ts b/src/context/environment.ts index 8d55c5e3..1f1554b6 100644 --- a/src/context/environment.ts +++ b/src/context/environment.ts @@ -1,5 +1,7 @@ import { + Client, PolymorfaConfigurationError, + type ProjectWithStats, type MessagingCredential, } from '@polymorfa/sdk' @@ -8,6 +10,8 @@ import { type CredentialStore, } from '../config/credentials.js' import {ProfileRepository} from '../config/profiles.js' +import {resolveOwnerContext} from './owner.js' +import type {OutputContext} from '../output/contracts.js' import type {ProfileRepositoryLike} from '../services/auth.js' export type ApiSurface = 'messaging' | 'platform' @@ -33,6 +37,335 @@ export interface ResolveCommandEnvironmentInput extends ResolveEnvironmentInput readonly profile?: string | undefined } +export interface ListenerEnvironment { + readonly apiVersion?: string + readonly baseUrl: string + readonly credential: string + readonly principalType: 'organization_key' | 'project_token' + readonly profile?: string + readonly projectId: string + readonly timeoutMs?: number +} + +export interface ResolveListenerEnvironmentInput { + readonly apiVersion?: string | undefined + readonly env: Readonly> + readonly profile?: string | undefined + readonly project?: string | undefined + readonly timeoutMs?: number | undefined +} + +export interface DeveloperEnvironment { + readonly apiVersion?: string + readonly baseUrl: string + readonly context: OutputContext + readonly credential: string + readonly timeoutMs?: number +} + +export interface ResolveDeveloperEnvironmentInput { + readonly apiVersion?: string | undefined + readonly env: Readonly> + readonly owner?: string | undefined + readonly profile?: string | undefined + readonly project?: string | undefined + readonly timeoutMs?: number | undefined +} + +export type ProjectLookup = (input: { + readonly apiVersion?: string + readonly baseUrl: string + readonly credential: string + readonly timeoutMs?: number +}) => Promise + +export async function resolveDeveloperEnvironment( + input: ResolveDeveloperEnvironmentInput, + profiles: ProfileRepositoryLike = new ProfileRepository(), + credentials: CredentialStore = new SecureCredentialStore(), + projectLookup: ProjectLookup = listProjects, +): Promise { + const explicitProfile = nonEmpty(input.profile) + const environmentCredential = + explicitProfile === undefined + ? durableEnvironmentCredential(input.env) + : undefined + if (explicitProfile === undefined && environmentCredential !== undefined) { + const baseUrl = + nonEmpty(input.env.POLYMORFA_BASE_URL) ?? 'https://api.polymorfa.com' + const requestedProject = nonEmpty(input.project) + const unresolvedContext = resolveOwnerContext({ + principalType: environmentCredential.principalType, + ...(input.owner === undefined ? {} : {owner: input.owner}), + ...(requestedProject === undefined ? {} : {projectId: requestedProject}), + }) + const projectId = await canonicalProjectId( + unresolvedContext.projectId ?? undefined, + environmentCredential.principalType, + { + ...(input.apiVersion === undefined + ? {} + : {apiVersion: input.apiVersion}), + baseUrl, + credential: environmentCredential.value, + ...(input.timeoutMs === undefined ? {} : {timeoutMs: input.timeoutMs}), + }, + projectLookup, + ) + const context = + projectId === undefined + ? unresolvedContext + : {...unresolvedContext, projectId} + return { + baseUrl, + context, + credential: environmentCredential.value, + ...(input.apiVersion === undefined ? {} : {apiVersion: input.apiVersion}), + ...(input.timeoutMs === undefined ? {} : {timeoutMs: input.timeoutMs}), + } + } + + const config = await profiles.load() + const selected = explicitProfile ?? config.activeProfile + if (selected === undefined) { + if (nonEmpty(input.env.POLYMORFA_CLIENT_TOKEN) !== undefined) { + throw new PolymorfaConfigurationError( + 'Browser client tokens cannot access developer resources.', + 'credential', + ) + } + throw new PolymorfaConfigurationError( + 'Developer commands require POLYMORFA_API_KEY, POLYMORFA_PROJECT_TOKEN, or an authentication profile.', + 'credential', + ) + } + const profile = config.profiles[selected] + if (profile === undefined) { + throw new PolymorfaConfigurationError( + `Profile ${selected} does not exist.`, + 'profile', + ) + } + const credential = await credentials.get(profile.credential.account) + if (credential === undefined) { + throw new PolymorfaConfigurationError( + `Profile ${selected} has no credential in the operating-system keyring.`, + 'keyring', + ) + } + const principalType = + profile.credential.kind === 'projectToken' + ? 'project_token' + : 'organization_key' + const owner = input.owner ?? profile.owner + const explicitProject = nonEmpty(input.project) + if ( + principalType === 'project_token' && + explicitProject !== undefined && + profile.project !== explicitProject + ) { + throw new PolymorfaConfigurationError( + 'A project-token profile cannot be used with a different --project context.', + 'project', + ) + } + const unresolvedProject = + owner === 'project' ? (explicitProject ?? profile.project) : explicitProject + const baseUrl = + nonEmpty(input.env.POLYMORFA_BASE_URL) ?? + profile.baseUrl ?? + 'https://api.polymorfa.com' + const apiVersion = input.apiVersion ?? profile.apiVersion + const unresolvedContext = resolveOwnerContext({ + principalType, + profile: selected, + ...(owner === undefined ? {} : {owner}), + ...(unresolvedProject === undefined ? {} : {projectId: unresolvedProject}), + }) + const projectId = await canonicalProjectId( + unresolvedContext.projectId ?? undefined, + principalType, + { + ...(apiVersion === undefined ? {} : {apiVersion}), + baseUrl, + credential, + ...(input.timeoutMs === undefined ? {} : {timeoutMs: input.timeoutMs}), + }, + projectLookup, + ) + const context = + projectId === undefined + ? unresolvedContext + : {...unresolvedContext, projectId} + return { + baseUrl, + context, + credential, + ...(apiVersion === undefined ? {} : {apiVersion}), + ...(input.timeoutMs === undefined ? {} : {timeoutMs: input.timeoutMs}), + } +} + +export async function resolveListenerEnvironment( + input: ResolveListenerEnvironmentInput, + profiles: ProfileRepositoryLike = new ProfileRepository(), + credentials: CredentialStore = new SecureCredentialStore(), + projectLookup: ProjectLookup = listProjects, +): Promise { + const explicitProfile = nonEmpty(input.profile) + const projectToken = + explicitProfile === undefined + ? nonEmpty(input.env.POLYMORFA_PROJECT_TOKEN) + : undefined + const apiKey = + explicitProfile === undefined + ? nonEmpty(input.env.POLYMORFA_API_KEY) + : undefined + const clientToken = + explicitProfile === undefined + ? nonEmpty(input.env.POLYMORFA_CLIENT_TOKEN) + : undefined + if ([projectToken, apiKey, clientToken].filter(Boolean).length > 1) { + throw new PolymorfaConfigurationError( + 'Listener credentials are ambiguous; set only one durable principal.', + 'credential', + ) + } + const project = nonEmpty(input.project) + if (projectToken !== undefined) { + if (project === undefined) { + throw new PolymorfaConfigurationError( + 'Listening requires an explicit --project context.', + 'project', + ) + } + const baseUrl = + nonEmpty(input.env.POLYMORFA_BASE_URL) ?? 'https://api.polymorfa.com' + const projectId = await canonicalProjectId( + project, + 'project_token', + {baseUrl, credential: projectToken}, + projectLookup, + ) + return { + ...(input.apiVersion === undefined ? {} : {apiVersion: input.apiVersion}), + baseUrl, + credential: projectToken, + principalType: 'project_token', + projectId: requiredProjectId(projectId), + ...(input.timeoutMs === undefined ? {} : {timeoutMs: input.timeoutMs}), + } + } + if (clientToken !== undefined) { + throw new PolymorfaConfigurationError( + 'Browser client tokens cannot authorize CLI listeners.', + 'credential', + ) + } + if (apiKey !== undefined) { + if (project === undefined) { + throw new PolymorfaConfigurationError( + 'Listening requires an explicit --project context.', + 'project', + ) + } + const baseUrl = + nonEmpty(input.env.POLYMORFA_BASE_URL) ?? 'https://api.polymorfa.com' + const projectId = await canonicalProjectId( + project, + 'organization_key', + { + ...(input.apiVersion === undefined + ? {} + : {apiVersion: input.apiVersion}), + baseUrl, + credential: apiKey, + ...(input.timeoutMs === undefined ? {} : {timeoutMs: input.timeoutMs}), + }, + projectLookup, + ) + return { + ...(input.apiVersion === undefined ? {} : {apiVersion: input.apiVersion}), + baseUrl, + credential: apiKey, + principalType: 'organization_key', + projectId: requiredProjectId(projectId), + ...(input.timeoutMs === undefined ? {} : {timeoutMs: input.timeoutMs}), + } + } + + const config = await profiles.load() + const selected = explicitProfile ?? config.activeProfile + if (selected === undefined) { + throw new PolymorfaConfigurationError( + 'Listening requires POLYMORFA_API_KEY, POLYMORFA_PROJECT_TOKEN, or an authentication profile.', + 'credential', + ) + } + const profile = config.profiles[selected] + if (profile === undefined) { + throw new PolymorfaConfigurationError( + `Profile ${selected} does not exist.`, + 'profile', + ) + } + const storedCredential = await credentials.get(profile.credential.account) + if (storedCredential === undefined) { + throw new PolymorfaConfigurationError( + `Profile ${selected} has no credential in the operating-system keyring.`, + 'keyring', + ) + } + if ( + profile.credential.kind === 'projectToken' && + project !== undefined && + profile.project !== project + ) { + throw new PolymorfaConfigurationError( + 'A project-token profile cannot listen to a different --project context.', + 'project', + ) + } + const unresolvedProject = project ?? profile.project + if (unresolvedProject === undefined) { + throw new PolymorfaConfigurationError( + 'Listening requires an explicit --project or a project-bound profile.', + 'project', + ) + } + const principalType = + profile.credential.kind === 'projectToken' + ? 'project_token' + : 'organization_key' + const baseUrl = + nonEmpty(input.env.POLYMORFA_BASE_URL) ?? + profile.baseUrl ?? + 'https://api.polymorfa.com' + const apiVersion = input.apiVersion ?? profile.apiVersion + const resolvedProject = await canonicalProjectId( + unresolvedProject, + principalType, + { + ...(apiVersion === undefined ? {} : {apiVersion}), + baseUrl, + credential: storedCredential, + ...(input.timeoutMs === undefined ? {} : {timeoutMs: input.timeoutMs}), + }, + projectLookup, + ) + return { + ...((input.apiVersion ?? profile.apiVersion) === undefined + ? {} + : {apiVersion: input.apiVersion ?? profile.apiVersion}), + baseUrl, + credential: storedCredential, + principalType, + projectId: requiredProjectId(resolvedProject), + profile: selected, + ...(input.timeoutMs === undefined ? {} : {timeoutMs: input.timeoutMs}), + } +} + export async function resolveCommandEnvironment( input: ResolveCommandEnvironmentInput, profiles: ProfileRepositoryLike = new ProfileRepository(), @@ -64,6 +397,13 @@ export async function resolveCommandEnvironment( ) } + if (profile.credential.kind !== 'apiKey') { + throw new PolymorfaConfigurationError( + `Profile ${selected} contains a project token, which is accepted only by project-owned developer commands and polymorfa listen.`, + 'credential', + ) + } + const apiKey = await credentials.get(profile.credential.account) if (apiKey === undefined) { throw new PolymorfaConfigurationError( @@ -141,3 +481,96 @@ function nonEmpty(value: string | undefined): string | undefined { const trimmed = value?.trim() return trimmed === undefined || trimmed.length === 0 ? undefined : trimmed } + +function durableEnvironmentCredential( + env: Readonly>, +): + | { + readonly principalType: 'organization_key' | 'project_token' + readonly value: string + } + | undefined { + const projectToken = nonEmpty(env.POLYMORFA_PROJECT_TOKEN) + const apiKey = nonEmpty(env.POLYMORFA_API_KEY) + const clientToken = nonEmpty(env.POLYMORFA_CLIENT_TOKEN) + if ([projectToken, apiKey, clientToken].filter(Boolean).length > 1) { + throw new PolymorfaConfigurationError( + 'Developer credentials are ambiguous; set only one credential environment variable.', + 'credential', + ) + } + if (clientToken !== undefined) { + throw new PolymorfaConfigurationError( + 'Browser client tokens cannot access developer resources.', + 'credential', + ) + } + if (projectToken !== undefined) { + return {principalType: 'project_token', value: projectToken} + } + return apiKey === undefined + ? undefined + : {principalType: 'organization_key', value: apiKey} +} + +const PROJECT_UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +async function canonicalProjectId( + project: string | undefined, + principalType: 'organization_key' | 'project_token', + connection: Parameters[0], + lookup: ProjectLookup, +): Promise { + if (project === undefined || PROJECT_UUID.test(project)) return project + if (principalType === 'project_token') { + throw new PolymorfaConfigurationError( + 'Project-token contexts require an exact project UUID; tokens cannot resolve project slugs.', + 'project', + ) + } + const matches = (await lookup(connection)).filter( + (candidate) => candidate.slug === project, + ) + if (matches.length === 0) { + throw new PolymorfaConfigurationError( + `Project slug ${project} was not found in the organization.`, + 'project', + ) + } + if (matches.length > 1) { + throw new PolymorfaConfigurationError( + `Project slug ${project} is ambiguous in the organization.`, + 'project', + ) + } + const projectId = matches[0]?._id + if (projectId === undefined || !PROJECT_UUID.test(projectId)) { + throw new PolymorfaConfigurationError( + 'The project list returned an invalid project identifier.', + 'project', + ) + } + return projectId +} + +async function listProjects( + input: Parameters[0], +): Promise { + const client = new Client({ + baseUrl: input.baseUrl, + credential: {type: 'organizationApiKey', value: input.credential}, + ...(input.apiVersion === undefined ? {} : {apiVersion: input.apiVersion}), + ...(input.timeoutMs === undefined ? {} : {timeoutMs: input.timeoutMs}), + }) + const response = await client.projects.list() + return response.data.data +} + +function requiredProjectId(projectId: string | undefined): string { + if (projectId !== undefined) return projectId + throw new PolymorfaConfigurationError( + 'An exact project context is required.', + 'project', + ) +} diff --git a/src/context/owner.ts b/src/context/owner.ts new file mode 100644 index 00000000..c941117a --- /dev/null +++ b/src/context/owner.ts @@ -0,0 +1,53 @@ +import {PolymorfaConfigurationError} from '@polymorfa/sdk' + +import type { + OutputContext, + PrincipalType, + ResourceOwner, +} from '../output/contracts.js' + +export function resolveOwnerContext(input: { + readonly owner?: string + readonly principalType: PrincipalType + readonly organizationId?: string + readonly projectId?: string + readonly profile?: string +}): OutputContext { + if (input.owner !== 'project' && input.owner !== 'organization') { + throw new PolymorfaConfigurationError( + 'This command requires --owner project or --owner organization.', + 'owner', + ) + } + const owner: ResourceOwner = input.owner + if (owner === 'project') { + if (!input.projectId) { + throw new PolymorfaConfigurationError( + 'Project-owned commands require an explicit --project context.', + 'project', + ) + } + } else { + if (input.principalType === 'project_token') { + throw new PolymorfaConfigurationError( + 'A project token cannot access organization-owned resources.', + 'credential', + ) + } + if (input.projectId) { + throw new PolymorfaConfigurationError( + 'Organization-owned commands reject a simultaneous project override.', + 'project', + ) + } + } + return { + owner, + principalType: input.principalType, + ...(input.organizationId === undefined + ? {} + : {organizationId: input.organizationId}), + ...(input.profile === undefined ? {} : {profile: input.profile}), + ...(input.projectId === undefined ? {} : {projectId: input.projectId}), + } +} diff --git a/src/developer/client.ts b/src/developer/client.ts new file mode 100644 index 00000000..4ecbaad8 --- /dev/null +++ b/src/developer/client.ts @@ -0,0 +1,85 @@ +import { + Client, + type ApiResponse, + type ClientBase, + type ClientOwner, + type CursorPage, + type RequestOptions, + type ResponseMetadata, +} from '@polymorfa/sdk' + +import type {GlobalFlags} from '../base-command.js' +import {resolveDeveloperEnvironment} from '../context/environment.js' +import type {OutputContext} from '../output/contracts.js' + +export type MetadataLike = ResponseMetadata +export type ApiResponseLike = ApiResponse +export type CursorPageLike = CursorPage + +export interface DeveloperSession { + readonly baseUrl: string + readonly client: ClientBase + readonly context: OutputContext + readonly requestOptions: RequestOptions +} + +export async function createDeveloperSession( + flags: GlobalFlags, + registerSecret?: (secret: string) => void, +): Promise { + const environment = await resolveDeveloperEnvironment({ + env: process.env, + ...(flags['api-version'] === undefined + ? {} + : {apiVersion: flags['api-version']}), + ...(flags.owner === undefined ? {} : {owner: flags.owner}), + ...(flags.profile === undefined ? {} : {profile: flags.profile}), + ...(flags.project === undefined ? {} : {project: flags.project}), + ...(flags.timeout === undefined ? {} : {timeoutMs: flags.timeout}), + }) + registerSecret?.(environment.credential) + const root: ClientBase = + environment.context.principalType === 'project_token' + ? new Client({ + baseUrl: environment.baseUrl, + credential: { + type: 'projectToken', + value: environment.credential, + }, + projectId: requiredProject(environment.context.projectId), + ...(environment.apiVersion === undefined + ? {} + : {apiVersion: environment.apiVersion}), + ...(environment.timeoutMs === undefined + ? {} + : {timeoutMs: environment.timeoutMs}), + }) + : new Client({ + baseUrl: environment.baseUrl, + credential: { + type: 'organizationApiKey', + value: environment.credential, + }, + ...(environment.apiVersion === undefined + ? {} + : {apiVersion: environment.apiVersion}), + ...(environment.timeoutMs === undefined + ? {} + : {timeoutMs: environment.timeoutMs}), + }) + const client = + environment.context.owner === 'project' && root.owner === 'organization' + ? root.project(requiredProject(environment.context.projectId)) + : root + return { + baseUrl: environment.baseUrl, + client, + context: environment.context, + requestOptions: {}, + } +} + +function requiredProject(projectId: string | null | undefined): string { + if (projectId) return projectId + throw new Error('Project-owned commands require a project ID.') +} diff --git a/src/developer/flags.ts b/src/developer/flags.ts new file mode 100644 index 00000000..5b1c3518 --- /dev/null +++ b/src/developer/flags.ts @@ -0,0 +1,29 @@ +import {Flags} from '@oclif/core' + +export const paginationFlags = { + cursor: Flags.string({description: 'Continue from this opaque cursor.'}), + limit: Flags.integer({ + default: 25, + description: 'Request this many resources per page.', + max: 100, + min: 1, + }), + 'max-items': Flags.integer({ + description: 'Stop automatic pagination after this many resources.', + max: 10_000, + min: 1, + }), + paginate: Flags.boolean({ + description: + 'Request consecutive pages until --max-items resources are collected or no pages remain. Requires --max-items.', + }), +} + +export const idempotencyFlag = Flags.string({ + description: + 'Use this key to identify retries; one is generated when omitted.', +}) + +export const yesFlag = Flags.boolean({ + description: 'Confirm the external effect without prompting.', +}) diff --git a/src/developer/output.ts b/src/developer/output.ts new file mode 100644 index 00000000..b89d06f9 --- /dev/null +++ b/src/developer/output.ts @@ -0,0 +1,73 @@ +import type {ApiResponseLike, MetadataLike} from './client.js' +import {record, stringField} from './support.js' +import {outputRequest} from '../output/adapters.js' +import { + mutationEnvelope, + readEnvelope, + type OutputContext, + type OutputPage, + type OutputResource, +} from '../output/contracts.js' + +export function developerRead( + data: T, + metadata: MetadataLike, + context: OutputContext, + page?: OutputPage, +) { + return readEnvelope({ + context, + data, + request: outputRequest(metadata), + ...(page === undefined ? {} : {page}), + }) +} + +export function developerResponse( + response: ApiResponseLike, + context: OutputContext, +) { + return developerRead(response.data, response.metadata, context) +} + +export function developerMutation(input: { + readonly context: OutputContext + readonly metadata: MetadataLike + readonly receipt: Readonly> + readonly resource: OutputResource | null + readonly result?: Readonly> +}) { + const idempotency = idempotencyOutput(input.receipt.idempotency) + const operationId = input.receipt.operationId + const operation = typeof operationId === 'string' ? {id: operationId} : null + return mutationEnvelope({ + context: input.context, + idempotency, + operation, + request: outputRequest(input.metadata), + resource: input.resource, + result: input.result ?? sanitizedResult(input.receipt), + }) +} + +function idempotencyOutput(value: unknown) { + const receipt = record(value, 'Idempotency receipt') + if (typeof receipt.replayed !== 'boolean') { + throw new Error('Idempotency receipt field replayed was missing.') + } + return { + key: stringField(receipt, 'key'), + receiptId: stringField(receipt, 'id'), + replayed: receipt.replayed, + } +} + +export function sanitizedResult( + receipt: Readonly>, +): Readonly> { + const result = {...receipt} + delete result.idempotency + delete result.operationId + delete result.secret + return result +} diff --git a/src/developer/render.ts b/src/developer/render.ts new file mode 100644 index 00000000..c3413261 --- /dev/null +++ b/src/developer/render.ts @@ -0,0 +1,161 @@ +import type { + EncodedEventPayload, + PayloadAvailability, + WebhookDeliveryAttemptResponse, + WebhookDeliveryStatus, +} from '@polymorfa/sdk' + +type Row = readonly [label: string, value: string | number | boolean | null] + +export function renderEventDetail( + event: { + readonly id: string + readonly type: string + readonly source: string + readonly environment: string + readonly projectId: string | null + readonly createdAt: string + readonly payloadAvailability: PayloadAvailability + readonly payload: EncodedEventPayload | null + readonly replayableUntil: string | null + }, + includePayload: boolean, +): string { + const rows: Row[] = [ + ['ID', event.id], + ['Type', event.type], + ['Source', event.source], + ['Environment', event.environment], + ['Project', event.projectId ?? 'organization'], + ['Created', event.createdAt], + ['Replayable until', event.replayableUntil], + ['Payload availability', event.payloadAvailability], + ] + if (includePayload) rows.push(['Payload', payloadText(event.payload)]) + return table(rows) +} + +export function renderWebhookDetail(webhook: { + readonly id: string + readonly owner: string + readonly projectId: string | null + readonly url: string + readonly enabled: boolean + readonly format: string + readonly eventTypes: readonly string[] + readonly headers: readonly {readonly name: string}[] + readonly createdAt: string + readonly updatedAt: string +}): string { + return table([ + ['ID', webhook.id], + ['Owner', webhook.owner], + ['Project', webhook.projectId], + ['URL', webhook.url], + ['Enabled', webhook.enabled], + ['Format', webhook.format], + ['Event types', webhook.eventTypes.join(', ')], + ['Header names', webhook.headers.map(({name}) => name).join(', ')], + ['Created', webhook.createdAt], + ['Updated', webhook.updatedAt], + ]) +} + +export function renderDeliveryDetail(delivery: { + readonly id: string + readonly status: WebhookDeliveryStatus + readonly webhookId: string + readonly eventId: string + readonly attemptCount: number + readonly capabilities: {readonly retryable: boolean} + readonly lastOutcome: { + readonly statusCode: number | null + readonly errorCode: string | null + } | null + readonly lastAttemptAt: string | null + readonly nextAttemptAt: string | null + readonly completedAt: string | null + readonly createdAt: string +}): string { + return table([ + ['ID', delivery.id], + ['Status', delivery.status], + ['Webhook', delivery.webhookId], + ['Event', delivery.eventId], + ['Attempts', delivery.attemptCount], + ['Retryable', delivery.capabilities.retryable], + ['Last status code', delivery.lastOutcome?.statusCode ?? null], + ['Last error code', delivery.lastOutcome?.errorCode ?? null], + ['Last attempt', delivery.lastAttemptAt], + ['Next attempt', delivery.nextAttemptAt], + ['Completed', delivery.completedAt], + ['Created', delivery.createdAt], + ]) +} + +export function renderAttemptDetail(attempt: { + readonly id: string + readonly deliveryId: string + readonly number: number + readonly status: string + readonly startedAt: string | null + readonly completedAt: string | null + readonly durationMs: number | null + readonly statusCode: number | null + readonly errorCode: string | null + readonly nextRetryAt: string | null + readonly response: WebhookDeliveryAttemptResponse | null +}): string { + const rows: Row[] = [ + ['ID', attempt.id], + ['Delivery', attempt.deliveryId], + ['Number', attempt.number], + ['Status', attempt.status], + ['Started', attempt.startedAt], + ['Completed', attempt.completedAt], + ['Duration ms', attempt.durationMs], + ['Status code', attempt.statusCode], + ['Error code', attempt.errorCode], + ['Next retry', attempt.nextRetryAt], + ] + // Deployments before the excerpt field was added omit it entirely. + const response = attempt.response as + WebhookDeliveryAttemptResponse | null | undefined + if (response) { + rows.push( + ['Response content type', response.contentType], + [ + 'Response excerpt', + `${response.excerpt}${response.truncated ? ' (truncated)' : ''}`, + ], + ) + } + return table(rows) +} + +function payloadText(payload: EncodedEventPayload | null): string { + if (payload === null) return 'not included' + const text = Buffer.from(payload.data, 'base64').toString('utf8') + try { + return JSON.stringify(JSON.parse(text) as unknown) + } catch { + return text + } +} + +function table(rows: readonly Row[]): string { + return rows + .map(([label, value]) => `${label}\t${terminalText(value)}`) + .join('\n') +} + +/** Escape control characters so remote text cannot drive the terminal. */ +export function terminalText(value: string | number | boolean | null): string { + if (value === null) return '-' + return String(value).replace( + // eslint-disable-next-line no-control-regex + /[\u0000-\u001f\u007f-\u009f\u2028\u2029]/g, + (character) => + `\\u${character.charCodeAt(0).toString(16).padStart(4, '0')}`, + ) +} diff --git a/src/developer/secrets.ts b/src/developer/secrets.ts new file mode 100644 index 00000000..96853e64 --- /dev/null +++ b/src/developer/secrets.ts @@ -0,0 +1,65 @@ +import {PolymorfaValidationError} from '@polymorfa/sdk' +import type {Writable} from 'node:stream' + +import {prepareSecretFile} from '../security/secret-file.js' + +export interface SecretDeliveryResult { + readonly mode: 'file' | 'tty' + readonly path?: string + readonly written: boolean +} + +export interface PreparedOneTimeSecretDelivery { + readonly mode: 'file' | 'tty' + readonly path?: string + deliver(secret: string | null): Promise +} + +export async function prepareOneTimeSecretDelivery(input: { + readonly interactive?: boolean + readonly json: boolean + readonly output?: Writable + readonly path?: string +}): Promise { + if (input.path !== undefined) { + if (input.path.trim().length === 0) { + throw new PolymorfaValidationError( + 'The secret output path must not be empty.', + {code: 'secret_output_required'}, + ) + } + const prepared = await prepareSecretFile(input.path) + return { + mode: 'file', + path: prepared.path, + async deliver(secret) { + if (secret === null) { + return {mode: 'file', path: prepared.path, written: false} + } + await prepared.write(secret) + return {mode: 'file', path: prepared.path, written: true} + }, + } + } + + const interactive = + input.interactive ?? + (process.stdin.isTTY === true && process.stderr.isTTY === true) + if (input.json || !interactive) { + throw new PolymorfaValidationError( + 'JSON and noninteractive webhook secret delivery require --secret-out.', + {code: 'secret_output_required'}, + ) + } + + const output = input.output ?? process.stderr + return { + mode: 'tty', + deliver(secret) { + if (secret === null) + return Promise.resolve({mode: 'tty' as const, written: false}) + output.write(`Webhook signing secret (shown once): ${secret}\n`) + return Promise.resolve({mode: 'tty' as const, written: true}) + }, + } +} diff --git a/src/developer/support.ts b/src/developer/support.ts new file mode 100644 index 00000000..90341757 --- /dev/null +++ b/src/developer/support.ts @@ -0,0 +1,148 @@ +import {PolymorfaServerError, PolymorfaValidationError} from '@polymorfa/sdk' + +import type {CursorPageLike, MetadataLike} from './client.js' +import type {OutputPage} from '../output/contracts.js' + +export interface PaginationInput { + readonly cursor?: string + readonly limit?: number + readonly maxItems?: number + readonly paginate?: boolean +} + +export function assertCursorNotCombined( + cursor: string | undefined, + filters: readonly unknown[], +): void { + if ( + cursor !== undefined && + filters.some((value) => value !== undefined && value !== null) + ) { + throw invalid('--cursor cannot be combined with list filters.') + } +} + +export function paginationInput(flags: { + readonly cursor?: string | undefined + readonly limit?: number | undefined + readonly 'max-items'?: number | undefined + readonly paginate?: boolean | undefined +}): PaginationInput { + return { + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags['max-items'] === undefined ? {} : {maxItems: flags['max-items']}), + ...(flags.paginate === undefined ? {} : {paginate: flags.paginate}), + } +} + +export async function listResources( + flags: PaginationInput, + fetchPage: (params: { + readonly cursor?: string + readonly limit: number + }) => Promise>, +): Promise<{ + readonly items: readonly T[] + readonly metadata: MetadataLike + readonly page: OutputPage +}> { + validatePagination(flags) + const limit = flags.limit ?? 25 + if (!flags.paginate) { + const response = await fetchPage({ + limit, + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + }) + return { + items: response.items, + metadata: response.response.metadata, + page: { + hasMore: response.hasMore, + nextCursor: response.nextCursor ?? null, + }, + } + } + + const maxItems = flags.maxItems as number + const items: T[] = [] + let cursor = flags.cursor + let last: CursorPageLike | undefined + while (items.length < maxItems) { + const remaining = maxItems - items.length + last = await fetchPage({ + limit: Math.min(limit, remaining), + ...(cursor === undefined ? {} : {cursor}), + }) + items.push(...last.items.slice(0, remaining)) + if (last.items.length > remaining) { + // The page cursor would skip the truncated items, so do not offer it. + return { + items, + metadata: last.response.metadata, + page: {hasMore: true, nextCursor: null}, + } + } + if (!last.hasMore || !last.nextCursor || last.items.length === 0) break + assertCursorAdvanced(cursor, last.nextCursor) + cursor = last.nextCursor + } + if (!last) throw new Error('Pagination did not request a page.') + return { + items, + metadata: last.response.metadata, + page: {hasMore: last.hasMore, nextCursor: last.nextCursor ?? null}, + } +} + +export function assertCursorAdvanced( + current: string | undefined, + next: string, +): void { + if (next === current) { + throw new PolymorfaServerError( + 'The API returned the same continuation cursor for the next page.', + {code: 'pagination_cursor_unchanged'}, + ) + } +} + +export function validatePagination(flags: PaginationInput): void { + if (flags.paginate === true && flags.maxItems === undefined) { + throw invalid('--paginate requires --max-items.') + } + if (flags.paginate !== true && flags.maxItems !== undefined) { + throw invalid('--max-items requires --paginate.') + } + if ( + flags.maxItems !== undefined && + (!Number.isSafeInteger(flags.maxItems) || + flags.maxItems < 1 || + flags.maxItems > 10_000) + ) { + throw invalid('--max-items must be an integer from 1 through 10000.') + } +} + +export function record( + value: unknown, + name: string, +): Readonly> { + if (typeof value === 'object' && value !== null && !Array.isArray(value)) { + return value as Readonly> + } + throw new Error(`${name} response was not an object.`) +} + +export function stringField( + value: Readonly>, + key: string, +): string { + const field = value[key] + if (typeof field === 'string' && field.length > 0) return field + throw new Error(`Response field ${key} was missing.`) +} + +function invalid(message: string): PolymorfaValidationError { + return new PolymorfaValidationError(message, {code: 'invalid_pagination'}) +} diff --git a/src/errors/conditions.ts b/src/errors/conditions.ts new file mode 100644 index 00000000..e0763ecb --- /dev/null +++ b/src/errors/conditions.ts @@ -0,0 +1,32 @@ +export type CliCondition = + | 'action_required' + | 'stream_gap' + | 'stream_dropped' + | 'forwarding_failed' + | 'partial_success' + | 'authorization' + +const EXIT_CODES: Readonly> = { + action_required: 8, + authorization: 3, + forwarding_failed: 11, + partial_success: 12, + stream_dropped: 10, + stream_gap: 9, +} + +export class CliConditionError extends Error { + readonly condition: CliCondition + readonly exitCode: number + + constructor( + condition: CliCondition, + message: string, + options?: {readonly cause?: unknown}, + ) { + super(message, options) + this.name = 'CliConditionError' + this.condition = condition + this.exitCode = EXIT_CODES[condition] + } +} diff --git a/src/errors/present.ts b/src/errors/present.ts index 66a0da09..050b7b79 100644 --- a/src/errors/present.ts +++ b/src/errors/present.ts @@ -14,6 +14,10 @@ import { PolymorfaValidationError, } from '@polymorfa/sdk' +import {CliConditionError} from './conditions.js' +import {CheckpointError, EventFilterError} from '../listener/checkpoint.js' +import {ListenerProtocolError} from '../listener/sse.js' + export type ErrorCategory = | 'authentication' | 'authorization' @@ -28,14 +32,18 @@ export type ErrorCategory = | 'server' | 'timeout' | 'validation' + | 'stream' + | 'forwarding' + | 'operation' + | 'partial_success' export interface PresentedError { readonly category: ErrorCategory + readonly code: string readonly exitCode: number readonly message: string readonly requestId?: string - readonly code?: string - readonly retryable?: boolean + readonly retryable: boolean } export function presentError( @@ -45,19 +53,30 @@ export function presentError( const message = redact(errorMessage(error), secrets) const classified = classifyError(error) const requestId = - error instanceof PolymorfaError ? error.requestId : undefined + error instanceof PolymorfaError || error instanceof ListenerProtocolError + ? error.requestId + : undefined return { ...classified, - ...(error instanceof PolymorfaPaymentRequiredError - ? { - retryable: false, - ...(error.code === undefined - ? {} - : {code: redact(error.code, secrets)}), - } - : {}), + code: + error instanceof PolymorfaPaymentRequiredError && error.code + ? redact(error.code, secrets) + : error instanceof CliConditionError + ? error.condition + : error instanceof ListenerProtocolError || + error instanceof CheckpointError || + error instanceof EventFilterError + ? error.code + : error instanceof PolymorfaError && error.code + ? error.code + : `${classified.category}_error`, message, + retryable: + (error instanceof ListenerProtocolError && error.retryable) || + ['network', 'rate_limit', 'server', 'timeout'].includes( + classified.category, + ), ...(requestId === undefined ? {} : {requestId}), } } @@ -69,6 +88,31 @@ function classifyError( return {category: 'payment_required', exitCode: 4} } + if (error instanceof CliConditionError) { + const category = error.condition.startsWith('stream_') + ? 'stream' + : error.condition === 'forwarding_failed' + ? 'forwarding' + : error.condition === 'partial_success' + ? 'partial_success' + : error.condition === 'authorization' + ? 'authorization' + : 'operation' + return {category, exitCode: error.exitCode} + } + if (error instanceof CheckpointError || error instanceof EventFilterError) { + return {category: 'validation', exitCode: 2} + } + if (error instanceof ListenerProtocolError) { + if (error.status === 401 || error.status === 403) { + return {category: 'authorization', exitCode: 3} + } + if (error.status === 429) return {category: 'rate_limit', exitCode: 5} + if (error.status !== undefined && error.status >= 500) { + return {category: 'server', exitCode: 5} + } + return {category: 'stream', exitCode: 1} + } if (error instanceof PolymorfaConfigurationError) { return {category: 'configuration', exitCode: 2} } diff --git a/src/input/confirmation.ts b/src/input/confirmation.ts new file mode 100644 index 00000000..9f5fa7ba --- /dev/null +++ b/src/input/confirmation.ts @@ -0,0 +1,42 @@ +import {PolymorfaValidationError} from '@polymorfa/sdk' +import {createInterface} from 'node:readline/promises' +import type {Readable, Writable} from 'node:stream' + +interface TtyReadable extends Readable { + readonly isTTY?: boolean +} + +interface TtyWritable extends Writable { + readonly isTTY?: boolean +} + +export async function confirmDangerous(input: { + readonly input?: TtyReadable + readonly output?: TtyWritable + readonly prompt: string + readonly yes: boolean +}): Promise { + if (input.yes) return + const source = input.input ?? process.stdin + const output = input.output ?? process.stderr + if (source.isTTY !== true || output.isTTY !== true) { + throw new PolymorfaValidationError( + `${input.prompt} Re-run with --yes in noninteractive environments.`, + {code: 'confirmation_required'}, + ) + } + + const reader = createInterface({input: source, output}) + try { + const answer = await reader.question( + `${input.prompt} Type yes to continue: `, + ) + if (answer.trim().toLowerCase() !== 'yes') { + throw new PolymorfaValidationError('Confirmation declined.', { + code: 'confirmation_declined', + }) + } + } finally { + reader.close() + } +} diff --git a/src/input/duration.ts b/src/input/duration.ts new file mode 100644 index 00000000..32b32261 --- /dev/null +++ b/src/input/duration.ts @@ -0,0 +1,63 @@ +import {PolymorfaValidationError} from '@polymorfa/sdk' + +const UNITS: Readonly> = { + d: 86_400_000, + h: 3_600_000, + m: 60_000, + ms: 1, + s: 1000, +} + +export function parseDurationMilliseconds(value: string): number { + const match = /^(0|[1-9][0-9]*)(ms|s|m|h|d)$/.exec(value.trim()) + if (!match) { + throw new PolymorfaValidationError( + 'Durations must be a nonnegative integer followed by ms, s, m, h, or d.', + {code: 'invalid_duration'}, + ) + } + const amount = Number(match[1]) + const result = amount * (UNITS[match[2] ?? ''] ?? 0) + if (!Number.isSafeInteger(result)) { + throw new PolymorfaValidationError('The duration is too large.', { + code: 'invalid_duration', + }) + } + return result +} + +export function parseDurationSeconds(value: string): number { + const milliseconds = parseDurationMilliseconds(value) + if (milliseconds % 1000 !== 0) { + throw new PolymorfaValidationError( + 'This duration must resolve to a whole number of seconds.', + {code: 'invalid_duration'}, + ) + } + return milliseconds / 1000 +} + +export function parsePositiveDurationMilliseconds(value: string): number { + const milliseconds = parseDurationMilliseconds(value) + if (milliseconds === 0) { + throw new PolymorfaValidationError( + 'This duration must be greater than zero.', + {code: 'invalid_duration'}, + ) + } + return milliseconds +} + +export function parseBoundedDurationSeconds( + value: string, + maximum: number, +): number { + const seconds = parseDurationSeconds(value) + if (seconds > maximum) { + throw new PolymorfaValidationError( + `This duration must be at most ${maximum} seconds.`, + {code: 'invalid_duration'}, + ) + } + return seconds +} diff --git a/src/input/idempotency.ts b/src/input/idempotency.ts new file mode 100644 index 00000000..6938f525 --- /dev/null +++ b/src/input/idempotency.ts @@ -0,0 +1,23 @@ +import {randomUUID} from 'node:crypto' +import {PolymorfaValidationError} from '@polymorfa/sdk' + +export function idempotencyKey( + value: string | undefined, + generate: () => string = randomUUID, +): string { + const supplied = value?.trim() + if (supplied !== undefined && supplied.length === 0) { + throw new PolymorfaValidationError( + 'Idempotency keys must not be empty; omit --idempotency-key to generate one.', + {code: 'invalid_idempotency_key'}, + ) + } + const key = supplied ?? `pmfa_cli_${generate()}` + if (Buffer.byteLength(key, 'utf8') > 255) { + throw new PolymorfaValidationError( + 'Idempotency keys must not exceed 255 bytes.', + {code: 'invalid_idempotency_key'}, + ) + } + return key +} diff --git a/src/input/stdin.ts b/src/input/stdin.ts index 7b58e8d4..903f9454 100644 --- a/src/input/stdin.ts +++ b/src/input/stdin.ts @@ -25,7 +25,7 @@ export async function readSecretFromStdin( const credential = Buffer.concat(chunks).toString('utf8').trim() if (credential.length === 0) { throw new PolymorfaConfigurationError( - 'Standard input did not contain an API key.', + 'Standard input did not contain a credential.', 'stdin', ) } diff --git a/src/input/time.ts b/src/input/time.ts new file mode 100644 index 00000000..29a08685 --- /dev/null +++ b/src/input/time.ts @@ -0,0 +1,54 @@ +import {PolymorfaValidationError} from '@polymorfa/sdk' + +const RFC_3339 = + /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?(?:Z|[+-](\d{2}):(\d{2}))$/ + +export function optionalTimeRange( + since: string | undefined, + until: string | undefined, +): {readonly since?: string; readonly until?: string} { + if (since !== undefined) validateTimestamp(since, '--since') + if (until !== undefined) validateTimestamp(until, '--until') + if ( + since !== undefined && + until !== undefined && + Date.parse(until) <= Date.parse(since) + ) { + throw invalid('--until must be after --since.') + } + return { + ...(since === undefined ? {} : {since}), + ...(until === undefined ? {} : {until}), + } +} + +function validateTimestamp(value: string, flag: string): void { + const match = RFC_3339.exec(value) + if ( + match === null || + !validFields(match.slice(1).map((field) => Number(field ?? 0))) || + !Number.isFinite(Date.parse(value)) + ) { + throw invalid(`${flag} must be an RFC 3339 timestamp.`) + } +} + +function validFields(fields: readonly number[]): boolean { + const [year, month, day, hour, minute, second, offsetHour, offsetMinute] = + fields as [number, number, number, number, number, number, number, number] + if (month < 1 || month > 12 || day < 1) return false + // Day 0 of the next month is the last day of this month. + const daysInMonth = new Date(Date.UTC(year, month, 0)).getUTCDate() + return ( + day <= daysInMonth && + hour <= 23 && + minute <= 59 && + second <= 59 && + offsetHour <= 23 && + offsetMinute <= 59 + ) +} + +function invalid(message: string): PolymorfaValidationError { + return new PolymorfaValidationError(message, {code: 'invalid_time_filter'}) +} diff --git a/src/listener/checkpoint.ts b/src/listener/checkpoint.ts new file mode 100644 index 00000000..dc8492c9 --- /dev/null +++ b/src/listener/checkpoint.ts @@ -0,0 +1,236 @@ +import {constants} from 'node:fs' +import {lstat, open, rename, unlink} from 'node:fs/promises' +import {basename, dirname, join} from 'node:path' +import {randomBytes} from 'node:crypto' + +export interface ListenerCheckpoint { + readonly schemaVersion: 1 + readonly kind: 'polymorfa_cli_listener_checkpoint' + readonly protocolVersion: '1' + readonly projectId: string + readonly filters: {readonly events: readonly string[]} + readonly lastAcknowledgedCursor: string + readonly cursorExpiresAt: string + readonly updatedAt: string +} + +export class CheckpointError extends Error { + readonly code = 'listener_checkpoint_invalid' + + constructor(message: string) { + super(message) + this.name = 'CheckpointError' + } +} + +export class EventFilterError extends Error { + readonly code = 'listener_filter_invalid' + + constructor(message: string) { + super(message) + this.name = 'EventFilterError' + } +} + +export function normalizeEventFilters(filters: readonly string[]): string[] { + if (filters.length === 0) return ['*'] + + const normalized = new Set() + for (const input of filters) { + const filter = input.trim() + const bytes = Buffer.byteLength(filter, 'utf8') + if (bytes === 0 || !/^(?:\*|[a-z0-9._:-]+\*?)$/.test(filter)) { + throw new EventFilterError( + 'Each listener event filter must be a lowercase exact name, *, or a prefix with one trailing *.', + ) + } + if (bytes > 128) { + throw new EventFilterError( + 'A listener event filter cannot exceed 128 UTF-8 bytes.', + ) + } + normalized.add(filter) + } + + if (normalized.size > 32) { + throw new EventFilterError( + 'A listener request cannot contain more than 32 unique event filters.', + ) + } + const values = [...normalized].sort() + const totalBytes = values.reduce( + (total, filter) => total + Buffer.byteLength(filter, 'utf8'), + 0, + ) + if (totalBytes > 2048) { + throw new EventFilterError( + 'Listener event filters cannot exceed 2048 UTF-8 bytes in total.', + ) + } + return values +} + +export async function loadCheckpoint( + path: string, + projectId: string, + filters: readonly string[], +): Promise { + await validateFile(path) + const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW) + let text: string + try { + text = await handle.readFile('utf8') + } finally { + await handle.close() + } + + let parsed: unknown + try { + parsed = JSON.parse(text) + } catch { + throw invalid('The listener checkpoint contains malformed JSON.') + } + if (!isCheckpoint(parsed)) { + throw invalid('The listener checkpoint has an unknown or malformed schema.') + } + if (parsed.projectId !== projectId) { + throw invalid('The listener checkpoint belongs to a different project.') + } + const expected = normalizeEventFilters(filters) + if (!sameStrings(normalizeEventFilters(parsed.filters.events), expected)) { + throw invalid('The listener checkpoint uses different event filters.') + } + return {...parsed, filters: {events: expected}} +} + +export async function writeCheckpoint( + path: string, + checkpoint: ListenerCheckpoint, +): Promise { + const normalized: ListenerCheckpoint = { + ...checkpoint, + filters: {events: normalizeEventFilters(checkpoint.filters.events)}, + } + const body = `${JSON.stringify(normalized, undefined, 2)}\n` + const parent = dirname(path) + + try { + await lstat(path) + } catch (error) { + if (isMissing(error)) { + const handle = await open( + path, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, + 0o600, + ) + try { + await handle.writeFile(body, 'utf8') + await handle.sync() + } finally { + await handle.close() + } + await syncDirectory(parent) + return + } + throw error + } + + await loadCheckpoint(path, normalized.projectId, normalized.filters.events) + const temporary = join( + parent, + `.${basename(path)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`, + ) + let created = false + try { + const handle = await open( + temporary, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, + 0o600, + ) + created = true + try { + await handle.writeFile(body, 'utf8') + await handle.sync() + } finally { + await handle.close() + } + await rename(temporary, path) + created = false + await syncDirectory(parent) + } finally { + if (created) await unlink(temporary).catch(() => {}) + } +} + +async function validateFile(path: string): Promise { + const stats = await lstat(path) + if (!stats.isFile() || stats.isSymbolicLink()) { + throw invalid('The listener checkpoint must be a regular file, not a link.') + } + if (process.platform !== 'win32' && (stats.mode & 0o777) !== 0o600) { + throw invalid('The listener checkpoint must use mode 0600.') + } + const uid = process.getuid?.() + if (uid !== undefined && stats.uid !== uid) { + throw invalid('The listener checkpoint must be owned by the current user.') + } +} + +async function syncDirectory(path: string): Promise { + if (process.platform === 'win32') return + const handle = await open(path, constants.O_RDONLY) + try { + await handle.sync() + } finally { + await handle.close() + } +} + +function isCheckpoint(value: unknown): value is ListenerCheckpoint { + if (!isRecord(value)) return false + if ( + value.schemaVersion !== 1 || + value.kind !== 'polymorfa_cli_listener_checkpoint' || + value.protocolVersion !== '1' || + !nonempty(value.projectId) || + !nonempty(value.lastAcknowledgedCursor) || + !validDate(value.cursorExpiresAt) || + !validDate(value.updatedAt) || + !isRecord(value.filters) || + !Array.isArray(value.filters.events) || + !value.filters.events.every(nonempty) + ) { + return false + } + return true +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function nonempty(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function validDate(value: unknown): value is string { + return nonempty(value) && Number.isFinite(Date.parse(value)) +} + +function sameStrings( + left: readonly string[], + right: readonly string[], +): boolean { + return ( + left.length === right.length && + left.every((value, index) => value === right[index]) + ) +} + +function isMissing(error: unknown): boolean { + return isRecord(error) && error.code === 'ENOENT' +} + +function invalid(message: string): CheckpointError { + return new CheckpointError(message) +} diff --git a/src/listener/forwarding.ts b/src/listener/forwarding.ts new file mode 100644 index 00000000..10c4bf8c --- /dev/null +++ b/src/listener/forwarding.ts @@ -0,0 +1,391 @@ +import {createHmac, randomBytes} from 'node:crypto' +import {lookup} from 'node:dns/promises' +import {request as requestHttp} from 'node:http' +import {request as requestHttps} from 'node:https' +import {BlockList, isIP} from 'node:net' + +const NON_PUBLIC_IPV4 = new BlockList() +for (const [network, prefix] of [ + ['0.0.0.0', 8], + ['10.0.0.0', 8], + ['100.64.0.0', 10], + ['127.0.0.0', 8], + ['169.254.0.0', 16], + ['172.16.0.0', 12], + ['192.0.0.0', 24], + ['192.0.2.0', 24], + ['192.88.99.0', 24], + ['192.168.0.0', 16], + ['198.18.0.0', 15], + ['198.51.100.0', 24], + ['203.0.113.0', 24], + ['224.0.0.0', 4], + ['240.0.0.0', 4], +] as const) { + NON_PUBLIC_IPV4.addSubnet(network, prefix, 'ipv4') +} + +const NON_PUBLIC_IPV6 = new BlockList() +for (const [network, prefix] of [ + ['::', 128], + ['::1', 128], + ['::ffff:0:0', 96], + ['64:ff9b:1::', 48], + ['100::', 64], + ['2001:db8::', 32], + ['2001:10::', 28], + ['2001:20::', 28], + ['2002::', 16], + ['fc00::', 7], + ['fe80::', 10], + ['ff00::', 8], +] as const) { + NON_PUBLIC_IPV6.addSubnet(network, prefix, 'ipv6') +} + +export interface ForwardEventInput { + readonly body: Uint8Array + readonly eventId: string + readonly eventType: string + readonly secret: string + readonly targets: readonly string[] + readonly allowRemote?: boolean + readonly followRedirects?: boolean + readonly signal?: AbortSignal +} + +export class ForwardingError extends Error { + readonly code = 'forwarding_failed' + + constructor(message: string) { + super(message) + this.name = 'ForwardingError' + } +} + +export function createForwardingSecret(): string { + return randomBytes(32).toString('base64url') +} + +export function signLocalPayload( + secret: string, + timestamp: number, + body: Uint8Array, +): string { + const key = Buffer.from(secret, 'base64url') + if (key.byteLength !== 32) + throw new ForwardingError('The local forwarding secret is invalid.') + const digest = createHmac('sha256', key) + .update(String(timestamp)) + .update('.') + .update(body) + .digest('hex') + return `t=${timestamp},v1=${digest}` +} + +export type DestinationResolver = ( + hostname: string, +) => Promise> + +export async function validateForwardDestination( + value: string, + allowRemote: boolean, + resolveHost: DestinationResolver = resolve, +): Promise<{address: string; family: 4 | 6; url: URL}> { + let url: URL + try { + url = new URL(value) + } catch { + throw new ForwardingError('A forwarding destination is not a valid URL.') + } + if (url.username || url.password) { + throw new ForwardingError( + 'Forwarding destinations must not contain userinfo.', + ) + } + if (url.hash) + throw new ForwardingError( + 'Forwarding destinations must not contain fragments.', + ) + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + throw new ForwardingError('Forwarding destinations must use HTTP or HTTPS.') + } + + const addresses = await resolveHost(hostname(url)) + const allLoopback = addresses.every(({address}) => isLoopback(address)) + if (!allLoopback && !allowRemote) { + throw new ForwardingError( + 'Remote forwarding requires --allow-remote-forward.', + ) + } + if (!allLoopback && url.protocol !== 'https:') { + throw new ForwardingError('Remote forwarding destinations must use HTTPS.') + } + if (!allLoopback && addresses.some(({address}) => !isPublic(address))) { + throw new ForwardingError( + 'Remote forwarding resolved to a private or reserved address.', + ) + } + if (addresses.some(({address}) => isLoopback(address)) && !allLoopback) { + throw new ForwardingError( + 'Forwarding destinations must not mix public and private addresses.', + ) + } + const selected = addresses[0] + if (!selected) + throw new ForwardingError('The forwarding destination has no address.') + return {address: selected.address, family: selected.family, url} +} + +export async function forwardEvent(input: ForwardEventInput): Promise { + if (input.targets.length === 0 || input.targets.length > 8) { + throw new ForwardingError( + 'Forwarding requires between one and eight destinations.', + ) + } + await Promise.all(input.targets.map((target) => forwardTarget(target, input))) +} + +async function forwardTarget( + initial: string, + input: ForwardEventInput, +): Promise { + let target = initial + let redirects = 0 + let lastFailure = 'The forwarding destination did not accept the event.' + + for (let attempt = 0; attempt < 3; attempt += 1) { + const destination = await validateForwardDestination( + target, + input.allowRemote ?? false, + ) + const timestamp = Math.floor(Date.now() / 1000) + let response: ResponseSummary + try { + response = await send(destination, input, timestamp) + } catch (error) { + if (input.signal?.aborted) throw error + lastFailure = 'The forwarding destination could not be reached.' + if (attempt < 2) await delayForRetry(retryDelay(attempt), input.signal) + continue + } + + if (response.status >= 200 && response.status <= 299) return + if (response.status >= 300 && response.status <= 399) { + if (!input.followRedirects) { + throw new ForwardingError( + 'The forwarding destination returned a redirect.', + ) + } + if (response.status !== 307 && response.status !== 308) { + throw new ForwardingError( + 'Only 307 and 308 forwarding redirects are allowed.', + ) + } + if (!response.location || redirects >= 3) { + throw new ForwardingError('The forwarding redirect limit was exceeded.') + } + target = new URL(response.location, destination.url).toString() + redirects += 1 + attempt -= 1 + continue + } + + const retryable = + response.status === 408 || + response.status === 425 || + response.status === 429 || + response.status >= 500 + if (!retryable) { + throw new ForwardingError( + `The forwarding destination returned HTTP ${response.status}.`, + ) + } + lastFailure = `The forwarding destination returned HTTP ${response.status}.` + if (attempt < 2) { + const computed = retryDelay(attempt) + const retryAfter = parseRetryAfter(response.retryAfter) + await delayForRetry( + Math.min(30_000, Math.max(computed, retryAfter ?? 0)), + input.signal, + ) + } + } + throw new ForwardingError(lastFailure) +} + +interface ResolvedDestination { + readonly address: string + readonly family: 4 | 6 + readonly url: URL +} + +interface ResponseSummary { + readonly status: number + readonly location?: string + readonly retryAfter?: string +} + +function send( + destination: ResolvedDestination, + input: ForwardEventInput, + timestamp: number, +): Promise { + return new Promise((resolve, reject) => { + const client = + destination.url.protocol === 'https:' ? requestHttps : requestHttp + const request = client( + { + family: destination.family, + headers: { + Accept: '*/*', + 'Content-Length': input.body.byteLength, + 'Content-Type': 'application/json', + Host: destination.url.host, + 'Polymorfa-Local-Event-Id': input.eventId, + 'Polymorfa-Local-Event-Type': input.eventType, + 'Polymorfa-Local-Signature': signLocalPayload( + input.secret, + timestamp, + input.body, + ), + }, + hostname: destination.address, + method: 'POST', + path: `${destination.url.pathname}${destination.url.search}`, + port: destination.url.port || undefined, + servername: hostname(destination.url), + signal: input.signal, + timeout: 10_000, + }, + (response) => { + response.once('aborted', () => { + reject(new Error('forward response aborted')) + }) + response.once('error', reject) + response.resume() + response.once('end', () => { + resolve({ + status: response.statusCode ?? 0, + ...(typeof response.headers.location === 'string' + ? {location: response.headers.location} + : {}), + ...(typeof response.headers['retry-after'] === 'string' + ? {retryAfter: response.headers['retry-after']} + : {}), + }) + }) + }, + ) + request.once('timeout', () => request.destroy(new Error('forward timeout'))) + request.once('error', reject) + request.end(input.body) + }) +} + +async function resolve( + hostname: string, +): Promise> { + if (isIP(hostname)) + return [{address: hostname, family: isIP(hostname) as 4 | 6}] + let values: Array<{address: string; family: number}> + try { + values = await lookup(hostname, {all: true, verbatim: true}) + } catch { + throw new ForwardingError( + 'The forwarding destination could not be resolved.', + ) + } + return values.map(({address, family}) => ({address, family: family as 4 | 6})) +} + +function isLoopback(address: string): boolean { + if (address === '::1') return true + const mapped = mappedIpv4(address) + if (mapped) return mapped.startsWith('127.') + const bytes = ipv4(address) + return bytes?.[0] === 127 +} + +function isPublic(address: string): boolean { + const mapped = mappedIpv4(address) + if (mapped) return !NON_PUBLIC_IPV4.check(mapped, 'ipv4') + const bytes = ipv4(address) + if (bytes) return !NON_PUBLIC_IPV4.check(address, 'ipv4') + return isIP(address) === 6 && !NON_PUBLIC_IPV6.check(address, 'ipv6') +} + +function ipv4(address: string): number[] | undefined { + if (isIP(address) !== 4) return undefined + return address.split('.').map(Number) +} + +function mappedIpv4(address: string): string | undefined { + const normalized = address.toLowerCase() + if (!normalized.startsWith('::ffff:')) return undefined + const tail = normalized.slice('::ffff:'.length) + if (isIP(tail) === 4) return tail + const halves = tail.split(':') + if (halves.length !== 2) return undefined + const high = Number.parseInt(halves[0] ?? '', 16) + const low = Number.parseInt(halves[1] ?? '', 16) + if ( + !Number.isInteger(high) || + !Number.isInteger(low) || + high < 0 || + high > 0xffff || + low < 0 || + low > 0xffff + ) { + return undefined + } + return `${high >>> 8}.${high & 0xff}.${low >>> 8}.${low & 0xff}` +} + +function hostname(url: URL): string { + return url.hostname.startsWith('[') && url.hostname.endsWith(']') + ? url.hostname.slice(1, -1) + : url.hostname +} + +function retryDelay(attempt: number): number { + const base = attempt === 0 ? 250 : 1000 + return Math.round(base * (1 + Math.random() * 0.2)) +} + +function parseRetryAfter(value: string | undefined): number | undefined { + if (!value) return undefined + const seconds = Number(value) + if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1000 + const date = Date.parse(value) + if (Number.isNaN(date)) return undefined + return Math.max(0, date - Date.now()) +} + +export function delayForRetry( + milliseconds: number, + signal: AbortSignal | undefined, +): Promise { + if (signal?.aborted) return Promise.reject(abortReason(signal)) + + return new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + reject( + signal ? abortReason(signal) : new Error('Forwarding was aborted.'), + ) + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, milliseconds) + signal?.addEventListener('abort', onAbort, {once: true}) + }) +} + +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new Error('Forwarding was aborted.') +} diff --git a/src/listener/frames.ts b/src/listener/frames.ts new file mode 100644 index 00000000..0e7b48f8 --- /dev/null +++ b/src/listener/frames.ts @@ -0,0 +1,514 @@ +import {ListenerProtocolError, type SseRecord} from './sse.js' + +export type ListenerFrameType = + | 'ready' + | 'event' + | 'event_unavailable' + | 'checkpoint' + | 'heartbeat' + | 'gap' + | 'backpressure' + | 'dropped' + | 'expiry' + | 'revoked' + +export interface ListenerFrame { + readonly v: 1 + readonly type: ListenerFrameType + readonly streamId: string + readonly sequence: number + readonly sentAt: string + readonly cursor?: string + readonly [key: string]: unknown +} + +const TYPES = new Set([ + 'ready', + 'event', + 'event_unavailable', + 'checkpoint', + 'heartbeat', + 'gap', + 'backpressure', + 'dropped', + 'expiry', + 'revoked', +]) + +const CURSOR_TYPES = new Set([ + 'event', + 'event_unavailable', + 'checkpoint', +]) + +const BASE_KEYS = ['sentAt', 'sequence', 'streamId', 'type', 'v'] as const +const FRAME_KEYS: Readonly> = { + backpressure: [...BASE_KEYS, 'graceMs', 'inFlight', 'limit'], + checkpoint: [...BASE_KEYS, 'cursor', 'cursorExpiresAt'], + dropped: [ + ...BASE_KEYS, + 'count', + 'fromCursor', + 'reason', + 'recoverable', + 'toCursor', + ], + event: [...BASE_KEYS, 'body', 'cursor', 'event'], + event_unavailable: [ + ...BASE_KEYS, + 'cursor', + 'event', + 'payloadAvailability', + 'reason', + ], + expiry: [...BASE_KEYS, 'expiredAt', 'lastCursor'], + gap: [ + ...BASE_KEYS, + 'cursorExpiresAt', + 'oldestAvailableCursor', + 'reason', + 'recoverable', + 'requestedCursor', + ], + heartbeat: [...BASE_KEYS, 'lastCursor', 'serverTime'], + ready: [ + ...BASE_KEYS, + 'connectionExpiresAt', + 'heartbeatIntervalMs', + 'maxInFlight', + 'projectId', + 'resume', + ], + revoked: [...BASE_KEYS, 'lastCursor', 'reason'], +} + +export interface ListenerFrameExpectations { + readonly streamId: string + readonly projectId: string + readonly heartbeatIntervalMs: number + readonly maxInFlight: number + readonly resume: { + readonly startPosition: 'now' | 'resume' + readonly acceptedCursor: string | null + readonly oldestAvailableCursor: string | null + readonly cursorExpiresAt: string | null + } +} + +export class ListenerFrameDecoder { + private lastSequence = 0 + private ready = false + private readonly expected: ListenerFrameExpectations | undefined + private readonly streamId: string + + constructor(input: string | ListenerFrameExpectations) { + this.streamId = typeof input === 'string' ? input : input.streamId + this.expected = typeof input === 'string' ? undefined : input + } + + decode(record: SseRecord): ListenerFrame { + let parsed: unknown + try { + parsed = JSON.parse(record.data) + } catch { + throw protocol( + 'listener_invalid_json', + 'An SSE record contains invalid JSON.', + ) + } + if (!isRecord(parsed)) { + throw protocol( + 'listener_invalid_frame', + 'An SSE data value must be an object.', + ) + } + + const type = parsed.type + if (typeof type !== 'string' || !TYPES.has(type as ListenerFrameType)) { + throw protocol( + 'listener_unknown_event', + 'The stream sent an unknown event name.', + ) + } + if (record.event !== type) { + throw protocol( + 'listener_event_mismatch', + 'The SSE event name does not match the JSON frame type.', + ) + } + if (!this.ready && type !== 'ready') { + throw protocol( + 'listener_ready_required', + 'The first stream record must be ready.', + ) + } + if (this.ready && type === 'ready') { + throw protocol( + 'listener_ready_repeated', + 'The ready frame may appear only first.', + ) + } + if (parsed.v !== 1) { + throw protocol( + 'listener_version_mismatch', + 'The listener frame version must be 1.', + ) + } + if (parsed.streamId !== this.streamId) { + throw protocol( + 'listener_stream_mismatch', + 'The listener frame has the wrong stream ID.', + ) + } + if ( + !Number.isSafeInteger(parsed.sequence) || + (parsed.sequence as number) <= this.lastSequence + ) { + throw protocol( + 'listener_sequence_invalid', + 'Listener frame sequence values must increase strictly.', + ) + } + if (!validTimestamp(parsed.sentAt)) { + throw protocol( + 'listener_frame_invalid', + 'A listener frame is missing sentAt.', + ) + } + + requireExactKeys(parsed, FRAME_KEYS[type as ListenerFrameType]) + + const cursorType = CURSOR_TYPES.has(type as ListenerFrameType) + if (cursorType) { + if ( + typeof parsed.cursor !== 'string' || + parsed.cursor.length === 0 || + record.id !== parsed.cursor + ) { + throw protocol( + 'listener_cursor_mismatch', + 'The SSE id must equal the frame cursor.', + ) + } + } else if (record.id !== undefined || parsed.cursor !== undefined) { + throw protocol( + 'listener_id_forbidden', + `The ${type} frame must omit the SSE id and cursor fields.`, + ) + } + + validateType(type as ListenerFrameType, parsed, this.expected) + if (type === 'ready' && this.expected) + validateReadyBinding(parsed, this.expected) + this.lastSequence = parsed.sequence as number + this.ready = true + return parsed as ListenerFrame + } +} + +function validateReadyBinding( + value: Record, + expected: ListenerFrameExpectations, +): void { + if ( + value.projectId !== expected.projectId || + value.heartbeatIntervalMs !== expected.heartbeatIntervalMs || + value.maxInFlight !== expected.maxInFlight || + !sameResume(value.resume, expected.resume) + ) { + throw protocol( + 'listener_authorization_mismatch', + 'The ready frame does not match the listener authorization.', + ) + } +} + +function sameResume( + value: unknown, + expected: ListenerFrameExpectations['resume'], +): boolean { + return ( + isRecord(value) && + value.startPosition === expected.startPosition && + value.acceptedCursor === expected.acceptedCursor && + value.oldestAvailableCursor === expected.oldestAvailableCursor && + value.cursorExpiresAt === expected.cursorExpiresAt + ) +} + +function validateType( + type: ListenerFrameType, + value: Record, + expected?: ListenerFrameExpectations, +): void { + if (type === 'ready') { + requireString(value, 'projectId') + requireTimestamp(value, 'connectionExpiresAt') + requireInteger(value, 'heartbeatIntervalMs', 1) + requireInteger(value, 'maxInFlight', 1) + validateResume(requireRecord(value, 'resume')) + } else if (type === 'event') { + const event = requireRecord(value, 'event') + validateEventMetadata(event, expected?.projectId) + if (event.payloadAvailability !== 'available') { + throw protocol( + 'listener_event_invalid', + 'An event frame must contain an available event.', + ) + } + const body = requireRecord(value, 'body') + requireExactKeys(body, ['contentType', 'data', 'encoding']) + if ( + body.encoding !== 'base64' || + body.contentType !== 'application/json' || + typeof body.data !== 'string' || + !canonicalBase64(body.data) + ) { + throw protocol( + 'listener_body_invalid', + 'An event body is not valid encoded JSON.', + ) + } + } else if (type === 'event_unavailable') { + const event = requireRecord(value, 'event') + validateEventMetadata(event, expected?.projectId) + if ( + !['not_retained', 'redacted'].includes( + String(value.payloadAvailability), + ) || + event.payloadAvailability !== value.payloadAvailability || + !['not_retained', 'redacted'].includes(String(value.reason)) + ) { + throw protocol( + 'listener_unavailable_invalid', + 'An unavailable event contains an invalid availability reason.', + ) + } + } else if (type === 'checkpoint') { + requireTimestamp(value, 'cursorExpiresAt') + } else if (type === 'heartbeat') { + requireTimestamp(value, 'serverTime') + requireNullableString(value, 'lastCursor') + } else if (type === 'gap') { + requireNullableString(value, 'requestedCursor') + requireNullableString(value, 'oldestAvailableCursor') + requireNullableTimestamp(value, 'cursorExpiresAt') + requireBoolean(value, 'recoverable') + requireString(value, 'reason') + } else if (type === 'backpressure') { + requireInteger(value, 'inFlight', 0) + requireInteger(value, 'limit', 1) + requireInteger(value, 'graceMs', 1) + } else if (type === 'dropped') { + requireInteger(value, 'count', 1) + requireString(value, 'fromCursor') + requireString(value, 'toCursor') + requireBoolean(value, 'recoverable') + requireString(value, 'reason') + } else if (type === 'expiry') { + requireTimestamp(value, 'expiredAt') + requireNullableString(value, 'lastCursor') + } else { + requireNullableString(value, 'lastCursor') + if (value.reason !== 'principal_revoked') { + throw protocol( + 'listener_revoked_invalid', + 'A revoked frame has an invalid reason.', + ) + } + } +} + +function validateEventMetadata( + value: Record, + expectedProjectId: string | undefined, +): void { + requireExactKeys(value, [ + 'createdAt', + 'environment', + 'id', + 'metadataExpiresAt', + 'organizationId', + 'payloadAvailability', + 'projectId', + 'replayableUntil', + 'source', + 'type', + ]) + for (const key of ['id', 'organizationId', 'projectId', 'type'] as const) + requireString(value, key) + for (const key of ['id', 'organizationId', 'projectId'] as const) { + if (!UUID.test(String(value[key]))) { + throw protocol('listener_event_invalid', `${key} must be a UUID.`) + } + } + if ( + expectedProjectId !== undefined && + value.projectId !== expectedProjectId + ) { + throw protocol( + 'listener_event_invalid', + 'The listener event belongs to a different project.', + ) + } + if (!/^[a-z0-9._:-]+$/.test(String(value.type))) { + throw protocol( + 'listener_event_invalid', + 'The listener event type is invalid.', + ) + } + if (!['runtime', 'platform', 'test'].includes(String(value.source))) { + throw protocol( + 'listener_event_invalid', + 'The listener event source is invalid.', + ) + } + if (!['development', 'production'].includes(String(value.environment))) { + throw protocol( + 'listener_event_invalid', + 'The listener event environment is invalid.', + ) + } + if ( + ![ + 'available', + 'not_retained', + 'expired', + 'redacted', + 'unavailable', + ].includes(String(value.payloadAvailability)) + ) { + throw protocol( + 'listener_event_invalid', + 'The listener event payload availability is invalid.', + ) + } + requireTimestamp(value, 'createdAt') + requireTimestamp(value, 'metadataExpiresAt') + requireNullableTimestamp(value, 'replayableUntil') +} + +function canonicalBase64(value: string): boolean { + if (value.length === 0) return false + try { + const decoded = Buffer.from(value, 'base64') + return decoded.length > 0 && decoded.toString('base64') === value + } catch { + return false + } +} + +function validateResume(value: Record): void { + requireExactKeys(value, [ + 'acceptedCursor', + 'cursorExpiresAt', + 'oldestAvailableCursor', + 'startPosition', + ]) + if (value.startPosition !== 'now' && value.startPosition !== 'resume') { + throw protocol('listener_frame_invalid', 'resume.startPosition is invalid.') + } + requireNullableString(value, 'acceptedCursor') + requireNullableString(value, 'oldestAvailableCursor') + requireNullableTimestamp(value, 'cursorExpiresAt') +} + +function requireExactKeys( + value: Readonly>, + allowed: readonly string[], +): void { + const expected = new Set(allowed) + if (Object.keys(value).some((key) => !expected.has(key))) { + throw protocol( + 'listener_frame_invalid', + 'A listener frame contains an unknown property.', + ) + } +} + +function requireRecord( + value: Record, + key: string, +): Record { + const field = value[key] + if (!isRecord(field)) + throw protocol('listener_frame_invalid', `${key} must be an object.`) + return field +} + +function requireString(value: Record, key: string): void { + const field = value[key] + if (typeof field !== 'string' || field.length === 0) { + throw protocol( + 'listener_frame_invalid', + `${key} must be a nonempty string.`, + ) + } +} + +function requireTimestamp(value: Record, key: string): void { + if (!validTimestamp(value[key])) { + throw protocol( + 'listener_frame_invalid', + `${key} must be an RFC 3339 timestamp.`, + ) + } +} + +function requireNullableTimestamp( + value: Record, + key: string, +): void { + if (value[key] !== null && !validTimestamp(value[key])) { + throw protocol( + 'listener_frame_invalid', + `${key} must be an RFC 3339 timestamp or null.`, + ) + } +} + +function requireNullableString( + value: Record, + key: string, +): void { + if (value[key] !== null && typeof value[key] !== 'string') { + throw protocol('listener_frame_invalid', `${key} must be a string or null.`) + } +} + +function requireBoolean(value: Record, key: string): void { + if (typeof value[key] !== 'boolean') { + throw protocol('listener_frame_invalid', `${key} must be boolean.`) + } +} + +function requireInteger( + value: Record, + key: string, + minimum: number, +): void { + if (!Number.isSafeInteger(value[key]) || (value[key] as number) < minimum) { + throw protocol('listener_frame_invalid', `${key} must be an integer.`) + } +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function validTimestamp(value: unknown): value is string { + return ( + typeof value === 'string' && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( + value, + ) && + Number.isFinite(Date.parse(value)) + ) +} + +const UUID = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ + +function protocol(code: string, message: string): ListenerProtocolError { + return new ListenerProtocolError(code, message) +} diff --git a/src/listener/protocol.ts b/src/listener/protocol.ts new file mode 100644 index 00000000..95282263 --- /dev/null +++ b/src/listener/protocol.ts @@ -0,0 +1,678 @@ +import {randomUUID} from 'node:crypto' +import {lookup as dnsLookup} from 'node:dns/promises' +import {isIP} from 'node:net' + +import {normalizeEventFilters} from './checkpoint.js' +import { + ListenerProtocolError, + type ListenerProtocolErrorOptions, +} from './sse.js' + +export const LISTENER_PROTOCOL_VERSION = '1' as const +export const LISTENER_PROTOCOL_SHA256 = + '0f324f0ce4aa6b39e9af9f4aed51fcce42aa818abae00f9e3e03eb551f35d1ee' as const + +export const LISTENER_PROJECT_ID_PATTERN = + /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/ +const UUID = LISTENER_PROJECT_ID_PATTERN + +export interface ListenerAuthorization { + readonly authorizationId: string + readonly streamId: string + readonly listenerCredential: string + readonly credentialExpiresAt: string + readonly streamUrl: string + readonly ackUrl: string + readonly closeUrl: string + readonly mediaType: 'text/event-stream' + readonly protocolVersion: '1' + readonly projectId: string + readonly filters: {readonly events: readonly string[]} + readonly resume: { + readonly startPosition: 'now' | 'resume' + readonly acceptedCursor: string | null + readonly oldestAvailableCursor: string | null + readonly cursorExpiresAt: string | null + } + readonly limits: { + readonly heartbeatIntervalMs: number + readonly ackTimeoutMs: number + readonly maxEventBytes: number + readonly maxInFlight: number + } + readonly capabilities: {readonly resume: true} +} + +export type ListenerCloseReason = + | 'client_requested' + | 'interrupted' + | 'reconnect' + | 'forwarding_failed' + | 'stream_gap' + | 'stream_dropped' + | 'process_shutdown' + +export interface ListenerProtocolClientOptions { + readonly apiOrigin: string + readonly credential: string + readonly version: string + readonly fetch?: typeof globalThis.fetch + readonly lookup?: ListenerLookup +} + +type ListenerLookup = ( + hostname: string, +) => Promise + +export class ListenerProtocolClient { + private readonly apiOrigin: URL + private readonly credential: string + private readonly fetch: typeof globalThis.fetch + private readonly lookup: ListenerLookup + private readonly version: string + + constructor(options: ListenerProtocolClientOptions) { + this.apiOrigin = trustedOrigin(options.apiOrigin) + this.credential = options.credential + this.version = options.version + this.fetch = options.fetch ?? globalThis.fetch + this.lookup = + options.lookup ?? + ((hostname) => dnsLookup(hostname, {all: true, verbatim: true})) + } + + async authorize(input: { + readonly projectId: string + readonly events: readonly string[] + readonly resumeCursor: string | null + readonly signal?: AbortSignal + }): Promise { + validateListenerProjectId(input.projectId) + if ( + input.resumeCursor !== null && + (input.resumeCursor.length === 0 || + Buffer.byteLength(input.resumeCursor, 'utf8') > 4096) + ) { + throw protocol( + 'listener_authorization_invalid', + 'The listener resume cursor is invalid.', + ) + } + const events = normalizeEventFilters(input.events) + await this.validateTransportOrigin() + const url = new URL('/v1/cli/listener-authorizations', this.apiOrigin) + const authorizationRequestId = randomUUID() + const body = JSON.stringify({ + authorizationRequestId, + client: { + name: '@polymorfa/cli', + protocolVersion: LISTENER_PROTOCOL_VERSION, + version: this.version, + }, + filters: {events}, + projectId: input.projectId, + resume: {cursor: input.resumeCursor}, + }) + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const response = await this.fetch(url, { + body, + headers: durableHeaders(this.credential), + method: 'POST', + redirect: 'manual', + ...(input.signal ? {signal: input.signal} : {}), + }) + const responseBody = await jsonResponse(response, 201) + if ( + !isRecord(responseBody) || + !exactKeys(responseBody, ['data']) || + !isAuthorization(responseBody.data, { + events, + projectId: input.projectId, + resumeCursor: input.resumeCursor, + }) + ) { + throw protocol( + 'listener_authorization_invalid', + 'The listener authorization response is invalid.', + ) + } + const authorization = responseBody.data + this.validateCoordinates(authorization) + return authorization + } catch (error) { + if (input.signal?.aborted) throw error + const retryableResponse = + error instanceof ListenerProtocolError && + error.retryable && + error.safeToReauthorize && + [409, 429, 503].includes(error.status ?? 0) && + (error.authorizationRequestId === undefined || + error.authorizationRequestId === authorizationRequestId) + const ambiguousTransport = !(error instanceof ListenerProtocolError) + if (attempt < 2 && (retryableResponse || ambiguousTransport)) { + await retryDelay(error, attempt, input.signal) + continue + } + if (ambiguousTransport) { + throw protocol( + 'listener_authorization_unavailable', + 'Listener authorization could not be completed.', + { + authorizationRequestId, + retryable: true, + safeToReauthorize: true, + }, + ) + } + throw error + } + } + throw new Error('unreachable') + } + + async open( + authorization: ListenerAuthorization, + signal?: AbortSignal, + ): Promise> { + await this.validateTransportOrigin() + this.validateCoordinates(authorization) + const response = await this.fetch(authorization.streamUrl, { + headers: { + Accept: 'text/event-stream', + Authorization: `Bearer ${authorization.listenerCredential}`, + }, + method: 'GET', + redirect: 'manual', + ...(signal ? {signal} : {}), + }) + if (response.status !== 200) await throwResponse(response) + const contentType = response.headers + .get('content-type') + ?.split(';', 1)[0] + ?.trim() + if (contentType !== 'text/event-stream' || !response.body) { + throw protocol( + 'listener_stream_invalid', + 'The listener did not return an event stream.', + ) + } + return response.body + } + + async ack( + authorization: ListenerAuthorization, + cursor: string, + sequence: number, + signal?: AbortSignal, + ): Promise { + await this.validateTransportOrigin() + this.validateCoordinates(authorization) + for (let attempt = 0; attempt < 3; attempt += 1) { + try { + const response = await this.fetch(authorization.ackUrl, { + body: JSON.stringify({cursor, sequence, v: 1}), + headers: durableHeaders(this.credential), + method: 'POST', + redirect: 'manual', + ...(signal ? {signal} : {}), + }) + const body = await jsonResponse(response, 200) + if (!isAckResponse(body, authorization.streamId, cursor, sequence)) { + throw protocol( + 'listener_response_invalid', + 'The listener acknowledgement response is invalid.', + ) + } + return + } catch (error) { + if (signal?.aborted) throw error + const retryableResponse = + error instanceof ListenerProtocolError && error.retryable + if ( + attempt < 2 && + (retryableResponse || !(error instanceof ListenerProtocolError)) + ) { + await retryDelay(error, attempt, signal) + continue + } + throw error + } + } + } + + async close( + authorization: ListenerAuthorization, + reason: ListenerCloseReason, + signal?: AbortSignal, + ): Promise { + await this.validateTransportOrigin() + this.validateCoordinates(authorization) + const response = await this.fetch(authorization.closeUrl, { + body: JSON.stringify({reason, v: 1}), + headers: durableHeaders(this.credential), + method: 'POST', + redirect: 'manual', + ...(signal ? {signal} : {}), + }) + const body = await jsonResponse(response, 200) + if (!isCloseResponse(body, authorization.streamId)) { + throw protocol( + 'listener_response_invalid', + 'The listener close response is invalid.', + ) + } + } + + private validateCoordinates(authorization: ListenerAuthorization): void { + validateCoordinate( + authorization.streamUrl, + this.apiOrigin, + `/v1/cli/listener-streams/${authorization.streamId}/events`, + ) + validateCoordinate( + authorization.ackUrl, + this.apiOrigin, + `/v1/cli/listener-streams/${authorization.streamId}/ack`, + ) + validateCoordinate( + authorization.closeUrl, + this.apiOrigin, + `/v1/cli/listener-streams/${authorization.streamId}/close`, + ) + } + + private async validateTransportOrigin(): Promise { + if (this.apiOrigin.protocol === 'https:') return + let addresses: readonly { + readonly address: string + readonly family: number + }[] + try { + addresses = await this.lookup(hostname(this.apiOrigin)) + } catch { + throw protocol( + 'listener_origin_invalid', + 'The configured plaintext API origin could not be resolved safely.', + ) + } + if ( + addresses.length === 0 || + addresses.some(({address}) => !isLoopback(address)) + ) { + throw protocol( + 'listener_origin_invalid', + 'The configured plaintext API origin must resolve only to loopback.', + ) + } + } +} + +export function validateListenerProjectId(projectId: string): void { + if (!UUID.test(projectId)) { + throw protocol( + 'listener_authorization_invalid', + 'The listener project must be a UUID.', + ) + } +} + +function trustedOrigin(value: string): URL { + let url: URL + try { + url = new URL(value) + } catch { + throw protocol( + 'listener_origin_invalid', + 'The configured API origin is invalid.', + ) + } + if ( + url.username || + url.password || + url.pathname !== '/' || + url.search || + url.hash + ) { + throw protocol( + 'listener_origin_invalid', + 'The configured API origin must not include a path, userinfo, a query, or a fragment.', + ) + } + if (url.protocol !== 'https:' && url.protocol !== 'http:') { + throw protocol( + 'listener_origin_invalid', + 'The configured API origin must use HTTPS, or HTTP with a host that resolves only to loopback addresses.', + ) + } + return new URL(url.origin) +} + +function validateCoordinate(value: string, origin: URL, path: string): void { + let url: URL + try { + url = new URL(value) + } catch { + throw protocol('listener_coordinate_invalid', 'A listener URL is invalid.') + } + if ( + url.origin !== origin.origin || + url.pathname !== path || + url.username || + url.password || + url.search || + url.hash + ) { + throw protocol( + 'listener_coordinate_invalid', + 'A listener URL is outside the trusted API origin.', + ) + } +} + +function durableHeaders(credential: string): Record { + return { + Accept: 'application/json', + Authorization: `Bearer ${credential}`, + 'Content-Type': 'application/json', + } +} + +async function jsonResponse( + response: Response, + expected: number, +): Promise { + if (response.status !== expected) await throwResponse(response) + const contentType = response.headers + .get('content-type') + ?.split(';', 1)[0] + ?.trim() + if (contentType !== 'application/json') { + throw protocol( + 'listener_response_invalid', + 'The listener endpoint did not return JSON.', + ) + } + try { + return await response.json() + } catch { + throw protocol( + 'listener_response_invalid', + 'The listener endpoint returned invalid JSON.', + ) + } +} + +async function throwResponse(response: Response): Promise { + if (response.status >= 300 && response.status <= 399) { + throw protocol( + 'listener_redirect_forbidden', + 'A listener endpoint returned a forbidden redirect.', + ) + } + let code = 'listener_request_failed' + let message = `The listener endpoint returned HTTP ${response.status}.` + let retryable = false + let safeToReauthorize = false + let authorizationRequestId: string | undefined + let requestId: string | undefined + let details: Readonly> | undefined + if (response.headers.get('content-type')?.includes('application/json')) { + try { + const body = (await response.json()) as unknown + if (isRecord(body) && isRecord(body.error)) { + if (typeof body.error.code === 'string') code = body.error.code + if (typeof body.error.message === 'string') message = body.error.message + if (typeof body.error.retryable === 'boolean') + retryable = body.error.retryable + if (typeof body.error.safeToReauthorize === 'boolean') + safeToReauthorize = body.error.safeToReauthorize + if (typeof body.error.authorizationRequestId === 'string') + authorizationRequestId = body.error.authorizationRequestId + if (typeof body.error.requestId === 'string') + requestId = body.error.requestId + if (isRecord(body.error.details)) details = body.error.details + } + } catch { + // Preserve the status-based fallback when an error body is malformed. + } + } + requestId ??= response.headers.get('x-request-id') ?? undefined + const retryAfterMs = parseRetryAfter(response.headers.get('retry-after')) + throw protocol(code, message, { + ...(authorizationRequestId === undefined ? {} : {authorizationRequestId}), + ...(details === undefined ? {} : {details}), + ...(requestId === undefined ? {} : {requestId}), + ...(retryAfterMs === undefined ? {} : {retryAfterMs}), + retryable, + safeToReauthorize, + status: response.status, + }) +} + +function isAuthorization( + value: unknown, + expected: { + readonly events: readonly string[] + readonly projectId: string + readonly resumeCursor: string | null + }, +): value is ListenerAuthorization { + if (!isRecord(value)) return false + const expectedStart = expected.resumeCursor === null ? 'now' : 'resume' + return ( + exactKeys(value, [ + 'ackUrl', + 'authorizationId', + 'capabilities', + 'closeUrl', + 'credentialExpiresAt', + 'filters', + 'limits', + 'listenerCredential', + 'mediaType', + 'projectId', + 'protocolVersion', + 'resume', + 'streamId', + 'streamUrl', + ]) && + typeof value.authorizationId === 'string' && + UUID.test(value.authorizationId) && + typeof value.streamId === 'string' && + UUID.test(value.streamId) && + typeof value.listenerCredential === 'string' && + /^pmfa_ls_[A-Za-z0-9_-]{43}$/.test(value.listenerCredential) && + validTimestamp(value.credentialExpiresAt) && + string(value.streamUrl) && + string(value.ackUrl) && + string(value.closeUrl) && + value.mediaType === 'text/event-stream' && + value.protocolVersion === '1' && + value.projectId === expected.projectId && + isRecord(value.filters) && + exactKeys(value.filters, ['events']) && + Array.isArray(value.filters.events) && + sameStrings(value.filters.events, expected.events) && + isRecord(value.resume) && + exactKeys(value.resume, [ + 'acceptedCursor', + 'cursorExpiresAt', + 'oldestAvailableCursor', + 'startPosition', + ]) && + value.resume.startPosition === expectedStart && + value.resume.acceptedCursor === expected.resumeCursor && + nullableString(value.resume.oldestAvailableCursor) && + nullableTimestamp(value.resume.cursorExpiresAt) && + isRecord(value.limits) && + exactKeys(value.limits, [ + 'ackTimeoutMs', + 'heartbeatIntervalMs', + 'maxEventBytes', + 'maxInFlight', + ]) && + integerBetween(value.limits.heartbeatIntervalMs, 1_000, 60_000) && + integerBetween(value.limits.ackTimeoutMs, 2_000, 300_000) && + integerBetween(value.limits.maxEventBytes, 1_024, 16_777_216) && + integerBetween(value.limits.maxInFlight, 1, 4_096) && + isRecord(value.capabilities) && + exactKeys(value.capabilities, ['resume']) && + value.capabilities.resume === true + ) +} + +function isAckResponse( + value: unknown, + streamId: string, + cursor: string, + sequence: number, +): boolean { + return ( + isRecord(value) && + exactKeys(value, ['data']) && + isRecord(value.data) && + exactKeys(value.data, [ + 'acknowledgedCursor', + 'replayed', + 'sequence', + 'streamId', + ]) && + value.data.streamId === streamId && + value.data.acknowledgedCursor === cursor && + value.data.sequence === sequence && + typeof value.data.replayed === 'boolean' + ) +} + +function isCloseResponse(value: unknown, streamId: string): boolean { + return ( + isRecord(value) && + exactKeys(value, ['data']) && + isRecord(value.data) && + exactKeys(value.data, ['closed', 'streamId']) && + value.data.streamId === streamId && + value.data.closed === true + ) +} + +function exactKeys( + value: Readonly>, + keys: readonly string[], +): boolean { + const actual = Object.keys(value).sort() + const expected = [...keys].sort() + return sameStrings(actual, expected) +} + +function sameStrings( + value: readonly unknown[], + expected: readonly string[], +): boolean { + return ( + value.length === expected.length && + value.every((item, index) => item === expected[index]) + ) +} + +function nullableString(value: unknown): boolean { + return value === null || string(value) +} + +function nullableTimestamp(value: unknown): boolean { + return value === null || validTimestamp(value) +} + +function validTimestamp(value: unknown): value is string { + return ( + typeof value === 'string' && + /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/.test( + value, + ) && + Number.isFinite(Date.parse(value)) + ) +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +function string(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} + +function integerBetween( + value: unknown, + minimum: number, + maximum: number, +): value is number { + return ( + Number.isSafeInteger(value) && + (value as number) >= minimum && + (value as number) <= maximum + ) +} + +function hostname(url: URL): string { + return url.hostname.startsWith('[') && url.hostname.endsWith(']') + ? url.hostname.slice(1, -1) + : url.hostname +} + +function isLoopback(address: string): boolean { + if (address === '::1') return true + if (isIP(address) === 4) return address.startsWith('127.') + if (address.toLowerCase().startsWith('::ffff:')) { + const mapped = address.slice('::ffff:'.length) + return isIP(mapped) === 4 && mapped.startsWith('127.') + } + return false +} + +function parseRetryAfter(value: string | null): number | undefined { + if (!value) return undefined + const seconds = Number(value) + if (Number.isFinite(seconds) && seconds >= 0) return seconds * 1_000 + const date = Date.parse(value) + return Number.isFinite(date) ? Math.max(0, date - Date.now()) : undefined +} + +async function retryDelay( + error: unknown, + attempt: number, + signal: AbortSignal | undefined, +): Promise { + const serverDelay = + error instanceof ListenerProtocolError ? error.retryAfterMs : undefined + const milliseconds = Math.min( + 30_000, + Math.max(serverDelay ?? 0, attempt === 0 ? 250 : 1_000), + ) + if (signal?.aborted) throw abortReason(signal) + await new Promise((resolve, reject) => { + const onAbort = () => { + clearTimeout(timer) + signal?.removeEventListener('abort', onAbort) + reject(signal ? abortReason(signal) : new Error('Listener aborted.')) + } + const timer = setTimeout(() => { + signal?.removeEventListener('abort', onAbort) + resolve() + }, milliseconds) + signal?.addEventListener('abort', onAbort, {once: true}) + }) +} + +function abortReason(signal: AbortSignal): Error { + return signal.reason instanceof Error + ? signal.reason + : new Error('Listener aborted.') +} + +function protocol( + code: string, + message: string, + options?: ListenerProtocolErrorOptions, +): ListenerProtocolError { + return new ListenerProtocolError(code, message, options) +} diff --git a/src/listener/session.ts b/src/listener/session.ts new file mode 100644 index 00000000..216536aa --- /dev/null +++ b/src/listener/session.ts @@ -0,0 +1,716 @@ +import {randomUUID} from 'node:crypto' + +import type {ListenerCheckpoint} from './checkpoint.js' +import {writeCheckpoint} from './checkpoint.js' +import {ListenerFrameDecoder, type ListenerFrame} from './frames.js' +import type {ListenerAuthorization, ListenerCloseReason} from './protocol.js' +import {decodeSse, ListenerProtocolError} from './sse.js' + +export type ListenerTerminalCondition = + | 'success' + | 'interrupted' + | 'stream_gap' + | 'stream_dropped' + | 'forwarding_failed' + | 'authorization' + +export interface ListenLine { + readonly schemaVersion: 1 + readonly listenerId: string + readonly streamId: string | null + readonly sequence: number + readonly observedAt: string + readonly type: string + readonly [key: string]: unknown +} + +export interface ListenerProtocol { + authorize(input: { + readonly projectId: string + readonly events: readonly string[] + readonly resumeCursor: string | null + readonly signal?: AbortSignal + }): Promise + open( + authorization: ListenerAuthorization, + signal?: AbortSignal, + ): Promise> + ack( + authorization: ListenerAuthorization, + cursor: string, + sequence: number, + signal?: AbortSignal, + ): Promise + close( + authorization: ListenerAuthorization, + reason: ListenerCloseReason, + signal?: AbortSignal, + ): Promise +} + +export interface RunListenerInput { + readonly protocol: ListenerProtocol + readonly projectId: string + readonly events: readonly string[] + readonly maxBuffer?: number + readonly resumeCursor?: string | null + readonly checkpointFile?: string + readonly onGap?: 'stop' | 'continue' + readonly onProgress?: (progress: ListenProgress) => void + readonly waitBeforeReconnect?: ( + attempt: number, + signal: AbortSignal | undefined, + ) => Promise + readonly signal?: AbortSignal + readonly output: (line: ListenLine) => Promise + readonly forward?: (input: { + readonly body: Uint8Array + readonly event: Record + }) => Promise +} + +export interface ListenProgress { + readonly acknowledged: number + readonly forwarded: number + readonly received: number +} + +export interface ListenResult { + readonly condition: ListenerTerminalCondition + readonly lastAcknowledgedCursor: string | null + readonly received: number + readonly acknowledged: number + readonly forwarded: number +} + +export async function runListener( + input: RunListenerInput, +): Promise { + const maxBuffer = input.maxBuffer ?? 256 + if (!Number.isSafeInteger(maxBuffer) || maxBuffer < 1 || maxBuffer > 4096) { + throw new ListenerProtocolError( + 'listener_buffer_invalid', + 'The local listener buffer must contain 1 through 4096 events.', + ) + } + let resumeCursor = input.resumeCursor ?? null + let lastAcknowledgedCursor = resumeCursor + let received = 0 + let acknowledged = 0 + let forwarded = 0 + let reconnectAttempt = 0 + let lastAuthorization: ListenerAuthorization | undefined + let observedSequence = 0 + const localListenerId = randomUUID() + const delivered = new Map() + + while (!input.signal?.aborted) { + let authorization: ListenerAuthorization + let stream: ReadableStream + try { + authorization = await input.protocol.authorize({ + events: input.events, + projectId: input.projectId, + resumeCursor, + ...(input.signal ? {signal: input.signal} : {}), + }) + lastAuthorization = authorization + validateBufferBound(maxBuffer, authorization.limits.maxInFlight) + stream = await input.protocol.open(authorization, input.signal) + } catch (error) { + if (input.signal?.aborted) { + if (lastAuthorization) { + return interrupted( + input, + lastAuthorization, + observedSequence, + lastAcknowledgedCursor, + received, + acknowledged, + forwarded, + ) + } + return interruptedBeforeAuthorization( + lastAcknowledgedCursor, + received, + acknowledged, + forwarded, + ) + } + if ( + error instanceof ListenerProtocolError && + error.status === 410 && + [ + 'listener_cursor_unavailable', + 'resume_cursor_expired', + 'resume_cursor_unavailable', + ].includes(error.code) + ) { + const gap = authorizationGap(error, resumeCursor) + await input.output({ + ...localLineBase(localListenerId, 'gap'), + ...gap, + reason: error.code, + }) + if ( + input.onGap === 'continue' && + typeof gap.oldestAvailableCursor === 'string' + ) { + const old = lastAcknowledgedCursor + resumeCursor = gap.oldestAvailableCursor + await input.output({ + ...localLineBase(localListenerId, 'discontinuity'), + fromCursor: old, + reason: 'gap_continue', + toCursor: resumeCursor, + }) + await (input.waitBeforeReconnect ?? waitBeforeReconnect)( + reconnectAttempt, + input.signal, + ) + reconnectAttempt = Math.min(reconnectAttempt + 1, 31) + continue + } + await input.output({ + ...localLineBase(localListenerId, 'terminal'), + condition: 'stream_gap', + lastAcknowledgedCursor, + }) + return { + acknowledged, + condition: 'stream_gap', + forwarded, + lastAcknowledgedCursor, + received, + } + } + if (error instanceof ListenerProtocolError && error.safeToReauthorize) { + await (input.waitBeforeReconnect ?? waitBeforeReconnect)( + reconnectAttempt, + input.signal, + ) + reconnectAttempt = Math.min(reconnectAttempt + 1, 31) + continue + } + throw error + } + const decoder = new ListenerFrameDecoder({ + heartbeatIntervalMs: authorization.limits.heartbeatIntervalMs, + maxInFlight: authorization.limits.maxInFlight, + projectId: authorization.projectId, + resume: authorization.resume, + streamId: authorization.streamId, + }) + observedSequence = 0 + + try { + for await (const record of decodeSse( + stream, + authorization.limits.maxEventBytes, + )) { + const frame = decoder.decode(record) + if (frame.type !== 'ready') reconnectAttempt = 0 + observedSequence = frame.sequence + received += + frame.type === 'event' || frame.type === 'event_unavailable' ? 1 : 0 + progress(input, received, acknowledged, forwarded) + const base = lineBase(authorization, frame) + + if (frame.type === 'heartbeat') continue + if (frame.type === 'ready') { + await input.output({ + ...base, + acceptedCursor: authorization.resume.acceptedCursor, + connectionExpiresAt: requiredString(frame, 'connectionExpiresAt'), + filters: authorization.filters, + projectId: authorization.projectId, + startPosition: authorization.resume.startPosition, + }) + continue + } + if (frame.type === 'backpressure') { + await input.output({...base, ...withoutBase(frame)}) + continue + } + if (frame.type === 'event') { + const event = recordField(frame, 'event') + const body = recordField(frame, 'body') + const bytes = Buffer.from(requiredString(body, 'data'), 'base64') + const key = deliveryKey(frame, event) + if (!delivered.has(key)) { + await input.output({...base, body, cursor: frame.cursor, event}) + if (input.forward) { + try { + await input.forward({body: bytes, event}) + } catch { + if (input.signal?.aborted) { + return interrupted( + input, + authorization, + frame.sequence, + lastAcknowledgedCursor, + received, + acknowledged, + forwarded, + ) + } + await bestEffortClose(input, authorization, 'forwarding_failed') + await terminal( + input, + authorization, + frame.sequence, + 'forwarding_failed', + lastAcknowledgedCursor, + ) + return { + acknowledged, + condition: 'forwarding_failed', + forwarded, + lastAcknowledgedCursor, + received, + } + } + forwarded += 1 + progress(input, received, acknowledged, forwarded) + } + remember(delivered, key, maxBuffer) + } + await acknowledge(input, authorization, frame) + lastAcknowledgedCursor = frame.cursor ?? null + resumeCursor = lastAcknowledgedCursor + acknowledged += 1 + progress(input, received, acknowledged, forwarded) + await saveCheckpoint( + input, + authorization, + frame, + lastAcknowledgedCursor, + ) + continue + } + if (frame.type === 'event_unavailable') { + const event = recordField(frame, 'event') + const key = deliveryKey(frame, event) + if (!delivered.has(key)) { + await input.output({...base, ...withoutBase(frame)}) + remember(delivered, key, maxBuffer) + } + if (input.forward) { + await bestEffortClose(input, authorization, 'forwarding_failed') + await terminal( + input, + authorization, + frame.sequence, + 'forwarding_failed', + lastAcknowledgedCursor, + ) + return { + acknowledged, + condition: 'forwarding_failed', + forwarded, + lastAcknowledgedCursor, + received, + } + } + await acknowledge(input, authorization, frame) + lastAcknowledgedCursor = frame.cursor ?? null + resumeCursor = lastAcknowledgedCursor + acknowledged += 1 + progress(input, received, acknowledged, forwarded) + await saveCheckpoint( + input, + authorization, + frame, + lastAcknowledgedCursor, + ) + continue + } + if (frame.type === 'checkpoint') { + await input.output({...base, ...withoutBase(frame)}) + await acknowledge(input, authorization, frame) + lastAcknowledgedCursor = frame.cursor ?? null + resumeCursor = lastAcknowledgedCursor + acknowledged += 1 + progress(input, received, acknowledged, forwarded) + await saveCheckpoint( + input, + authorization, + frame, + lastAcknowledgedCursor, + ) + continue + } + if (frame.type === 'gap') { + await input.output({...base, ...withoutBase(frame)}) + if ( + input.onGap === 'continue' && + typeof frame.oldestAvailableCursor === 'string' + ) { + const old = lastAcknowledgedCursor + resumeCursor = frame.oldestAvailableCursor + await input.output({ + ...base, + fromCursor: old, + reason: 'gap_continue', + toCursor: resumeCursor, + type: 'discontinuity', + }) + await bestEffortClose(input, authorization, 'reconnect') + break + } + await bestEffortClose(input, authorization, 'stream_gap') + await terminal( + input, + authorization, + frame.sequence, + 'stream_gap', + lastAcknowledgedCursor, + ) + return { + acknowledged, + condition: 'stream_gap', + forwarded, + lastAcknowledgedCursor, + received, + } + } + if (frame.type === 'dropped') { + await input.output({...base, ...withoutBase(frame)}) + await bestEffortClose(input, authorization, 'stream_dropped') + await terminal( + input, + authorization, + frame.sequence, + 'stream_dropped', + lastAcknowledgedCursor, + ) + return { + acknowledged, + condition: 'stream_dropped', + forwarded, + lastAcknowledgedCursor, + received, + } + } + if (frame.type === 'revoked') { + await input.output({...base, ...withoutBase(frame)}) + await terminal( + input, + authorization, + frame.sequence, + 'authorization', + lastAcknowledgedCursor, + ) + return { + acknowledged, + condition: 'authorization', + forwarded, + lastAcknowledgedCursor, + received, + } + } + if (frame.type === 'expiry') { + await input.output({...base, ...withoutBase(frame)}) + await bestEffortClose(input, authorization, 'reconnect') + break + } + } + } catch (error) { + if (input.signal?.aborted) { + return interrupted( + input, + authorization, + observedSequence, + lastAcknowledgedCursor, + received, + acknowledged, + forwarded, + ) + } + await bestEffortClose(input, authorization, 'process_shutdown') + throw error + } + + if (!input.signal?.aborted) { + await (input.waitBeforeReconnect ?? waitBeforeReconnect)( + reconnectAttempt, + input.signal, + ) + reconnectAttempt = Math.min(reconnectAttempt + 1, 31) + continue + } + } + + if (lastAuthorization) { + return interrupted( + input, + lastAuthorization, + observedSequence, + lastAcknowledgedCursor, + received, + acknowledged, + forwarded, + ) + } + return interruptedBeforeAuthorization( + lastAcknowledgedCursor, + received, + acknowledged, + forwarded, + ) +} + +function interruptedBeforeAuthorization( + cursor: string | null, + received: number, + acknowledged: number, + forwarded: number, +): ListenResult { + return { + acknowledged, + condition: 'interrupted', + forwarded, + lastAcknowledgedCursor: cursor, + received, + } +} + +function waitBeforeReconnect( + attempt: number, + signal: AbortSignal | undefined, +): Promise { + const exponential = 250 * 2 ** Math.min(attempt, 5) + const jitter = 0.8 + Math.random() * 0.4 + const delay = Math.min(10_000, Math.round(exponential * jitter)) + if (signal?.aborted) return Promise.resolve() + + return new Promise((resolve) => { + const done = () => { + clearTimeout(timer) + signal?.removeEventListener('abort', done) + resolve() + } + const timer = setTimeout(done, delay) + signal?.addEventListener('abort', done, {once: true}) + }) +} + +function validateBufferBound( + configured: number, + serverMaxInFlight: number, +): void { + if (!Number.isSafeInteger(serverMaxInFlight) || serverMaxInFlight < 1) { + throw new ListenerProtocolError( + 'listener_authorization_invalid', + 'The listener authorization contains an invalid in-flight limit.', + ) + } + // The loop deliberately does not request the next decoded record until the + // current record has cleared every active sink and ACK. Its decoded-event + // capacity is therefore one, a stricter bound than both negotiated limits. + if (Math.min(configured, serverMaxInFlight) < 1) { + throw new ListenerProtocolError( + 'listener_buffer_invalid', + 'The listener buffer is unavailable.', + ) + } +} + +function progress( + input: RunListenerInput, + received: number, + acknowledged: number, + forwarded: number, +): void { + input.onProgress?.({acknowledged, forwarded, received}) +} + +async function acknowledge( + input: RunListenerInput, + authorization: ListenerAuthorization, + frame: ListenerFrame, +): Promise { + if (!frame.cursor) + throw new Error('Acknowledgeable listener frame is missing its cursor.') + await input.protocol.ack( + authorization, + frame.cursor, + frame.sequence, + input.signal, + ) +} + +async function saveCheckpoint( + input: RunListenerInput, + authorization: ListenerAuthorization, + frame: ListenerFrame, + cursor: string | null, +): Promise { + if (!input.checkpointFile || !cursor) return + const cursorExpiresAt = + typeof frame.cursorExpiresAt === 'string' + ? frame.cursorExpiresAt + : authorization.resume.cursorExpiresAt + if (!cursorExpiresAt) return + const checkpoint: ListenerCheckpoint = { + cursorExpiresAt, + filters: {events: authorization.filters.events}, + kind: 'polymorfa_cli_listener_checkpoint', + lastAcknowledgedCursor: cursor, + projectId: input.projectId, + protocolVersion: '1', + schemaVersion: 1, + updatedAt: new Date().toISOString(), + } + await writeCheckpoint(input.checkpointFile, checkpoint) +} + +async function terminal( + input: RunListenerInput, + authorization: ListenerAuthorization, + sequence: number, + condition: ListenerTerminalCondition, + cursor: string | null, +): Promise { + await input.output({ + condition, + lastAcknowledgedCursor: cursor, + listenerId: authorization.authorizationId, + observedAt: new Date().toISOString(), + schemaVersion: 1, + sequence, + streamId: authorization.streamId, + type: 'terminal', + }) +} + +async function bestEffortClose( + input: RunListenerInput, + authorization: ListenerAuthorization, + reason: ListenerCloseReason, +): Promise { + await input.protocol + .close(authorization, reason, AbortSignal.timeout(5000)) + .catch(() => {}) +} + +async function interrupted( + input: RunListenerInput, + authorization: ListenerAuthorization, + sequence: number, + cursor: string | null, + received: number, + acknowledged: number, + forwarded: number, +): Promise { + await bestEffortClose(input, authorization, 'interrupted') + await terminal(input, authorization, sequence, 'interrupted', cursor) + return { + acknowledged, + condition: 'interrupted', + forwarded, + lastAcknowledgedCursor: cursor, + received, + } +} + +function lineBase( + authorization: ListenerAuthorization, + frame: ListenerFrame, +): ListenLine { + return { + listenerId: authorization.authorizationId, + observedAt: new Date().toISOString(), + schemaVersion: 1, + sequence: frame.sequence, + streamId: authorization.streamId, + type: frame.type, + } +} + +function withoutBase(frame: ListenerFrame): Record { + const baseKeys = new Set(['sentAt', 'sequence', 'streamId', 'type', 'v']) + return Object.fromEntries( + Object.entries(frame).filter(([key]) => !baseKeys.has(key)), + ) +} + +function recordField( + frame: ListenerFrame, + key: string, +): Record { + const value = frame[key] + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + throw new Error(`Listener frame ${key} must be an object.`) + } + return value as Record +} + +function requiredString(value: Record, key: string): string { + const field = value[key] + if (typeof field !== 'string') + throw new Error(`Listener frame ${key} must be a string.`) + return field +} + +function authorizationGap( + error: ListenerProtocolError, + requestedCursor: string | null, +): { + readonly requestedCursor: string | null + readonly oldestAvailableCursor: string | null + readonly cursorExpiresAt: string | null + readonly recoverable: boolean +} { + const details = error.details + const oldestAvailableCursor = nullableStringField( + details, + 'oldestAvailableCursor', + ) + return { + cursorExpiresAt: nullableStringField(details, 'cursorExpiresAt'), + oldestAvailableCursor, + recoverable: oldestAvailableCursor !== null, + requestedCursor: + nullableStringField(details, 'requestedCursor') ?? requestedCursor, + } +} + +function localLineBase(listenerId: string, type: string): ListenLine { + return { + listenerId, + observedAt: new Date().toISOString(), + schemaVersion: 1, + sequence: 0, + streamId: null, + type, + } +} + +function deliveryKey( + frame: ListenerFrame, + event: Record, +): string { + return `${frame.cursor ?? ''}\0${requiredString(event, 'id')}` +} + +function remember( + delivered: Map, + key: string, + maximum: number, +): void { + delivered.set(key, true) + while (delivered.size > maximum) { + const oldest = delivered.keys().next().value + if (oldest === undefined) return + delivered.delete(oldest) + } +} + +function nullableStringField( + value: Readonly> | undefined, + key: string, +): string | null { + const field = value?.[key] + return typeof field === 'string' && field.length > 0 ? field : null +} diff --git a/src/listener/sse.ts b/src/listener/sse.ts new file mode 100644 index 00000000..7d00a105 --- /dev/null +++ b/src/listener/sse.ts @@ -0,0 +1,208 @@ +export interface SseRecord { + readonly data: string + readonly event: string + readonly id?: string +} + +export interface ListenerProtocolErrorOptions { + readonly authorizationRequestId?: string + readonly details?: Readonly> + readonly requestId?: string + readonly retryAfterMs?: number + readonly retryable?: boolean + readonly safeToReauthorize?: boolean + readonly status?: number +} + +export class ListenerProtocolError extends Error { + readonly code: string + readonly authorizationRequestId?: string + readonly details?: Readonly> + readonly requestId?: string + readonly retryAfterMs?: number + readonly retryable: boolean + readonly safeToReauthorize: boolean + readonly status?: number + + constructor( + code: string, + message: string, + options: ListenerProtocolErrorOptions = {}, + ) { + super(message) + this.name = 'ListenerProtocolError' + this.code = code + this.retryable = options.retryable ?? false + this.safeToReauthorize = options.safeToReauthorize ?? false + if (options.authorizationRequestId !== undefined) + this.authorizationRequestId = options.authorizationRequestId + if (options.details !== undefined) this.details = options.details + if (options.requestId !== undefined) this.requestId = options.requestId + if (options.retryAfterMs !== undefined) + this.retryAfterMs = options.retryAfterMs + if (options.status !== undefined) this.status = options.status + } +} + +export async function* decodeSse( + stream: ReadableStream, + maxRecordBytes: number, +): AsyncGenerator { + if (!Number.isSafeInteger(maxRecordBytes) || maxRecordBytes < 1) { + throw protocol( + 'listener_limit_invalid', + 'The negotiated SSE record limit must be a positive safe integer.', + ) + } + const decoder = new TextDecoder('utf-8', {fatal: true, ignoreBOM: true}) + const reader = stream.getReader() + let buffer = '' + let atStart = true + let completed = false + let record = emptyRecord() + + try { + while (true) { + const {done, value} = await reader.read() + let decoded = '' + try { + decoded = decoder.decode(value, {stream: !done}) + } catch { + throw protocol( + 'listener_invalid_utf8', + 'The SSE stream is not valid UTF-8.', + ) + } + + if (atStart && decoded.length > 0) { + atStart = false + if (decoded.startsWith('\uFEFF')) decoded = decoded.slice(1) + } + if (decoded.includes('\uFEFF')) { + throw protocol( + 'listener_invalid_bom', + 'A UTF-8 BOM is allowed only at the beginning of the SSE stream.', + ) + } + buffer += decoded + + while (true) { + const line = takeLine(buffer, done) + if (line === undefined) break + buffer = line.remaining + record.bytes += + new TextEncoder().encode(line.value).byteLength + line.separatorBytes + if (record.bytes > maxRecordBytes) { + throw protocol( + 'listener_record_too_large', + `An SSE record exceeded the negotiated maximum of ${maxRecordBytes} bytes.`, + ) + } + + if (line.value.length === 0) { + if (record.data.length > 0) { + yield { + data: record.data.join('\n'), + event: record.event ?? 'message', + ...(record.id === undefined ? {} : {id: record.id}), + } + } + record = emptyRecord() + continue + } + + if (line.value.startsWith(':')) continue + const separator = line.value.indexOf(':') + const field = + separator === -1 ? line.value : line.value.slice(0, separator) + let fieldValue = separator === -1 ? '' : line.value.slice(separator + 1) + if (fieldValue.startsWith(' ')) fieldValue = fieldValue.slice(1) + + if (field === 'data') record.data.push(fieldValue) + else if (field === 'event') { + if (record.event !== undefined) { + throw protocol( + 'listener_conflicting_event', + 'An SSE record contains duplicate event fields.', + ) + } + record.event = fieldValue + } else if (field === 'id') { + if (fieldValue.includes('\0')) { + throw protocol( + 'listener_invalid_id', + 'An SSE id contains a NUL character.', + ) + } + if (record.id !== undefined) { + throw protocol( + 'listener_conflicting_id', + 'An SSE record contains duplicate id fields.', + ) + } + record.id = fieldValue + } else if (field === 'retry') { + throw protocol( + 'listener_retry_forbidden', + 'The private listener protocol forbids the SSE retry field.', + ) + } + } + + if ( + record.bytes + new TextEncoder().encode(buffer).byteLength > + maxRecordBytes + ) { + throw protocol( + 'listener_record_too_large', + `An SSE record exceeded the negotiated maximum of ${maxRecordBytes} bytes.`, + ) + } + + if (done) { + completed = true + break + } + } + } finally { + if (!completed) await reader.cancel().catch(() => undefined) + reader.releaseLock() + } +} + +interface MutableRecord { + bytes: number + data: string[] + event?: string + id?: string +} + +function emptyRecord(): MutableRecord { + return {bytes: 0, data: []} +} + +function takeLine( + input: string, + atEnd: boolean, +): {value: string; remaining: string; separatorBytes: number} | undefined { + for (let index = 0; index < input.length; index += 1) { + const character = input[index] + if (character !== '\r' && character !== '\n') continue + if (character === '\r' && index === input.length - 1 && !atEnd) + return undefined + const crlf = character === '\r' && input[index + 1] === '\n' + return { + value: input.slice(0, index), + remaining: input.slice(index + (crlf ? 2 : 1)), + separatorBytes: crlf ? 2 : 1, + } + } + if (atEnd && input.length > 0) { + return {value: input, remaining: '', separatorBytes: 0} + } + return undefined +} + +function protocol(code: string, message: string): ListenerProtocolError { + return new ListenerProtocolError(code, message) +} diff --git a/src/mcp/client-command.ts b/src/mcp/client-command.ts new file mode 100644 index 00000000..0e355bb1 --- /dev/null +++ b/src/mcp/client-command.ts @@ -0,0 +1,129 @@ +import {spawn} from 'node:child_process' + +export type McpClient = 'codex' | 'claude' +export type McpClientAction = 'install' | 'uninstall' | 'status' + +const COMMAND_TIMEOUT_MS = 30_000 +const FORCE_KILL_DELAY_MS = 1000 + +export function mcpClientInvocation( + client: McpClient, + action: McpClientAction, +): {command: string; args: string[]} { + if (client === 'codex') { + if (action === 'install') { + return { + command: 'codex', + args: ['mcp', 'add', 'polymorfa', '--', 'polymorfa', 'mcp', 'serve'], + } + } + if (action === 'uninstall') + return {command: 'codex', args: ['mcp', 'remove', 'polymorfa']} + return {command: 'codex', args: ['mcp', 'get', 'polymorfa']} + } + if (action === 'install') { + return { + command: 'claude', + args: [ + 'mcp', + 'add', + '--transport', + 'stdio', + '--scope', + 'user', + 'polymorfa', + '--', + 'polymorfa', + 'mcp', + 'serve', + ], + } + } + if (action === 'uninstall') + return { + command: 'claude', + args: ['mcp', 'remove', '--scope', 'user', 'polymorfa'], + } + return {command: 'claude', args: ['mcp', 'get', 'polymorfa']} +} + +export function runMcpClientCommand( + client: McpClient, + action: McpClientAction, +): Promise<{client: McpClient; action: McpClientAction; output: string}> { + const invocation = mcpClientInvocation(client, action) + return new Promise((resolve, reject) => { + const child = spawn(invocation.command, invocation.args, { + env: process.env, + shell: false, + stdio: ['ignore', 'pipe', 'pipe'], + }) + const stdout: Buffer[] = [] + const stderr: Buffer[] = [] + let closed = false + let settled = false + let forceKillTimer: NodeJS.Timeout | undefined + const settle = (callback: () => void) => { + if (settled) return + settled = true + clearTimeout(timeoutTimer) + callback() + } + const timeoutTimer = setTimeout(() => { + if (closed) return + child.kill('SIGTERM') + forceKillTimer = setTimeout(() => { + if (!closed) child.kill('SIGKILL') + }, FORCE_KILL_DELAY_MS) + forceKillTimer.unref() + settle(() => + reject( + new Error( + `${invocation.command} ${action} timed out after ${COMMAND_TIMEOUT_MS / 1000} seconds and was terminated.`, + ), + ), + ) + }, COMMAND_TIMEOUT_MS) + timeoutTimer.unref() + child.stdout.on('data', (chunk: Uint8Array) => + stdout.push(Buffer.from(chunk)), + ) + child.stderr.on('data', (chunk: Uint8Array) => + stderr.push(Buffer.from(chunk)), + ) + child.once('error', (error) => settle(() => reject(error))) + child.once('close', (code, signal) => { + closed = true + clearTimeout(forceKillTimer) + const output = Buffer.concat([...stdout, ...stderr]) + .toString('utf8') + .trim() + if (signal !== null) { + settle(() => + reject( + new Error( + `${invocation.command} ${action} was terminated by signal ${signal}.${output ? ` ${output}` : ''}`, + ), + ), + ) + return + } + if (code !== 0) { + settle(() => + reject( + new Error( + output || `${invocation.command} exited with code ${code}.`, + ), + ), + ) + return + } + settle(() => resolve({action, client, output})) + }) + }) +} + +export function parseMcpClient(value: string): McpClient { + if (value === 'codex' || value === 'claude') return value + throw new Error('CLIENT must be codex or claude.') +} diff --git a/src/mcp/factory.ts b/src/mcp/factory.ts new file mode 100644 index 00000000..fefd556e --- /dev/null +++ b/src/mcp/factory.ts @@ -0,0 +1,111 @@ +import {lstat} from 'node:fs/promises' + +import {loadCheckpoint} from '../listener/checkpoint.js' +import { + createForwardingSecret, + forwardEvent, + validateForwardDestination, +} from '../listener/forwarding.js' +import {ListenerProtocolClient} from '../listener/protocol.js' +import {runListener} from '../listener/session.js' +import {prepareSecretFile} from '../security/secret-file.js' +import {ListenerManager, type ListenStartInput} from './listener-manager.js' + +export function createListenerManager(version: string): ListenerManager { + return new ListenerManager((input, signal, onProgress) => + runManagedListener(input, signal, version, onProgress), + ) +} + +async function runManagedListener( + input: ListenStartInput, + signal: AbortSignal, + version: string, + onProgress: (value: { + readonly acknowledged: number + readonly forwarded: number + readonly received: number + }) => void, +) { + const {resolveListenerEnvironment} = await import('../context/environment.js') + const environment = await resolveListenerEnvironment({ + env: process.env, + project: input.projectId, + }) + await Promise.all( + input.forwardTo.map((target) => + validateForwardDestination(target, input.allowRemoteForward ?? false), + ), + ) + const secretFile = await prepareSecretFile(input.forwardSecretOut) + const secret = createForwardingSecret() + await secretFile.write(secret) + + let resumeCursor = input.from ?? null + if (input.checkpointFile && (await exists(input.checkpointFile))) { + const checkpoint = await loadCheckpoint( + input.checkpointFile, + environment.projectId, + input.events ?? ['*'], + ) + if (Date.parse(checkpoint.cursorExpiresAt) <= Date.now()) { + throw Object.assign(new Error('The listener checkpoint expired.'), { + code: 'stream_gap', + }) + } + resumeCursor = checkpoint.lastAcknowledgedCursor + } + + const protocol = new ListenerProtocolClient({ + apiOrigin: environment.baseUrl, + credential: environment.credential, + fetch: globalThis.fetch, + version, + }) + return runListener({ + ...(input.checkpointFile ? {checkpointFile: input.checkpointFile} : {}), + events: input.events ?? ['*'], + forward: async ({body, event}) => + forwardEvent({ + ...(input.allowRemoteForward === undefined + ? {} + : {allowRemote: input.allowRemoteForward}), + body, + eventId: field(event, 'id'), + eventType: field(event, 'type'), + ...(input.followRedirects === undefined + ? {} + : {followRedirects: input.followRedirects}), + secret, + signal, + targets: input.forwardTo, + }), + onGap: input.onGap ?? 'stop', + ...(input.maxBuffer === undefined ? {} : {maxBuffer: input.maxBuffer}), + onProgress, + output: async () => {}, + projectId: environment.projectId, + protocol, + resumeCursor, + signal, + }) +} + +function field(value: Record, key: string): string { + const result = value[key] + if (typeof result !== 'string' || result.length === 0) { + throw new Error(`Listener event ${key} must be a nonempty string.`) + } + return result +} + +async function exists(path: string): Promise { + try { + await lstat(path) + return true + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') + return false + throw error + } +} diff --git a/src/mcp/listener-manager.ts b/src/mcp/listener-manager.ts new file mode 100644 index 00000000..93976dbd --- /dev/null +++ b/src/mcp/listener-manager.ts @@ -0,0 +1,228 @@ +import {randomUUID} from 'node:crypto' + +import {normalizeEventFilters} from '../listener/checkpoint.js' +import {validateListenerProjectId} from '../listener/protocol.js' +import type {ListenResult} from '../listener/session.js' + +export interface ListenStartInput { + readonly projectId: string + readonly events?: readonly string[] | undefined + readonly forwardTo: readonly string[] + readonly from?: string | undefined + readonly checkpointFile?: string | undefined + readonly onGap?: 'stop' | 'continue' | undefined + readonly maxBuffer?: number | undefined + readonly forwardSecretOut: string + readonly allowRemoteForward?: boolean | undefined + readonly followRedirects?: boolean | undefined +} + +export interface LocalListenerResult { + readonly listenerId: string + readonly status: 'starting' | 'running' | 'stopping' | 'stopped' | 'failed' + readonly projectId: string + readonly filters: {readonly events: readonly string[]} + readonly counters: { + readonly received: number + readonly acknowledged: number + readonly forwarded: number + readonly failed: number + readonly gaps: number + } + readonly startedAt: string + readonly stoppedAt: string | null + readonly errorCode: string | null +} + +type Runner = ( + input: ListenStartInput, + signal: AbortSignal, + progress: ( + value: Pick, + ) => void, +) => Promise + +interface ManagedListener { + readonly controller: AbortController + readonly key: string + readonly promise: Promise + state: LocalListenerResult +} + +export class ListenerManager { + private readonly listeners = new Map() + + constructor(private readonly run: Runner) {} + + async start(input: ListenStartInput): Promise { + await Promise.resolve() + validateStart(input) + const key = identity(input) + const active = [...this.listeners.values()].filter(({state}) => + ['starting', 'running', 'stopping'].includes(state.status), + ) + if (active.some((listener) => listener.key === key)) { + throw new Error( + 'listener_already_running: an equivalent listener is already running.', + ) + } + if (active.length >= 4) + throw new Error('listener_limit_reached: the listener limit is four.') + + const listenerId = randomUUID() + const controller = new AbortController() + const base: LocalListenerResult = { + counters: { + acknowledged: 0, + failed: 0, + forwarded: 0, + gaps: 0, + received: 0, + }, + errorCode: null, + filters: {events: normalizeEventFilters(input.events ?? ['*'])}, + listenerId, + projectId: input.projectId, + startedAt: new Date().toISOString(), + status: 'starting', + stoppedAt: null, + } + const managed = {} as ManagedListener + const promise = Promise.resolve() + .then(async () => { + managed.state = {...managed.state, status: 'running'} + const result = await this.run(input, controller.signal, (value) => { + managed.state = { + ...managed.state, + counters: {...managed.state.counters, ...value}, + } + }) + managed.state = { + ...managed.state, + counters: { + acknowledged: result.acknowledged, + failed: result.condition === 'forwarding_failed' ? 1 : 0, + forwarded: result.forwarded, + gaps: result.condition === 'stream_gap' ? 1 : 0, + received: result.received, + }, + errorCode: + result.condition === 'success' || result.condition === 'interrupted' + ? null + : result.condition, + status: + result.condition === 'success' || result.condition === 'interrupted' + ? 'stopped' + : 'failed', + stoppedAt: new Date().toISOString(), + } + }) + .catch((error: unknown) => { + if ( + controller.signal.aborted && + ['stopping', 'stopped'].includes(managed.state.status) + ) { + managed.state = { + ...managed.state, + errorCode: null, + status: 'stopped', + stoppedAt: new Date().toISOString(), + } + return + } + managed.state = { + ...managed.state, + counters: { + ...managed.state.counters, + failed: managed.state.counters.failed + 1, + }, + errorCode: errorCode(error), + status: 'failed', + stoppedAt: new Date().toISOString(), + } + }) + Object.assign(managed, {controller, key, promise, state: base}) + this.listeners.set(listenerId, managed) + return clone(base) + } + + async status( + input: {readonly listenerId?: string | undefined} = {}, + ): Promise<{listeners: LocalListenerResult[]}> { + await Promise.resolve() + const values = input.listenerId + ? [this.listeners.get(input.listenerId)].filter( + (value): value is ManagedListener => value !== undefined, + ) + : [...this.listeners.values()] + return {listeners: values.map(({state}) => clone(state))} + } + + async stop(listenerId: string): Promise<{listenerId: string; stopped: true}> { + const listener = this.listeners.get(listenerId) + if ( + !listener || + ['stopping', 'stopped', 'failed'].includes(listener.state.status) + ) { + return {listenerId, stopped: true} + } + listener.state = {...listener.state, status: 'stopping'} + listener.controller.abort(new Error('Listener stopped by MCP client.')) + await Promise.race([listener.promise, timeout(5000)]) + return {listenerId, stopped: true} + } + + async close(): Promise { + await Promise.all( + [...this.listeners.keys()].map((listenerId) => this.stop(listenerId)), + ) + } +} + +function validateStart(input: ListenStartInput): void { + validateListenerProjectId(input.projectId) + if ( + !input.projectId || + input.forwardTo.length < 1 || + input.forwardTo.length > 8 + ) { + throw new Error( + 'MCP listen start requires a project and one to eight forwarding destinations.', + ) + } + if (!input.forwardSecretOut) { + throw new Error('MCP listen start requires forwardSecretOut.') + } + if (input.from && input.checkpointFile) { + throw new Error('from and checkpointFile are mutually exclusive.') + } +} + +function identity(input: ListenStartInput): string { + return JSON.stringify({ + events: normalizeEventFilters(input.events ?? ['*']), + forwardTo: [...input.forwardTo].sort(), + projectId: input.projectId, + }) +} + +function clone(value: LocalListenerResult): LocalListenerResult { + return structuredClone(value) +} + +function errorCode(error: unknown): string { + if ( + error instanceof Error && + 'code' in error && + typeof error.code === 'string' + ) + return error.code + return 'listener_failed' +} + +function timeout(milliseconds: number): Promise { + return new Promise((resolve) => { + const timer = setTimeout(resolve, milliseconds) + timer.unref() + }) +} diff --git a/src/mcp/server.ts b/src/mcp/server.ts new file mode 100644 index 00000000..47027997 --- /dev/null +++ b/src/mcp/server.ts @@ -0,0 +1,107 @@ +import {McpServer} from '@modelcontextprotocol/server' +import { + serveStdio, + type StdioServerTransport, +} from '@modelcontextprotocol/server/stdio' +import {z} from 'zod' + +import {LISTENER_PROJECT_ID_PATTERN} from '../listener/protocol.js' +import type {ListenerManager} from './listener-manager.js' + +export const MCP_TOOL_NAMES = [ + 'polymorfa_listen_start', + 'polymorfa_listen_status', + 'polymorfa_listen_stop', +] as const + +export function createMcpServer( + manager: ListenerManager, + version: string, +): McpServer { + const server = new McpServer({name: '@polymorfa/cli', version}) + server.registerTool( + 'polymorfa_listen_start', + { + annotations: { + destructiveHint: false, + idempotentHint: false, + openWorldHint: true, + readOnlyHint: false, + }, + description: + 'Start an in-process Polymorfa event listener that forwards to local development endpoints.', + inputSchema: z.object({ + allowRemoteForward: z.boolean().optional(), + checkpointFile: z.string().optional(), + events: z.array(z.string()).min(1).max(32).optional(), + followRedirects: z.boolean().optional(), + forwardSecretOut: z.string().min(1), + forwardTo: z.array(z.string()).min(1).max(8), + from: z.string().optional(), + maxBuffer: z.number().int().min(1).max(4096).optional(), + onGap: z.enum(['stop', 'continue']).optional(), + projectId: z.string().regex(LISTENER_PROJECT_ID_PATTERN), + }), + title: 'Start Polymorfa listener', + }, + async (input) => result(await manager.start(input)), + ) + server.registerTool( + 'polymorfa_listen_status', + { + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: true, + }, + description: 'Inspect listeners owned by this MCP stdio server.', + inputSchema: z.object({listenerId: z.string().optional()}), + title: 'Inspect Polymorfa listeners', + }, + async (input) => result(await manager.status(input)), + ) + server.registerTool( + 'polymorfa_listen_stop', + { + annotations: { + destructiveHint: false, + idempotentHint: true, + openWorldHint: false, + readOnlyHint: false, + }, + description: + 'Stop one in-process listener owned by this MCP stdio server.', + inputSchema: z.object({listenerId: z.string().min(1)}), + title: 'Stop Polymorfa listener', + }, + async ({listenerId}) => result(await manager.stop(listenerId)), + ) + return server +} + +export function serveMcp( + manager: ListenerManager, + version: string, + transport?: StdioServerTransport, +): {close(): Promise} { + const handle = serveStdio(() => createMcpServer(manager, version), { + onerror(error) { + process.stderr.write(`MCP error: ${error.message}\n`) + }, + ...(transport === undefined ? {} : {transport}), + }) + return { + async close(): Promise { + await manager.close() + await handle.close() + }, + } +} + +function result(value: object) { + return { + content: [{text: JSON.stringify(value), type: 'text' as const}], + structuredContent: value, + } +} diff --git a/src/output/adapters.ts b/src/output/adapters.ts new file mode 100644 index 00000000..1abb57ca --- /dev/null +++ b/src/output/adapters.ts @@ -0,0 +1,17 @@ +import type {OutputRequest} from './contracts.js' + +interface MetadataLike { + readonly attempts?: number + readonly apiVersion?: string + readonly headers?: Readonly> + readonly requestId?: string + readonly status: number +} + +export function outputRequest(metadata: MetadataLike): OutputRequest { + return { + apiVersion: metadata.apiVersion ?? null, + id: metadata.requestId ?? null, + status: metadata.status, + } +} diff --git a/src/output/contracts.ts b/src/output/contracts.ts new file mode 100644 index 00000000..e4db3ceb --- /dev/null +++ b/src/output/contracts.ts @@ -0,0 +1,86 @@ +export type ResourceOwner = 'project' | 'organization' +export type PrincipalType = 'project_token' | 'organization_key' | 'cli_grant' + +export interface OutputContext { + readonly owner: ResourceOwner + readonly principalType: PrincipalType + readonly profile?: string + readonly organizationId?: string | null + readonly projectId?: string | null +} + +export interface OutputRequest { + readonly id: string | null + readonly status: number + readonly apiVersion: string | null +} + +export interface OutputPage { + readonly nextCursor: string | null + readonly hasMore: boolean +} + +export interface OutputResource { + readonly type: string + readonly id: string +} + +export interface OutputOperation { + readonly id: string +} + +export interface OutputIdempotency { + readonly key: string + readonly receiptId: string + readonly replayed: boolean +} + +export function readEnvelope(input: { + readonly data: T + readonly context: OutputContext + readonly request: OutputRequest + readonly page?: OutputPage +}): { + readonly schemaVersion: 1 + readonly data: T + readonly context: OutputContext + readonly request: OutputRequest + readonly page?: OutputPage +} { + return {schemaVersion: 1, ...input} +} + +export function mutationEnvelope(input: { + readonly resource: OutputResource | null + readonly operation: OutputOperation | null + readonly idempotency: OutputIdempotency | null + readonly result: T + readonly context: OutputContext + readonly request: OutputRequest +}): { + readonly schemaVersion: 1 + readonly resource: OutputResource | null + readonly operation: OutputOperation | null + readonly idempotency: OutputIdempotency | null + readonly result: T + readonly context: OutputContext + readonly request: OutputRequest +} { + return {schemaVersion: 1, ...input} +} + +export function errorEnvelope(input: { + readonly code: string + readonly category: string + readonly message: string + readonly requestId?: string + readonly retryable: boolean + readonly context: Partial +}): { + readonly schemaVersion: 1 + readonly error: Omit + readonly context: Partial +} { + const {context, ...error} = input + return {schemaVersion: 1, error, context} +} diff --git a/src/pagination/collect.ts b/src/pagination/collect.ts new file mode 100644 index 00000000..cf0d8cd5 --- /dev/null +++ b/src/pagination/collect.ts @@ -0,0 +1,44 @@ +import {assertCursorAdvanced} from '../developer/support.js' + +export interface CursorPage { + readonly nextCursor: string | null + readonly hasMore: boolean +} + +export async function collectPages(input: { + readonly fetchPage: (params: { + readonly cursor: string | undefined + readonly limit: number + }) => Promise<{readonly items: readonly T[]; readonly page: CursorPage}> + readonly limit: number + readonly maxItems: number + readonly cursor?: string +}): Promise<{items: T[]; page: CursorPage}> { + if ( + !Number.isInteger(input.maxItems) || + input.maxItems < 1 || + input.maxItems > 10_000 + ) { + throw new RangeError('maxItems must be between 1 and 10000.') + } + const items: T[] = [] + let cursor = input.cursor + let page: CursorPage = {hasMore: false, nextCursor: null} + while (items.length < input.maxItems) { + const remaining = input.maxItems - items.length + const result = await input.fetchPage({ + cursor, + limit: Math.min(input.limit, remaining), + }) + items.push(...result.items.slice(0, remaining)) + if (result.items.length > remaining) { + page = {hasMore: true, nextCursor: null} + break + } + page = result.page + if (!page.hasMore || !page.nextCursor || result.items.length === 0) break + assertCursorAdvanced(cursor, page.nextCursor) + cursor = page.nextCursor + } + return {items, page} +} diff --git a/src/sdk/client.ts b/src/sdk/client.ts index eb012617..711c4f23 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -1,7 +1,8 @@ import { + Client, MessagingClient, - Client as PlatformClient, PolymorfaConfigurationError, + type ClientInstance, type RawClient, type SharedClientOptions, } from '@polymorfa/sdk' @@ -32,7 +33,7 @@ export function createMessagingClient( export function createPlatformClient( environment: ResolvedEnvironment, -): PlatformClient { +): ClientInstance<'organization'> { if (environment.surface !== 'platform') { throw new PolymorfaConfigurationError( 'A typed Platform client requires the Platform API surface.', @@ -40,7 +41,7 @@ export function createPlatformClient( ) } - return new PlatformClient({ + return new Client({ credential: { type: 'organizationApiKey', value: environment.credential.value, diff --git a/src/security/secret-file.ts b/src/security/secret-file.ts new file mode 100644 index 00000000..c0c90750 --- /dev/null +++ b/src/security/secret-file.ts @@ -0,0 +1,80 @@ +import {constants} from 'node:fs' +import {access, link, lstat, open, unlink} from 'node:fs/promises' +import {basename, dirname, join} from 'node:path' +import {randomBytes} from 'node:crypto' + +export interface PreparedSecretFile { + readonly path: string + write(secret: string): Promise +} + +export async function prepareSecretFile( + path: string, +): Promise { + const parent = dirname(path) + const parentStats = await lstat(parent) + if (!parentStats.isDirectory() || parentStats.isSymbolicLink()) { + throw new Error('The secret output parent must be a regular directory.') + } + await access(parent, constants.W_OK) + try { + await lstat(path) + throw new Error(`The secret output file already exists: ${path}`) + } catch (error) { + if (!isMissing(error)) throw error + } + + return { + path, + async write(secret: string): Promise { + const temporary = join( + parent, + `.${basename(path)}.${process.pid}.${randomBytes(8).toString('hex')}.tmp`, + ) + const handle = await open( + temporary, + constants.O_CREAT | constants.O_EXCL | constants.O_WRONLY, + 0o600, + ) + let linked = false + try { + await handle.writeFile(`${secret}\n`, 'utf8') + await handle.sync() + await handle.close() + await link(temporary, path) + linked = true + await unlink(temporary) + await syncDirectory(parent) + } catch (error) { + await handle.close().catch(() => {}) + await unlink(temporary).catch(() => {}) + if (isExists(error)) + throw new Error(`The secret output file already exists: ${path}`) + throw error + } + if (!linked) throw new Error('The secret output file was not created.') + }, + } +} + +async function syncDirectory(path: string): Promise { + if (process.platform === 'win32') return + const handle = await open(path, constants.O_RDONLY) + try { + await handle.sync() + } finally { + await handle.close() + } +} + +function isMissing(error: unknown): boolean { + return isNodeError(error) && error.code === 'ENOENT' +} + +function isExists(error: unknown): boolean { + return isNodeError(error) && error.code === 'EEXIST' +} + +function isNodeError(error: unknown): error is NodeJS.ErrnoException { + return error instanceof Error && 'code' in error +} diff --git a/src/services/auth.ts b/src/services/auth.ts index 0b40c8a9..5ecb0029 100644 --- a/src/services/auth.ts +++ b/src/services/auth.ts @@ -6,6 +6,7 @@ import { import { profileCredentialAccount, + validateProjectToken, type CredentialStore, } from '../config/credentials.js' import { @@ -23,8 +24,9 @@ export interface ProfileSummary { readonly active: boolean readonly apiVersion?: string readonly baseUrl?: string - readonly credentialKind: 'apiKey' + readonly credentialKind: 'apiKey' | 'projectToken' readonly name: string + readonly owner?: 'organization' | 'project' readonly organization?: string readonly project?: string } @@ -66,11 +68,17 @@ export class AuthService { await this.credentials.set(account, apiKey) const metadata: ProfileMetadata = { - ...(previous ?? {}), + ...(previous?.apiVersion === undefined + ? {} + : {apiVersion: previous.apiVersion}), + ...(previous?.baseUrl === undefined ? {} : {baseUrl: previous.baseUrl}), + ...(previous?.organization === undefined + ? {} + : {organization: previous.organization}), credential: {account, kind: 'apiKey'}, } const next: ProfileConfig = { - version: 1, + version: 2, activeProfile: input.profile, profiles: {...config.profiles, [input.profile]: metadata}, } @@ -78,9 +86,10 @@ export class AuthService { try { await this.profiles.save(next) } catch (error) { - if (previousCredential === undefined) { + if (previousAccount !== account || previousCredential === undefined) { await this.credentials.delete(account).catch(() => undefined) - } else { + } + if (previousCredential !== undefined) { await this.credentials .set(previousAccount ?? account, previousCredential) .catch(() => undefined) @@ -88,6 +97,67 @@ export class AuthService { throw error } + if (previousAccount !== undefined && previousAccount !== account) { + await this.credentials.delete(previousAccount).catch(() => undefined) + } + + return summary(input.profile, metadata, input.profile) + } + + async loginProjectToken(input: { + readonly profile: string + readonly project: string + readonly projectToken: string + }): Promise { + assertProfileName(input.profile) + const project = input.project.trim() + if (project.length === 0) { + throw new PolymorfaConfigurationError( + 'Project-token profiles require an exact project.', + 'project', + ) + } + const projectToken = validateProjectToken(input.projectToken) + const config = await this.profiles.load() + const previous = config.profiles[input.profile] + const previousAccount = previous?.credential.account + const previousCredential = + previous === undefined + ? undefined + : await this.credentials.get(previous.credential.account) + const account = profileCredentialAccount(input.profile, 'projectToken') + const metadata: ProfileMetadata = { + ...(previous?.apiVersion === undefined + ? {} + : {apiVersion: previous.apiVersion}), + ...(previous?.baseUrl === undefined ? {} : {baseUrl: previous.baseUrl}), + ...(previous?.organization === undefined + ? {} + : {organization: previous.organization}), + credential: {account, kind: 'projectToken'}, + owner: 'project', + project, + } + + await this.credentials.set(account, projectToken) + try { + await this.profiles.save({ + activeProfile: input.profile, + profiles: {...config.profiles, [input.profile]: metadata}, + version: 2, + }) + } catch (error) { + await this.credentials.delete(account).catch(() => undefined) + if (previousCredential !== undefined && previousAccount !== undefined) { + await this.credentials + .set(previousAccount, previousCredential) + .catch(() => undefined) + } + throw error + } + if (previousAccount !== undefined && previousAccount !== account) { + await this.credentials.delete(previousAccount).catch(() => undefined) + } return summary(input.profile, metadata, input.profile) } @@ -103,7 +173,7 @@ export class AuthService { const remaining = {...config.profiles} delete remaining[selected] const next: ProfileConfig = { - version: 1, + version: 2, profiles: remaining, ...(config.activeProfile === selected || config.activeProfile === undefined @@ -133,7 +203,11 @@ export class AuthService { let credentialFormatValid = false if (credential !== undefined) { try { - validateOrganizationApiKey(credential) + if (metadata.credential.kind === 'projectToken') { + validateProjectToken(credential) + } else { + validateOrganizationApiKey(credential) + } credentialFormatValid = true } catch { credentialFormatValid = false @@ -200,6 +274,7 @@ function summary( ...(profile.organization === undefined ? {} : {organization: profile.organization}), + ...(profile.owner === undefined ? {} : {owner: profile.owner}), ...(profile.project === undefined ? {} : {project: profile.project}), } } diff --git a/src/services/customers.ts b/src/services/customers.ts index 4fcfe475..eccba578 100644 --- a/src/services/customers.ts +++ b/src/services/customers.ts @@ -1,5 +1,6 @@ import { type CreateCustomerPairingLinkRequest, + type ClientInstance, type CreateCustomerRequest, type CreatedCustomerPairingLink, type Customer, @@ -12,7 +13,6 @@ import { type CustomersStatus, type ListCustomerEventsParams, type ListCustomersParams, - type Client as PlatformClient, PolymorfaConfigurationError, PolymorfaValidationError, type RequestOptions, @@ -42,7 +42,7 @@ export function requireCustomerProject(projectId?: string): string { } export async function customerStatus( - client: PlatformClient, + client: ClientInstance<'organization'>, projectId: string, ): Promise> { const response = await client.customers.status(projectId) @@ -50,7 +50,7 @@ export async function customerStatus( } export async function enableCustomers( - client: PlatformClient, + client: ClientInstance<'organization'>, projectId: string, options: RequestOptions, ): Promise> { @@ -59,7 +59,7 @@ export async function enableCustomers( } export async function listCustomers( - client: PlatformClient, + client: ClientInstance<'organization'>, params: ListCustomersParams, ): Promise> { const response = await client.customers.list(params) @@ -67,7 +67,7 @@ export async function listCustomers( } export async function createCustomer( - client: PlatformClient, + client: ClientInstance<'organization'>, body: CreateCustomerRequest, options: RequestOptions, ): Promise> { @@ -76,7 +76,7 @@ export async function createCustomer( } export async function viewCustomer( - client: PlatformClient, + client: ClientInstance<'organization'>, customerId: string, projectId: string, ): Promise> { @@ -85,7 +85,7 @@ export async function viewCustomer( } export async function updateCustomer( - client: PlatformClient, + client: ClientInstance<'organization'>, customerId: string, body: UpdateCustomerRequest, ): Promise> { @@ -100,7 +100,7 @@ export async function updateCustomer( } export async function archiveCustomer( - client: PlatformClient, + client: ClientInstance<'organization'>, customerId: string, body: CustomerProjectRequest, options: RequestOptions, @@ -110,7 +110,7 @@ export async function archiveCustomer( } export async function restoreCustomer( - client: PlatformClient, + client: ClientInstance<'organization'>, customerId: string, body: CustomerProjectRequest, options: RequestOptions, @@ -120,7 +120,7 @@ export async function restoreCustomer( } export async function listCustomerNumbers( - client: PlatformClient, + client: ClientInstance<'organization'>, customerId: string, projectId: string, ): Promise> { @@ -129,7 +129,7 @@ export async function listCustomerNumbers( } export async function listCustomerEvents( - client: PlatformClient, + client: ClientInstance<'organization'>, customerId: string, params: ListCustomerEventsParams, ): Promise> { @@ -138,7 +138,7 @@ export async function listCustomerEvents( } export async function createCustomerPairingLink( - client: PlatformClient, + client: ClientInstance<'organization'>, customerId: string, body: CreateCustomerPairingLinkRequest, options: RequestOptions, @@ -152,7 +152,7 @@ export async function createCustomerPairingLink( } export async function listCustomerPairingLinks( - client: PlatformClient, + client: ClientInstance<'organization'>, customerId: string, projectId: string, ): Promise> { @@ -164,7 +164,7 @@ export async function listCustomerPairingLinks( } export async function revokeCustomerPairingLink( - client: PlatformClient, + client: ClientInstance<'organization'>, customerId: string, pairingLinkId: string, projectId: string, @@ -178,7 +178,7 @@ export async function revokeCustomerPairingLink( } export async function transferCustomerNumber( - client: PlatformClient, + client: ClientInstance<'organization'>, customerId: string, sessionId: string, body: TransferCustomerNumberRequest, diff --git a/src/services/platform-management.ts b/src/services/platform-management.ts index 79bdeced..1b7409f4 100644 --- a/src/services/platform-management.ts +++ b/src/services/platform-management.ts @@ -2,9 +2,9 @@ import { type ApiKey, type ApiKeyDeactivation, type AuditLog, + type ClientInstance, type ListAuditLogsParams, type OrganizationMember, - type Client as PlatformClient, PolymorfaValidationError, type ProjectToken, type RequestOptions, @@ -33,14 +33,14 @@ export interface PlatformResult { } export async function listApiKeys( - client: PlatformClient, + client: ClientInstance<'organization'>, ): Promise> { const response = await client.apiKeys.list() return result(response.data.data, response.metadata) } export async function revokeApiKey( - client: PlatformClient, + client: ClientInstance<'organization'>, keyId: string, options: RequestOptions = {}, ): Promise> { @@ -49,14 +49,14 @@ export async function revokeApiKey( } export async function listMembers( - client: PlatformClient, + client: ClientInstance<'organization'>, ): Promise> { const response = await client.members.list() return result(response.data.data, response.metadata) } export async function listAuditLogs( - client: PlatformClient, + client: ClientInstance<'organization'>, params: ListAuditLogsParams = {}, ): Promise> { const response = await client.auditLogs.list(params) @@ -64,7 +64,7 @@ export async function listAuditLogs( } export async function listSessionBans( - client: PlatformClient, + client: ClientInstance<'organization'>, activeOnly: boolean, ): Promise> { const response = activeOnly @@ -74,14 +74,14 @@ export async function listSessionBans( } export async function listSecurityIncidents( - client: PlatformClient, + client: ClientInstance<'organization'>, ): Promise> { const response = await client.securityIncidents.list() return result(response.data.data, response.metadata) } export async function acknowledgeSecurityIncident( - client: PlatformClient, + client: ClientInstance<'organization'>, incidentId: string, options: RequestOptions = {}, ): Promise> { @@ -93,37 +93,15 @@ export async function acknowledgeSecurityIncident( } export async function listProjectTokens( - client: PlatformClient, + client: ClientInstance<'organization'>, projectId: string, ): Promise> { const response = await client.projectTokens.list(projectId) return result(response.data.data, response.metadata) } -export async function viewWidgetSettings( - client: PlatformClient, - projectId?: string, -): Promise> { - const response = await ( - projectId === undefined ? client : client.project(projectId) - ).quickLinkSettings.retrieve() - return result(response.data, response.metadata) -} - -export async function updateWidgetSettings( - client: PlatformClient, - input: unknown, - projectId?: string, - options: RequestOptions = {}, -): Promise> { - const response = await ( - projectId === undefined ? client : client.project(projectId) - ).quickLinkSettings.update(widgetUpdate(input), options) - return result(response.data, response.metadata) -} - export async function stopSessions( - client: PlatformClient, + client: ClientInstance<'organization'>, sessionIds: readonly string[], projectId?: string, options: RequestOptions = {}, @@ -136,7 +114,7 @@ export async function stopSessions( } export async function deleteSessions( - client: PlatformClient, + client: ClientInstance<'organization'>, sessionIds: readonly string[], projectId?: string, options: RequestOptions = {}, @@ -231,6 +209,28 @@ export function renderProjectTokens(tokens: readonly ProjectToken[]): string { .join('\n') } +export async function viewWidgetSettings( + client: ClientInstance<'organization'>, + projectId?: string, +): Promise> { + const response = await ( + projectId === undefined ? client : client.project(projectId) + ).quickLinkSettings.retrieve() + return result(response.data, response.metadata) +} + +export async function updateWidgetSettings( + client: ClientInstance<'organization'>, + input: unknown, + projectId?: string, + options: RequestOptions = {}, +): Promise> { + const response = await ( + projectId === undefined ? client : client.project(projectId) + ).quickLinkSettings.update(widgetUpdate(input), options) + return result(response.data, response.metadata) +} + export function renderWidgetSettings( settings: WidgetSettings | null, requestedProjectId?: string, @@ -340,10 +340,10 @@ function sessionBatch( } } -function isRecord(value: unknown): value is Readonly> { - return typeof value === 'object' && value !== null && !Array.isArray(value) -} - function plural(count: number, singular: string): string { return count === 1 ? singular : `${singular}s` } + +function isRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index 602e06a0..2dff96dc 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -64,10 +64,13 @@ describe('api', () => { ) expect(exitCode(result.error)).toBe(4) expect(JSON.parse(result.stderr)).toMatchObject({ - category: 'payment_required', - code: 'entitlement_required', - retryable: false, - requestId: 'req_billing', + schemaVersion: 1, + error: { + category: 'payment_required', + code: 'entitlement_required', + retryable: false, + requestId: 'req_billing', + }, }) expect(server.requests).toHaveLength(1) }) @@ -164,9 +167,14 @@ describe('api', () => { expect(result.error).toBeDefined() expect(exitCode(result.error)).toBe(2) expect(JSON.parse(result.stderr)).toEqual({ - category: 'validation', - exitCode: 2, - message: 'API endpoints must be relative and begin with a slash.', + context: {}, + error: { + category: 'validation', + code: 'invalid_endpoint', + message: 'API endpoints must be relative and begin with a slash.', + retryable: false, + }, + schemaVersion: 1, }) expect(server.requests).toHaveLength(0) }) @@ -189,8 +197,8 @@ describe('api', () => { expect(exitCode(result.error)).toBe(2) expect(JSON.parse(result.stderr)).toMatchObject({ - category: 'validation', - exitCode: 2, + error: {category: 'validation', code: 'conflicting_body_input'}, + schemaVersion: 1, }) expect(server.requests).toHaveLength(0) }) @@ -217,10 +225,15 @@ describe('api', () => { expect(exitCode(result.error)).toBe(3) expect(JSON.parse(result.stderr)).toEqual({ - category: 'authentication', - exitCode: 3, - message: 'Invalid API key [REDACTED].', - requestId: 'req_auth', + context: {}, + error: { + category: 'authentication', + code: 'authentication_error', + message: 'Invalid API key [REDACTED].', + requestId: 'req_auth', + retryable: false, + }, + schemaVersion: 1, }) expect(result.stderr).not.toContain( 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', diff --git a/test/commands/auth.test.ts b/test/commands/auth.test.ts index 5fbddeda..e06e9277 100644 --- a/test/commands/auth.test.ts +++ b/test/commands/auth.test.ts @@ -1,41 +1,37 @@ import {runCommand} from '@oclif/test' +import {AsyncEntry} from '@napi-rs/keyring' import {mkdtemp, rm} from 'node:fs/promises' import {tmpdir} from 'node:os' import {join} from 'node:path' import {Readable} from 'node:stream' -import {afterEach, describe, expect, test, vi} from 'vitest' +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' import {withEnvironment} from '../helpers/http-server.js' const secrets = new Map() -vi.mock('@napi-rs/keyring', () => ({ - AsyncEntry: class { - constructor( - private readonly service: string, - private readonly account: string, - ) {} - - deletePassword() { - return Promise.resolve(secrets.delete(`${this.service}:${this.account}`)) - } - - getPassword() { - return Promise.resolve(secrets.get(`${this.service}:${this.account}`)) - } - - setPassword(value: string) { - secrets.set(`${this.service}:${this.account}`, value) - return Promise.resolve() - } - }, -})) - describe('auth commands', () => { const directories: string[] = [] + beforeEach(() => { + vi.spyOn(AsyncEntry.prototype, 'deletePassword').mockImplementation(() => { + secrets.delete('profile:work:apiKey') + return Promise.resolve(true) + }) + vi.spyOn(AsyncEntry.prototype, 'getPassword').mockImplementation(() => + Promise.resolve(secrets.get('profile:work:apiKey')), + ) + vi.spyOn(AsyncEntry.prototype, 'setPassword').mockImplementation( + (value) => { + secrets.set('profile:work:apiKey', value) + return Promise.resolve() + }, + ) + }) + afterEach(async () => { secrets.clear() + vi.restoreAllMocks() await Promise.all( directories .splice(0) @@ -49,11 +45,10 @@ describe('auth commands', () => { expect(exitCode(result.error)).toBe(2) const error = JSON.parse(result.stderr) as { - category: string - message: string + error: {category: string; message: string} } - expect(error.category).toBe('configuration') - expect(error.message).toContain('--with-api-key') + expect(error.error.category).toBe('configuration') + expect(error.error.message).toContain('--with-api-key') }) }) @@ -64,7 +59,7 @@ describe('auth commands', () => { async () => runCommand('auth login --with-api-key --profile work --json'), ) - expect(login.error).toBeUndefined() + expect(login.error, login.stderr).toBeUndefined() expect(JSON.parse(login.stdout)).toEqual({ active: true, credentialKind: 'apiKey', @@ -109,7 +104,8 @@ describe('auth commands', () => { test('reports environment credential validation separately from remote exercise', async () => { await withEnvironment( { - POLYMORFA_API_KEY: `pmfa_${'A'.repeat(72)}`, + POLYMORFA_API_KEY: + 'pmfa_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', POLYMORFA_CLIENT_TOKEN: undefined, }, async () => { @@ -126,6 +122,63 @@ describe('auth commands', () => { ) }) + test('stores a project-token profile with exact project ownership', async () => { + await withConfig(async () => { + const token = `pmfa_pt_${'A'.repeat(94)}\n` + const login = await withStdin(token, async () => + runCommand( + 'auth login --with-project-token --profile project --project project_1 --json', + ), + ) + + expect(login.error, login.stderr).toBeUndefined() + expect(JSON.parse(login.stdout)).toEqual({ + active: true, + credentialKind: 'projectToken', + name: 'project', + owner: 'project', + project: 'project_1', + }) + }) + }) + + test('validates project-token context before reading standard input', async () => { + await withConfig(async () => { + const login = await withStdin('', async () => + runCommand('auth login --with-project-token --profile project --json'), + ) + + expect(exitCode(login.error)).toBe(2) + expect( + (JSON.parse(login.stderr) as {error: {message: string}}).error.message, + ).toContain('requires --project') + }) + }) + + test('reports an environment project token without exposing it', async () => { + const token = `pmfa_pt_${'A'.repeat(94)}` + await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: token, + }, + () => + withConfig(async () => { + const status = await runCommand('auth status --json') + expect(status.error, status.stderr).toBeUndefined() + expect(JSON.parse(status.stdout)).toEqual({ + credentialFormatValid: true, + credentialKind: 'projectToken', + credentialPresent: true, + remoteIdentityValidated: false, + source: 'environment', + }) + expect(status.stdout).not.toContain(token) + }), + ) + }) + async function withConfig(run: () => Promise): Promise { const directory = await mkdtemp(join(tmpdir(), 'polymorfa-auth-command-')) directories.push(directory) diff --git a/test/commands/developer-webhooks.test.ts b/test/commands/developer-webhooks.test.ts new file mode 100644 index 00000000..aece380c --- /dev/null +++ b/test/commands/developer-webhooks.test.ts @@ -0,0 +1,676 @@ +import {mkdtemp, readFile, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' + +import WebhookCreate from '../../src/commands/webhook/create.js' +import WebhookDelete from '../../src/commands/webhook/delete.js' +import WebhookDeliveryAttemptList from '../../src/commands/webhook/delivery/attempt/list.js' +import WebhookDeliveryAttemptView from '../../src/commands/webhook/delivery/attempt/view.js' +import WebhookDeliveryList from '../../src/commands/webhook/delivery/list.js' +import WebhookDeliveryRetry from '../../src/commands/webhook/delivery/retry.js' +import WebhookDeliveryView from '../../src/commands/webhook/delivery/view.js' +import WebhookList from '../../src/commands/webhook/list.js' +import WebhookSecretRotate from '../../src/commands/webhook/secret/rotate.js' +import WebhookTest from '../../src/commands/webhook/test.js' +import WebhookUpdate from '../../src/commands/webhook/update.js' +import WebhookView from '../../src/commands/webhook/view.js' +import {withEnvironment} from '../helpers/http-server.js' + +const state = vi.hoisted(() => ({ + calls: [] as Array<{args: unknown[]; method: string}>, + createSecretAvailable: true, + listPages: [] as Array>, + rotationSecretAvailable: true, +})) + +vi.mock('@polymorfa/sdk', async (importOriginal) => { + const actual = await importOriginal>() + class Client { + events = resource('events') + webhookDeliveries = resource('webhookDeliveries') + webhooks = resource('webhooks') + constructor(options: unknown) { + state.calls.push({args: [options], method: 'constructor'}) + } + + project(projectId: string) { + state.calls.push({args: [projectId], method: 'project'}) + return this + } + } + return {...actual, Client} +}) + +describe('developer webhook commands', () => { + const directories: string[] = [] + + beforeEach(() => { + state.calls.splice(0) + state.createSecretAvailable = true + state.listPages.splice(0) + state.rotationSecretAvailable = true + }) + + afterEach(async () => { + vi.restoreAllMocks() + await Promise.all( + directories + .splice(0) + .map((path) => rm(path, {force: true, recursive: true})), + ) + }) + + test('creates a project webhook and diverts its one-time secret to a private file', async () => { + const directory = await mkdtemp(join(tmpdir(), 'polymorfa-command-')) + directories.push(directory) + const input = join(directory, 'webhook.json') + const secret = join(directory, 'webhook.secret') + await writeFile( + input, + JSON.stringify({ + eventTypes: ['message.*'], + url: 'https://example.test/hook', + }), + ) + + const output = stdout() + await developerEnvironment(() => + WebhookCreate.run([ + '--owner', + 'project', + '--project', + '11111111-1111-4111-8111-111111111111', + '--input', + input, + '--secret-out', + secret, + '--idempotency-key', + 'create_1', + '--json', + ]), + ) + + const value = json(output) + expect(JSON.stringify(value)).not.toContain('pmfa_whsec_test') + expect(value).toMatchObject({ + idempotency: {key: 'create_1', receiptId: 'receipt_1'}, + operation: null, + resource: {id: 'wh_1', type: 'webhook'}, + result: {secretDelivery: {mode: 'file', path: secret, written: true}}, + schemaVersion: 1, + }) + await expect(readFile(secret, 'utf8')).resolves.toBe('pmfa_whsec_test\n') + expect(findCall('webhooks.create').args[1]).toMatchObject({ + idempotencyKey: 'create_1', + }) + }) + + test('preserves a replayed create receipt when no one-time secret is returned', async () => { + state.createSecretAvailable = false + const directory = await mkdtemp(join(tmpdir(), 'polymorfa-command-')) + directories.push(directory) + const input = join(directory, 'webhook.json') + const secret = join(directory, 'webhook.secret') + await writeFile(input, JSON.stringify({url: 'https://example.test/hook'})) + const output = stdout() + + await developerEnvironment(() => + WebhookCreate.run([ + '--owner', + 'project', + '--project', + '11111111-1111-4111-8111-111111111111', + '--input', + input, + '--secret-out', + secret, + '--json', + ]), + ) + + expect(json(output)).toMatchObject({ + result: {secretDelivery: {mode: 'file', path: secret, written: false}}, + }) + await expect(readFile(secret, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT', + }) + }) + + test('lists webhooks with a stable owner and page envelope', async () => { + state.listPages.push(page([{id: 'wh_1'}], 'next_1', true)) + const output = stdout() + + await developerEnvironment(() => + WebhookList.run(['--owner', 'organization', '--limit', '10', '--json']), + ) + + expect(findCall('webhooks.list').args).toEqual([{limit: 10}, {}]) + expect(json(output)).toMatchObject({ + context: {owner: 'organization'}, + data: [{id: 'wh_1'}], + page: {hasMore: true, nextCursor: 'next_1'}, + schemaVersion: 1, + }) + }) + + test('views one webhook through the typed SDK resource', async () => { + const output = stdout() + + await developerEnvironment(() => + WebhookView.run(['wh_1', '--owner', 'organization', '--json']), + ) + + expect(findCall('webhooks.retrieve').args).toEqual(['wh_1', {}]) + expect(json(output)).toMatchObject({data: {id: 'wh_1'}, schemaVersion: 1}) + }) + + test('prints webhook and delivery details in human output', async () => { + const output = stdout() + + await developerEnvironment(async () => { + await WebhookView.run(['wh_1', '--owner', 'organization']) + await WebhookDeliveryView.run(['delivery_1', '--owner', 'organization']) + }) + + const text = output.mock.calls.map(([chunk]) => String(chunk)).join('') + expect(text).toContain('URL\thttps://hooks.example.test/polymorfa') + expect(text).toContain('Event types\tmessage.received') + expect(text).toContain('Status\tsucceeded') + expect(text).toContain('Last status code\t204') + }) + + test('deletes only after confirmation and preserves the receipt', async () => { + const output = stdout() + + await developerEnvironment(() => + WebhookDelete.run([ + 'wh_1', + '--owner', + 'organization', + '--yes', + '--idempotency-key', + 'delete_1', + '--json', + ]), + ) + + expect(findCall('webhooks.delete').args).toEqual([ + 'wh_1', + {idempotencyKey: 'delete_1'}, + ]) + expect(json(output)).toMatchObject({ + idempotency: {key: 'delete_1'}, + operation: null, + resource: {id: 'wh_1', type: 'webhook'}, + }) + }) + + test('shrinks the final delivery page to the remaining item budget', async () => { + state.listPages.push( + page([{id: 'd1'}, {id: 'd2'}], 'next_1', true), + page([{id: 'd3'}], 'next_2', true), + ) + const output = stdout() + await developerEnvironment(() => + WebhookDeliveryList.run([ + '--owner', + 'project', + '--project', + '11111111-1111-4111-8111-111111111111', + '--limit', + '2', + '--paginate', + '--max-items', + '3', + '--status', + 'failed', + '--json', + ]), + ) + + expect(json(output)).toMatchObject({ + data: [{id: 'd1'}, {id: 'd2'}, {id: 'd3'}], + page: {hasMore: true, nextCursor: 'next_2'}, + schemaVersion: 1, + }) + expect( + state.calls + .filter(({method}) => method === 'webhookDeliveries.list') + .map(({args}) => args[0]), + ).toEqual([ + {limit: 2, status: 'failed'}, + {cursor: 'next_1', limit: 1, status: 'failed'}, + ]) + }) + + test('refuses retry before constructing a client when confirmation is unavailable', async () => { + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + const error = await developerEnvironment(() => + WebhookDeliveryRetry.run([ + 'delivery_1', + '--owner', + 'project', + '--project', + '11111111-1111-4111-8111-111111111111', + '--json', + ]), + ).catch((caught: unknown) => caught) + + expect(exitCode(error)).toBe(2) + expect(state.calls).toHaveLength(0) + }) + + test('retrieves one delivery attempt through the first-class SDK method', async () => { + const output = stdout() + await developerEnvironment(() => + WebhookDeliveryAttemptView.run([ + 'delivery_1', + 'attempt_1', + '--owner', + 'organization', + '--json', + ]), + ) + + expect(json(output)).toMatchObject({ + data: {id: 'attempt_1'}, + schemaVersion: 1, + }) + expect(findCall('webhookDeliveries.retrieveAttempt').args).toEqual([ + 'delivery_1', + 'attempt_1', + {}, + ]) + }) + + test('views a delivery through the typed SDK resource', async () => { + const output = stdout() + + await developerEnvironment(() => + WebhookDeliveryView.run([ + 'delivery_1', + '--owner', + 'organization', + '--json', + ]), + ) + + expect(findCall('webhookDeliveries.retrieve').args).toEqual([ + 'delivery_1', + {}, + ]) + expect(json(output)).toMatchObject({ + data: {id: 'delivery_1'}, + schemaVersion: 1, + }) + }) + + test('lists immutable attempts in ascending API order', async () => { + state.listPages.push(page([{id: 'attempt_1', number: 1}], undefined, false)) + const output = stdout() + + await developerEnvironment(() => + WebhookDeliveryAttemptList.run([ + 'delivery_1', + '--owner', + 'project', + '--project', + '11111111-1111-4111-8111-111111111111', + '--json', + ]), + ) + + expect(findCall('webhookDeliveries.listAttempts').args).toEqual([ + 'delivery_1', + {limit: 25}, + {}, + ]) + expect(json(output)).toMatchObject({ + data: [{id: 'attempt_1', number: 1}], + page: {hasMore: false, nextCursor: null}, + schemaVersion: 1, + }) + }) + + test('updates without rotating and keeps the complete idempotency receipt', async () => { + const directory = await mkdtemp(join(tmpdir(), 'polymorfa-command-')) + directories.push(directory) + const input = join(directory, 'webhook.json') + await writeFile(input, JSON.stringify({enabled: false})) + const output = stdout() + + await developerEnvironment(() => + WebhookUpdate.run([ + 'wh_1', + '--owner', + 'organization', + '--input', + input, + '--idempotency-key', + 'update_1', + '--json', + ]), + ) + + expect(findCall('webhooks.update').args).toEqual([ + 'wh_1', + {enabled: false}, + {idempotencyKey: 'update_1'}, + ]) + expect(json(output)).toMatchObject({ + operation: null, + resource: {id: 'wh_1', type: 'webhook'}, + result: {webhook: {id: 'wh_1'}}, + }) + }) + + test('creates a test event and delivery without an extra confirmation', async () => { + const output = stdout() + + await developerEnvironment(() => + WebhookTest.run([ + 'wh_1', + '--owner', + 'project', + '--project', + '11111111-1111-4111-8111-111111111111', + '--event', + 'polymorfa.test', + '--idempotency-key', + 'test_1', + '--json', + ]), + ) + + expect(findCall('webhooks.test').args).toEqual([ + 'wh_1', + {eventType: 'polymorfa.test'}, + {idempotencyKey: 'test_1'}, + ]) + expect(json(output)).toMatchObject({ + operation: {id: 'operation_1'}, + resource: {id: 'delivery_1', type: 'webhook_delivery'}, + }) + }) + + test('rotates with confirmation, a bounded overlap, and no secret in JSON', async () => { + const directory = await mkdtemp(join(tmpdir(), 'polymorfa-command-')) + directories.push(directory) + const secret = join(directory, 'rotated.secret') + const output = stdout() + + await developerEnvironment(() => + WebhookSecretRotate.run([ + 'wh_1', + '--owner', + 'organization', + '--overlap', + '1h', + '--secret-out', + secret, + '--yes', + '--idempotency-key', + 'rotate_1', + '--json', + ]), + ) + + expect(findCall('webhooks.rotateSecret').args).toEqual([ + 'wh_1', + {overlapSeconds: 3600}, + {idempotencyKey: 'rotate_1'}, + ]) + expect(JSON.stringify(json(output))).not.toContain('pmfa_whsec_rotated') + await expect(readFile(secret, 'utf8')).resolves.toBe('pmfa_whsec_rotated\n') + }) + + test('preserves a replayed rotation receipt when no one-time secret is returned', async () => { + state.rotationSecretAvailable = false + const directory = await mkdtemp(join(tmpdir(), 'polymorfa-command-')) + directories.push(directory) + const secret = join(directory, 'rotated.secret') + const output = stdout() + + await developerEnvironment(() => + WebhookSecretRotate.run([ + 'wh_1', + '--owner', + 'organization', + '--secret-out', + secret, + '--yes', + '--json', + ]), + ) + + expect(json(output)).toMatchObject({ + result: {secretDelivery: {mode: 'file', path: secret, written: false}}, + }) + await expect(readFile(secret, 'utf8')).rejects.toMatchObject({ + code: 'ENOENT', + }) + }) + + test('retries a delivery only after confirmation and returns the new attempt', async () => { + const output = stdout() + + await developerEnvironment(() => + WebhookDeliveryRetry.run([ + 'delivery_1', + '--owner', + 'organization', + '--yes', + '--idempotency-key', + 'retry_1', + '--json', + ]), + ) + + expect(findCall('webhookDeliveries.retry').args).toEqual([ + 'delivery_1', + {}, + {idempotencyKey: 'retry_1'}, + ]) + expect(json(output)).toMatchObject({ + operation: {id: 'operation_1'}, + resource: {id: 'delivery_1', type: 'webhook_delivery'}, + result: {attemptId: 'attempt_2'}, + }) + }) +}) + +function resource( + name: string, +): Record Promise> { + return new Proxy( + {}, + { + get(_target, property: string) { + return (...args: unknown[]) => { + const method = `${name}.${property}` + state.calls.push({args, method}) + if (property === 'list' || property === 'listAttempts') { + return Promise.resolve( + state.listPages.shift() ?? page([], undefined, false), + ) + } + if (method === 'webhooks.create') { + return Promise.resolve( + response({ + idempotency: receipt('create_1'), + operationId: null, + ...(state.createSecretAvailable + ? {secret: 'pmfa_whsec_test', secretAvailable: true} + : {secretAvailable: false}), + webhook: {id: 'wh_1'}, + }), + ) + } + if (method === 'webhooks.update') { + return Promise.resolve( + response({ + idempotency: receipt('update_1'), + operationId: null, + webhook: {id: String(args[0])}, + }), + ) + } + if (method === 'webhooks.delete') { + return Promise.resolve( + response({ + deleted: true, + idempotency: receipt('delete_1'), + operationId: null, + webhookId: String(args[0]), + }), + ) + } + if (method === 'webhooks.test') { + return Promise.resolve( + response({ + deliveryId: 'delivery_1', + eventId: 'event_1', + idempotency: receipt('test_1'), + operationId: 'operation_1', + }), + ) + } + if (method === 'webhooks.rotateSecret') { + return Promise.resolve( + response({ + idempotency: receipt('rotate_1'), + operationId: null, + ...(state.rotationSecretAvailable + ? {secret: 'pmfa_whsec_rotated', secretAvailable: true} + : {secretAvailable: false}), + secretMetadata: { + createdAt: '2026-09-08T00:00:00Z', + previousValidUntil: '2026-09-08T01:00:00Z', + version: 2, + }, + webhookId: String(args[0]), + }), + ) + } + if (method === 'webhookDeliveries.retry') { + return Promise.resolve( + response({ + attemptId: 'attempt_2', + deliveryId: String(args[0]), + idempotency: receipt('retry_1'), + operationId: 'operation_1', + }), + ) + } + if (method === 'webhooks.retrieve') { + return Promise.resolve( + response({ + createdAt: '2026-09-08T00:00:00Z', + enabled: true, + eventTypes: ['message.received'], + format: 'native', + headers: [], + id: String(args[0]), + owner: 'organization', + projectId: null, + updatedAt: '2026-09-08T00:00:00Z', + url: 'https://hooks.example.test/polymorfa', + }), + ) + } + if (method === 'webhookDeliveries.retrieve') { + return Promise.resolve( + response({ + attemptCount: 1, + capabilities: {retryable: false}, + completedAt: '2026-09-08T00:00:01Z', + createdAt: '2026-09-08T00:00:00Z', + eventId: 'event_1', + id: String(args[0]), + lastAttemptAt: '2026-09-08T00:00:00Z', + lastOutcome: {errorCode: null, statusCode: 204}, + nextAttemptAt: null, + status: 'succeeded', + webhookId: 'wh_1', + }), + ) + } + if (method === 'webhookDeliveries.retrieveAttempt') { + return Promise.resolve( + response({deliveryId: args[0], id: args[1], response: null}), + ) + } + const first = args[0] + const id = + typeof first === 'string' || typeof first === 'number' + ? String(first) + : 'resource_1' + return Promise.resolve(response({id})) + } + }, + }, + ) +} + +function receipt(key: string) { + return {id: 'receipt_1', key, replayed: false} +} + +function response(data: unknown) { + return { + data, + metadata: {attempts: 1, headers: {}, requestId: 'req_1', status: 200}, + } +} + +function page( + items: unknown[], + nextCursor: string | undefined, + hasMore: boolean, +) { + return { + hasMore, + items, + ...(nextCursor === undefined ? {} : {nextCursor}), + response: response({}), + } +} + +function findCall(method: string) { + const call = state.calls.find((candidate) => candidate.method === method) + if (!call) throw new Error(`Missing ${method} call.`) + return call +} + +function developerEnvironment(run: () => Promise): Promise { + return withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: 'https://api.example.test', + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + }, + run, + ) +} + +function stdout() { + return vi.spyOn(process.stdout, 'write').mockImplementation(() => true) +} + +function json( + output: ReturnType, +): Readonly> { + return JSON.parse(String(output.mock.calls[0]?.[0])) as Readonly< + Record + > +} + +function exitCode(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null || !('oclif' in error)) + return undefined + return (error as {oclif?: {exit?: number}}).oclif?.exit +} diff --git a/test/commands/events.test.ts b/test/commands/events.test.ts new file mode 100644 index 00000000..7fcd2097 --- /dev/null +++ b/test/commands/events.test.ts @@ -0,0 +1,313 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' + +import EventList from '../../src/commands/event/list.js' +import EventReplay from '../../src/commands/event/replay.js' +import EventView from '../../src/commands/event/view.js' +import {withEnvironment} from '../helpers/http-server.js' + +const sdk = vi.hoisted(() => ({ + calls: [] as Array<{method: string; values: unknown[]}>, + responses: [] as unknown[], +})) + +vi.mock('@polymorfa/sdk', async (importOriginal) => { + const original = await importOriginal() + + class Client { + readonly events = { + list: (...values: unknown[]) => response('events.list', values), + replay: (...values: unknown[]) => response('events.replay', values), + retrieve: (...values: unknown[]) => response('events.retrieve', values), + } + + project(): this { + return this + } + } + + return {...original, Client} +}) + +describe('event commands', () => { + beforeEach(() => { + sdk.calls.length = 0 + sdk.responses.length = 0 + }) + + afterEach(() => vi.restoreAllMocks()) + + test('lists project events with filters and the stable page envelope', async () => { + sdk.responses.push(page([event()], 'cursor_2', true)) + + const output = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true) + await environment(() => + EventList.run([ + '--owner', + 'project', + '--project', + '11111111-1111-4111-8111-111111111111', + '--type', + 'message.received', + '--since', + '2026-09-01T00:00:00Z', + '--until', + '2026-09-08T00:00:00Z', + '--limit', + '20', + '--json', + ]), + ) + + expect(sdk.calls).toEqual([ + { + method: 'events.list', + values: [ + { + limit: 20, + since: '2026-09-01T00:00:00Z', + type: 'message.received', + until: '2026-09-08T00:00:00Z', + }, + {}, + ], + }, + ]) + expect(JSON.parse(String(output.mock.calls[0]?.[0]))).toEqual({ + schemaVersion: 1, + data: [event()], + context: { + owner: 'project', + principalType: 'organization_key', + projectId: '11111111-1111-4111-8111-111111111111', + }, + request: {apiVersion: '2026-08-20', id: 'req_1', status: 200}, + page: {hasMore: true, nextCursor: 'cursor_2'}, + }) + }) + + test('views an organization event and explicitly requests its payload', async () => { + sdk.responses.push({data: event(), metadata: metadata()}) + const output = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true) + + await environment(() => + EventView.run([ + 'evt_1', + '--owner', + 'organization', + '--include-payload', + '--json', + ]), + ) + + expect(sdk.calls).toEqual([ + { + method: 'events.retrieve', + values: ['evt_1', {includePayload: true}, {}], + }, + ]) + expect(JSON.parse(String(output.mock.calls[0]?.[0]))).toMatchObject({ + schemaVersion: 1, + data: {id: 'evt_1'}, + context: {owner: 'organization', principalType: 'organization_key'}, + request: {id: 'req_1', status: 200}, + }) + }) + + test('prints event details and the decoded payload in human output', async () => { + sdk.responses.push({ + data: { + ...event(), + payload: { + contentType: 'application/json', + data: Buffer.from('{"text":"hello"}').toString('base64'), + encoding: 'base64', + }, + payloadAvailability: 'available', + }, + metadata: metadata(), + }) + const output = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true) + + await environment(() => + EventView.run(['evt_1', '--owner', 'organization', '--include-payload']), + ) + + const text = output.mock.calls.map(([chunk]) => String(chunk)).join('') + expect(text).toContain('ID\tevt_1') + expect(text).toContain('Payload availability\tavailable') + expect(text).toContain('Payload\t{"text":"hello"}') + }) + + test('rejects cursor and filter combinations before calling the SDK', async () => { + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const error = await environment(() => + EventList.run([ + '--owner', + 'organization', + '--cursor', + 'cursor_1', + '--type', + 'message.received', + '--json', + ]), + ).catch((caught: unknown) => caught) + + expect(exitCode(error)).toBe(2) + expect(sdk.calls).toHaveLength(0) + }) + + test('replays an event with confirmation, retry identity, and an operation receipt', async () => { + sdk.responses.push({ + data: replayReceipt(), + metadata: {...metadata(), status: 202}, + }) + const output = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true) + + await environment(() => + EventReplay.run([ + 'evt_1', + '--webhook', + 'wh_1', + '--owner', + 'project', + '--project', + '11111111-1111-4111-8111-111111111111', + '--yes', + '--idempotency-key', + 'replay_1', + '--json', + ]), + ) + + expect(sdk.calls).toEqual([ + { + method: 'events.replay', + values: ['evt_1', {webhookId: 'wh_1'}, {idempotencyKey: 'replay_1'}], + }, + ]) + const replayed = JSON.parse(String(output.mock.calls[0]?.[0])) as { + operation: unknown + } + expect(replayed.operation).toEqual({id: 'operation_1'}) + expect(replayed).toMatchObject({ + schemaVersion: 1, + resource: {id: 'delivery_1', type: 'webhook_delivery'}, + operation: {id: 'operation_1'}, + idempotency: {key: 'replay_1', receiptId: 'receipt_1', replayed: false}, + result: {deliveryId: 'delivery_1', eventId: 'evt_1'}, + context: { + owner: 'project', + projectId: '11111111-1111-4111-8111-111111111111', + }, + request: {id: 'req_1', status: 202}, + }) + }) + + test('does not replay in a noninteractive process without --yes', async () => { + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const error = await environment(() => + EventReplay.run([ + 'evt_1', + '--webhook', + 'wh_1', + '--owner', + 'organization', + '--json', + ]), + ).catch((caught: unknown) => caught) + + expect(exitCode(error)).toBe(2) + expect(sdk.calls).toHaveLength(0) + }) +}) + +function response(method: string, values: unknown[]): Promise { + sdk.calls.push({method, values}) + const value = sdk.responses.shift() + if (value === undefined) throw new Error(`Missing response for ${method}.`) + return Promise.resolve(value) +} + +function page( + items: readonly unknown[], + nextCursor: string | undefined, + hasMore: boolean, +) { + return { + hasMore, + items, + nextCursor, + response: {data: {}, metadata: metadata()}, + } +} + +function metadata() { + return { + apiVersion: '2026-08-20', + attempts: 1, + headers: {}, + requestId: 'req_1', + status: 200, + } +} + +function event() { + return { + createdAt: '2026-09-08T00:00:00Z', + environment: 'development', + id: 'evt_1', + metadataExpiresAt: '2026-10-08T00:00:00Z', + organizationId: 'org_1', + payload: null, + payloadAvailability: 'not_retained', + projectId: '11111111-1111-4111-8111-111111111111', + replayableUntil: null, + source: 'runtime', + type: 'message.received', + } +} + +function replayReceipt() { + return { + deliveryId: 'delivery_1', + eventId: 'evt_1', + idempotency: { + createdAt: '2026-09-08T00:00:00Z', + expiresAt: '2026-09-09T00:00:00Z', + id: 'receipt_1', + key: 'replay_1', + replayed: false, + }, + operationId: 'operation_1', + } +} + +function environment(run: () => Promise): Promise { + return withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: 'https://api.example.test', + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + }, + run, + ) +} + +function exitCode(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null || !('oclif' in error)) { + return undefined + } + return (error as {oclif?: {exit?: number}}).oclif?.exit +} diff --git a/test/commands/listen.test.ts b/test/commands/listen.test.ts new file mode 100644 index 00000000..01442a7a --- /dev/null +++ b/test/commands/listen.test.ts @@ -0,0 +1,313 @@ +import { + createServer, + type IncomingMessage, + type ServerResponse, +} from 'node:http' + +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' + +import {writeListenLine} from '../../src/commands/listen.js' +import {writeCheckpoint} from '../../src/listener/checkpoint.js' +import {withEnvironment} from '../helpers/http-server.js' +import {temporaryDirectory} from '../support/fs.js' +import {join} from 'node:path' + +describe('listen', () => { + let close: (() => Promise) | undefined + + afterEach(async () => { + await close?.() + close = undefined + }) + + test('emits JSONL and acknowledges only after the event line', async () => { + const requests: string[] = [] + const server = createServer( + (request, response) => void route(request, response, requests), + ) + await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)) + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('missing server address') + const baseUrl = `http://127.0.0.1:${address.port}` + close = () => + new Promise((resolve, reject) => + server.close((error) => (error ? reject(error) : resolve())), + ) + activeBaseUrl = baseUrl + + const result = await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_BASE_URL: baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + () => runCommand(`listen --project ${PROJECT} --event message.* --json`), + ) + + expect(exitCode(result.error)).toBe(3) + const lines = result.stdout + .trim() + .split('\n') + .map((line) => JSON.parse(line) as {type: string}) + expect(lines.map(({type}) => type)).toEqual([ + 'ready', + 'event', + 'revoked', + 'terminal', + ]) + expect(lines[0]).toMatchObject({ + acceptedCursor: null, + filters: {events: ['message.*']}, + startPosition: 'now', + type: 'ready', + }) + expect(lines[0]).not.toHaveProperty('resume') + expect(requests).toEqual(['authorize', 'stream', 'ack']) + }) + + test('waits for the stdout write callback before completing a sink', async () => { + let callback: ((error?: Error | null) => void) | undefined + const writer = { + write( + _value: string, + _encoding: BufferEncoding, + done: (error?: Error | null) => void, + ) { + callback = done + return true + }, + } + let settled = false + const writing = writeListenLine( + { + listenerId: AUTH, + observedAt: '2026-09-08T00:00:00Z', + schemaVersion: 1, + sequence: 1, + streamId: STREAM, + type: 'ready', + }, + true, + false, + writer, + ).then(() => { + settled = true + }) + + await Promise.resolve() + expect(settled).toBe(false) + callback?.() + await writing + expect(settled).toBe(true) + }) + + test('emits a synthetic gap before failing an expired checkpoint', async () => { + const path = join(await temporaryDirectory(), 'listen.json') + await writeCheckpoint(path, { + cursorExpiresAt: '2026-09-07T00:00:00Z', + filters: {events: ['message.*']}, + kind: 'polymorfa_cli_listener_checkpoint', + lastAcknowledgedCursor: 'expired-cursor', + projectId: PROJECT, + protocolVersion: '1', + schemaVersion: 1, + updatedAt: '2026-09-07T00:00:00Z', + }) + + const result = await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + () => + runCommand( + `listen --project ${PROJECT} --event message.* --checkpoint-file ${path} --json`, + ), + ) + + expect(exitCode(result.error)).toBe(9) + expect(JSON.parse(result.stdout)).toMatchObject({ + reason: 'checkpoint_expired', + sequence: 0, + streamId: null, + type: 'gap', + }) + }) + + test.each([ + '--allow-remote-forward', + '--follow-redirects', + '--forward-secret-out /tmp/unused-listener-secret', + ])('requires --forward-to when using %s', async (flag) => { + const result = await runCommand(`listen --project ${PROJECT} ${flag}`) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('--forward-to') + }) + + test('reports an actionable machine error when forwarding has no secret destination', async () => { + const result = await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + () => + runCommand( + `listen --project ${PROJECT} --forward-to http://127.0.0.1:1 --json`, + ), + ) + + expect(exitCode(result.error)).toBe(8) + expect(JSON.parse(result.stderr)).toMatchObject({ + error: {code: 'action_required'}, + schemaVersion: 1, + }) + }) +}) + +const PROJECT = '11111111-1111-4111-8111-111111111111' +const STREAM = '22222222-2222-4222-8222-222222222222' +const AUTH = '33333333-3333-4333-8333-333333333333' +const EVENT = '44444444-4444-4444-8444-444444444444' +const ORGANIZATION = '55555555-5555-4555-8555-555555555555' +let activeBaseUrl = '' + +function route( + request: IncomingMessage, + response: ServerResponse, + requests: string[], +) { + request.resume() + if (request.url === '/v1/cli/listener-authorizations') { + requests.push('authorize') + response.writeHead(201, {'content-type': 'application/json'}) + response.end(JSON.stringify({data: authorization()})) + return + } + if (request.url?.endsWith('/events')) { + requests.push('stream') + response.writeHead(200, {'content-type': 'text/event-stream'}) + response.end( + [ + sse('ready', ready()), + sse('event', event(), 'cursor_1'), + sse('revoked', { + ...base('revoked', 3), + lastCursor: 'cursor_1', + reason: 'principal_revoked', + }), + ].join(''), + ) + return + } + if (request.url?.endsWith('/ack')) { + requests.push('ack') + response.writeHead(200, {'content-type': 'application/json'}) + response.end( + JSON.stringify({ + data: { + acknowledgedCursor: 'cursor_1', + replayed: false, + sequence: 2, + streamId: STREAM, + }, + }), + ) + return + } + response.writeHead(404).end() +} + +function authorization() { + return { + ackUrl: `${activeBaseUrl}/v1/cli/listener-streams/${STREAM}/ack`, + authorizationId: AUTH, + capabilities: {resume: true}, + closeUrl: `${activeBaseUrl}/v1/cli/listener-streams/${STREAM}/close`, + credentialExpiresAt: '2030-01-01T00:00:00Z', + filters: {events: ['message.*']}, + limits: { + ackTimeoutMs: 30_000, + heartbeatIntervalMs: 15_000, + maxEventBytes: 65_536, + maxInFlight: 32, + }, + listenerCredential: `pmfa_ls_${'a'.repeat(43)}`, + mediaType: 'text/event-stream', + projectId: PROJECT, + protocolVersion: '1', + resume: { + acceptedCursor: null, + cursorExpiresAt: '2030-01-01T00:00:00Z', + oldestAvailableCursor: null, + startPosition: 'now', + }, + streamId: STREAM, + streamUrl: `${activeBaseUrl}/v1/cli/listener-streams/${STREAM}/events`, + } +} + +function base(type: string, sequence: number) { + return { + sentAt: '2026-09-08T00:00:00Z', + sequence, + streamId: STREAM, + type, + v: 1, + } +} +function ready() { + return { + ...base('ready', 1), + connectionExpiresAt: '2030-01-01T00:00:00Z', + heartbeatIntervalMs: 15_000, + maxInFlight: 32, + projectId: PROJECT, + resume: { + acceptedCursor: null, + cursorExpiresAt: '2030-01-01T00:00:00Z', + oldestAvailableCursor: null, + startPosition: 'now', + }, + } +} +function event() { + return { + ...base('event', 2), + body: { + contentType: 'application/json', + data: 'eyJvayI6dHJ1ZX0=', + encoding: 'base64', + }, + cursor: 'cursor_1', + event: { + createdAt: '2026-09-08T00:00:00Z', + environment: 'development', + id: EVENT, + metadataExpiresAt: '2026-10-08T00:00:00Z', + organizationId: ORGANIZATION, + payloadAvailability: 'available', + projectId: PROJECT, + replayableUntil: null, + source: 'runtime', + type: 'message.received', + }, + } +} +function sse(type: string, data: unknown, id?: string) { + return `event: ${type}\n${id ? `id: ${id}\n` : ''}data: ${JSON.stringify(data)}\n\n` +} + +function exitCode(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null || !('oclif' in error)) + return undefined + return (error as {oclif?: {exit?: number}}).oclif?.exit +} diff --git a/test/commands/mcp-status.test.ts b/test/commands/mcp-status.test.ts new file mode 100644 index 00000000..6ceebad3 --- /dev/null +++ b/test/commands/mcp-status.test.ts @@ -0,0 +1,92 @@ +import {afterEach, beforeEach, describe, expect, test, vi} from 'vitest' + +const clientCommand = vi.hoisted(() => ({ + run: vi.fn(), +})) + +vi.mock('../../src/mcp/client-command.js', async (importOriginal) => { + const original = + await importOriginal() + return {...original, runMcpClientCommand: clientCommand.run} +}) + +import McpStatus from '../../src/commands/mcp/status.js' + +describe('mcp status', () => { + beforeEach(() => clientCommand.run.mockReset()) + afterEach(() => vi.restoreAllMocks()) + + test('maps the client expected missing-server result to not configured', async () => { + clientCommand.run.mockRejectedValueOnce( + new Error("Error: No MCP server named 'polymorfa' found."), + ) + const output = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true) + + await McpStatus.run(['codex', '--json']) + + expect(JSON.parse(String(output.mock.calls[0]?.[0]))).toEqual([ + {client: 'codex', configured: false}, + ]) + }) + + test('rethrows client execution failures instead of calling them not configured', async () => { + clientCommand.run.mockRejectedValueOnce(new Error('spawn codex ENOENT')) + const output = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true) + vi.spyOn(process.stderr, 'write').mockImplementation(() => true) + + const error = await McpStatus.run(['codex', '--json']).catch( + (caught: unknown) => caught, + ) + + expect(exitCode(error)).toBe(1) + expect(output).not.toHaveBeenCalled() + }) + + test('reports a client whose executable is missing as not configured', async () => { + const missing = Object.assign(new Error('spawn claude ENOENT'), { + code: 'ENOENT', + errno: -2, + path: 'claude', + syscall: 'spawn claude', + }) + clientCommand.run + .mockResolvedValueOnce({action: 'status', client: 'codex', output: 'ok'}) + .mockRejectedValueOnce(missing) + const output = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true) + + await McpStatus.run(['--json']) + + expect(JSON.parse(String(output.mock.calls[0]?.[0]))).toEqual([ + {client: 'codex', configured: true, output: 'ok'}, + {client: 'claude', configured: false, reason: 'client_not_found'}, + ]) + }) + + test('recognizes the Claude CLI missing-server result', async () => { + clientCommand.run.mockRejectedValueOnce( + new Error('No MCP server named "polymorfa". Configured servers: example'), + ) + const output = vi + .spyOn(process.stdout, 'write') + .mockImplementation(() => true) + + await McpStatus.run(['claude', '--json']) + + expect(JSON.parse(String(output.mock.calls[0]?.[0]))).toEqual([ + {client: 'claude', configured: false}, + ]) + }) +}) + +function exitCode(error: unknown): number | undefined { + if (typeof error !== 'object' || error === null || !('oclif' in error)) { + return undefined + } + return (error as {oclif?: {exit?: number}}).oclif?.exit +} diff --git a/test/commands/platform-management.test.ts b/test/commands/platform-management.test.ts index 9db8031c..3c6e151c 100644 --- a/test/commands/platform-management.test.ts +++ b/test/commands/platform-management.test.ts @@ -341,6 +341,84 @@ describe('Platform management commands', () => { expect(server.requests).toHaveLength(0) }) + test('requests asynchronous stops for a bounded session batch', async () => { + server = await startHttpServer({body: {data: {stopping: 1}}}) + + const result = await platformEnvironment(() => + runCommand( + 'session stop session_1 " session_2 " --project project_1 --idempotency-key stop_batch', + ), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('Requested stops for 1 matching session.') + expect(result.stdout).toContain('This does not wait for completion.') + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + projectId: 'project_1', + sessionIds: ['session_1', 'session_2'], + }) + expect(server.requests[0]).toMatchObject({ + method: 'POST', + url: '/platform/sessions/stop', + }) + expect(server.requests[0]?.headers['idempotency-key']).toBe('stop_batch') + }) + + test('rejects duplicate and oversized session batches before transport', async () => { + server = await startHttpServer({body: {data: {stopping: 0}}}) + + const duplicate = await platformEnvironment(() => + runCommand('session stop session_1 session_1 --json'), + ) + const oversizedIds = Array.from( + {length: 101}, + (_, index) => `session_${index}`, + ).join(' ') + const oversized = await platformEnvironment(() => + runCommand(`session stop ${oversizedIds} --json`), + ) + + expect(exitCode(duplicate.error)).toBe(2) + expect(duplicate.stderr).toContain('Session IDs must be unique') + expect(exitCode(oversized.error)).toBe(2) + expect(oversized.stderr).toContain('Session batches cannot exceed 100 IDs') + expect(server.requests).toHaveLength(0) + }) + + test('permanently removes matching sessions only with confirmation', async () => { + server = await startHttpServer({body: {data: {removed: 2}}}) + + const result = await platformEnvironment(() => + runCommand( + 'session delete session_1 session_2 --project project_1 --yes --idempotency-key delete_batch', + ), + ) + + expect(result.error).toBeUndefined() + expect(result.stdout).toContain( + 'Removed 2 matching sessions from management.', + ) + expect(result.stdout).toContain('Device termination is best effort.') + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + projectId: 'project_1', + sessionIds: ['session_1', 'session_2'], + }) + expect(server.requests[0]?.url).toBe('/platform/sessions/delete') + expect(server.requests[0]?.headers['idempotency-key']).toBe('delete_batch') + }) + + test('refuses batch session deletion without --yes before transport', async () => { + server = await startHttpServer({body: {data: {removed: 1}}}) + + const result = await platformEnvironment(() => + runCommand('session delete session_1 --json'), + ) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('Missing required flag yes') + expect(server.requests).toHaveLength(0) + }) + test('represents absent organization-default widget settings as null', async () => { server = await startHttpServer({ body: {data: null}, @@ -449,84 +527,6 @@ describe('Platform management commands', () => { expect(server.requests).toHaveLength(0) }) - test('requests asynchronous stops for a bounded session batch', async () => { - server = await startHttpServer({body: {data: {stopping: 1}}}) - - const result = await platformEnvironment(() => - runCommand( - 'session stop session_1 " session_2 " --project project_1 --idempotency-key stop_batch', - ), - ) - - expect(result.error).toBeUndefined() - expect(result.stdout).toContain('Requested stops for 1 matching session.') - expect(result.stdout).toContain('This does not wait for completion.') - expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ - projectId: 'project_1', - sessionIds: ['session_1', 'session_2'], - }) - expect(server.requests[0]).toMatchObject({ - method: 'POST', - url: '/platform/sessions/stop', - }) - expect(server.requests[0]?.headers['idempotency-key']).toBe('stop_batch') - }) - - test('rejects duplicate and oversized session batches before transport', async () => { - server = await startHttpServer({body: {data: {stopping: 0}}}) - - const duplicate = await platformEnvironment(() => - runCommand('session stop session_1 session_1 --json'), - ) - const oversizedIds = Array.from( - {length: 101}, - (_, index) => `session_${index}`, - ).join(' ') - const oversized = await platformEnvironment(() => - runCommand(`session stop ${oversizedIds} --json`), - ) - - expect(exitCode(duplicate.error)).toBe(2) - expect(duplicate.stderr).toContain('Session IDs must be unique') - expect(exitCode(oversized.error)).toBe(2) - expect(oversized.stderr).toContain('Session batches cannot exceed 100 IDs') - expect(server.requests).toHaveLength(0) - }) - - test('permanently removes matching sessions only with confirmation', async () => { - server = await startHttpServer({body: {data: {removed: 2}}}) - - const result = await platformEnvironment(() => - runCommand( - 'session delete session_1 session_2 --project project_1 --yes --idempotency-key delete_batch', - ), - ) - - expect(result.error).toBeUndefined() - expect(result.stdout).toContain( - 'Removed 2 matching sessions from management.', - ) - expect(result.stdout).toContain('Device termination is best effort.') - expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ - projectId: 'project_1', - sessionIds: ['session_1', 'session_2'], - }) - expect(server.requests[0]?.url).toBe('/platform/sessions/delete') - expect(server.requests[0]?.headers['idempotency-key']).toBe('delete_batch') - }) - - test('refuses batch session deletion without --yes before transport', async () => { - server = await startHttpServer({body: {data: {removed: 1}}}) - - const result = await platformEnvironment(() => - runCommand('session delete session_1 --json'), - ) - - expect(exitCode(result.error)).toBe(2) - expect(result.stderr).toContain('Missing required flag yes') - expect(server.requests).toHaveLength(0) - }) - async function jsonInput(name: string, value: unknown): Promise { directory ??= await mkdtemp(join(tmpdir(), 'polymorfa-platform-')) const path = join(directory, name) diff --git a/test/commands/quicklinks.test.ts b/test/commands/quicklinks.test.ts index e987cf56..ab7a8423 100644 --- a/test/commands/quicklinks.test.ts +++ b/test/commands/quicklinks.test.ts @@ -38,6 +38,7 @@ test('creates a QuickLink and uploads separate test history with exact project s POLYMORFA_API_KEY: `pmfa_${'A'.repeat(72)}`, POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, }, async () => { const created = await runCommand( @@ -66,3 +67,97 @@ test('creates a QuickLink and uploads separate test history with exact project s await rm(directory, {recursive: true, force: true}) } }) + +const projectId = '11111111-1111-4111-8111-111111111111' +const apiKey = `pmfa_${'A'.repeat(72)}` +const projectToken = `pmfa_pt_${'A'.repeat(94)}` + +test('retrieves QuickLink status with an exact-project token and encoded ID', async () => { + const server = await startHttpSequenceServer([ + { + body: { + success: true, + data: { + connectedAt: null, + errorCode: null, + expiresAt: null, + id: 'ql/123', + openedAt: null, + phone: null, + session: 'quicklink-123', + status: 'pending', + }, + }, + }, + ]) + try { + const result = await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: projectToken, + }, + () => runCommand(`quicklink view ql/123 --project ${projectId} --json`), + ) + expect(result.error, result.stderr).toBeUndefined() + expect(JSON.parse(result.stdout)).toMatchObject({ + data: {data: {id: 'ql/123', status: 'pending', expiresAt: null}}, + }) + expect(server.requests[0]).toMatchObject({ + method: 'GET', + url: '/messaging/quicklinks/ql%2F123', + }) + expect(server.requests[0]?.headers.authorization).toBe( + `Bearer ${projectToken}`, + ) + } finally { + await server.close() + } +}) + +test('cancels a QuickLink only after confirmation', async () => { + const server = await startHttpSequenceServer([ + {body: {message: 'QuickLink cancelled', success: true}}, + ]) + try { + await withEnvironment( + { + POLYMORFA_API_KEY: apiKey, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + }, + async () => { + const refused = await runCommand( + `quicklink cancel ql_123 --project ${projectId} --json`, + ) + expect(exitCode(refused.error)).toBe(2) + expect(server.requests).toHaveLength(0) + + const unsupported = await runCommand( + `quicklink cancel ql_123 --project ${projectId} --yes --idempotency-key cancel_1 --json`, + ) + expect(exitCode(unsupported.error)).toBe(2) + expect(unsupported.stderr).toContain('Nonexistent flag') + expect(server.requests).toHaveLength(0) + + const cancelled = await runCommand( + `quicklink cancel ql_123 --project ${projectId} --yes --json`, + ) + expect(cancelled.error, cancelled.stderr).toBeUndefined() + }, + ) + expect(server.requests[0]).toMatchObject({ + method: 'DELETE', + url: '/messaging/quicklinks/ql_123', + }) + expect(server.requests[0]?.headers['idempotency-key']).toBeUndefined() + } 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 11b0b013..773d1ec4 100644 --- a/test/commands/version.test.ts +++ b/test/commands/version.test.ts @@ -34,8 +34,8 @@ describe('version', () => { expect(exitCode(result.error)).toBe(2) expect(JSON.parse(result.stderr)).toMatchObject({ - category: 'validation', - exitCode: 2, + error: {category: 'validation', code: 'validation_error'}, + schemaVersion: 1, }) expect(result.stdout).toBe('') }) diff --git a/test/config/profiles.test.ts b/test/config/profiles.test.ts index 999cb5ac..a6155ae2 100644 --- a/test/config/profiles.test.ts +++ b/test/config/profiles.test.ts @@ -52,7 +52,7 @@ describe('ProfileRepository', () => { const directory = await temporaryDirectory() const repository = new ProfileRepository(join(directory, 'config.json')) - await expect(repository.load()).resolves.toEqual({profiles: {}, version: 1}) + await expect(repository.load()).resolves.toEqual({profiles: {}, version: 2}) }) test('writes non-secret profile metadata atomically with restricted permissions', async () => { @@ -68,7 +68,7 @@ describe('ProfileRepository', () => { organization: 'org_123', }, }, - version: 1, + version: 2, }) const serialized = await readFile(path, 'utf8') @@ -80,21 +80,77 @@ describe('ProfileRepository', () => { organization: 'org_123', }, }, - version: 1, + version: 2, }) expect(serialized).not.toContain('pmfa_') expect((await stat(path)).mode & 0o777).toBe(0o600) expect((await stat(join(directory, 'nested'))).mode & 0o777).toBe(0o700) }) + test('migrates version 1 metadata without inventing an owner', async () => { + const directory = await temporaryDirectory() + const path = join(directory, 'config.json') + const repository = new ProfileRepository(path) + await writeFile( + path, + JSON.stringify({ + activeProfile: 'work', + profiles: { + work: { + credential: {account: 'profile:work:apiKey', kind: 'apiKey'}, + project: 'project_1', + }, + }, + version: 1, + }), + 'utf8', + ) + + await expect(repository.load()).resolves.toEqual({ + activeProfile: 'work', + profiles: { + work: { + credential: {account: 'profile:work:apiKey', kind: 'apiKey'}, + project: 'project_1', + }, + }, + version: 2, + }) + }) + + test('requires project-token profiles to declare exact project ownership', async () => { + const directory = await temporaryDirectory() + const path = join(directory, 'config.json') + const repository = new ProfileRepository(path) + await writeFile( + path, + JSON.stringify({ + profiles: { + project: { + credential: { + account: 'profile:project:projectToken', + kind: 'projectToken', + }, + owner: 'organization', + project: 'project_1', + }, + }, + version: 2, + }), + 'utf8', + ) + + await expect(repository.load()).rejects.toThrow('invalid shape') + }) + test('rejects unsupported or malformed configuration', async () => { const directory = await temporaryDirectory() const path = join(directory, 'config.json') const repository = new ProfileRepository(path) - await writeFile(path, '{"version":2,"profiles":{}}', 'utf8') + await writeFile(path, '{"version":3,"profiles":{}}', 'utf8') await expect(repository.load()).rejects.toThrow( - 'Unsupported Polymorfa config version 2.', + 'Unsupported Polymorfa config version 3.', ) }) @@ -111,7 +167,7 @@ describe('ProfileRepository', () => { credential: {account: 'profile:work:apiKey', kind: 'apiKey'}, }, }, - version: 1, + version: 2, }), 'utf8', ) diff --git a/test/context/environment.test.ts b/test/context/environment.test.ts index 7da99747..aa92cfb1 100644 --- a/test/context/environment.test.ts +++ b/test/context/environment.test.ts @@ -1,13 +1,270 @@ import {describe, expect, test, vi} from 'vitest' +import type {ProjectWithStats} from '@polymorfa/sdk' import type {CredentialStore} from '../../src/config/credentials.js' import type {ProfileConfig} from '../../src/config/profiles.js' import { + resolveDeveloperEnvironment, + resolveListenerEnvironment, resolveCommandEnvironment, resolveEnvironment, } from '../../src/context/environment.js' describe('resolveEnvironment', () => { + test('resolves an exact-project listener token without treating it as a browser token', async () => { + await expect( + resolveListenerEnvironment({ + env: { + POLYMORFA_BASE_URL: 'http://127.0.0.1:4567', + POLYMORFA_PROJECT_TOKEN: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + apiVersion: '2026-09-01', + project: PROJECT, + timeoutMs: 4321, + }), + ).resolves.toMatchObject({ + apiVersion: '2026-09-01', + baseUrl: 'http://127.0.0.1:4567', + credential: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + principalType: 'project_token', + projectId: PROJECT, + timeoutMs: 4321, + }) + }) + + test('rejects ambiguous durable listener credentials', async () => { + await expect( + resolveListenerEnvironment({ + env: { + POLYMORFA_API_KEY: + 'pmfa_DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD', + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt', + }, + project: 'project_1', + }), + ).rejects.toThrow('ambiguous') + }) + + test('resolves explicit project ownership from a project-token profile', async () => { + const profile: ProfileConfig = { + activeProfile: 'project', + profiles: { + project: { + credential: { + account: 'profile:project:projectToken', + kind: 'projectToken', + }, + owner: 'project', + project: PROJECT, + }, + }, + version: 2, + } + + await expect( + resolveDeveloperEnvironment( + {env: {}, profile: 'project'}, + {load: () => Promise.resolve(profile), save: () => Promise.resolve()}, + fakeCredentials( + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ), + ), + ).resolves.toMatchObject({ + context: { + owner: 'project', + principalType: 'project_token', + profile: 'project', + projectId: PROJECT, + }, + credential: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }) + }) + + test('uses an explicit developer profile without evaluating ambient credentials', async () => { + await expect( + resolveDeveloperEnvironment( + { + env: { + POLYMORFA_API_KEY: 'ambient_key', + POLYMORFA_CLIENT_TOKEN: 'ambient_client', + }, + owner: 'organization', + profile: 'work', + }, + fakeProfiles(), + fakeCredentials( + 'pmfa_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + ), + ), + ).resolves.toMatchObject({ + context: {owner: 'organization', profile: 'work'}, + credential: + 'pmfa_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + }) + }) + + test('uses an explicit listener profile without evaluating ambient credentials', async () => { + const profile: ProfileConfig = { + profiles: { + project: { + credential: { + account: 'profile:project:projectToken', + kind: 'projectToken', + }, + owner: 'project', + project: '11111111-1111-4111-8111-111111111111', + }, + }, + version: 2, + } + await expect( + resolveListenerEnvironment( + { + env: { + POLYMORFA_API_KEY: 'ambient_key', + POLYMORFA_CLIENT_TOKEN: 'ambient_client', + }, + profile: 'project', + }, + {load: () => Promise.resolve(profile), save: () => Promise.resolve()}, + fakeCredentials( + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ), + ), + ).resolves.toMatchObject({ + credential: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + profile: 'project', + projectId: '11111111-1111-4111-8111-111111111111', + }) + }) + + test('prefers an environment API key over the active listener profile', async () => { + const credentials = fakeCredentials( + 'pmfa_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + ) + + await expect( + resolveListenerEnvironment( + { + env: { + POLYMORFA_API_KEY: + 'pmfa_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + }, + project: '11111111-1111-4111-8111-111111111111', + }, + fakeProfiles(), + credentials, + ), + ).resolves.toMatchObject({ + credential: + 'pmfa_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + principalType: 'organization_key', + }) + expect(credentials.get).not.toHaveBeenCalled() + }) + + test('resolves an organization project slug to its canonical UUID', async () => { + const lookup = vi.fn(() => Promise.resolve([fakeProject('checkout')])) + + await expect( + resolveListenerEnvironment( + { + env: { + POLYMORFA_API_KEY: + 'pmfa_DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD', + }, + project: 'checkout', + }, + fakeProfiles(), + fakeCredentials('unused'), + lookup, + ), + ).resolves.toMatchObject({ + principalType: 'organization_key', + projectId: PROJECT, + }) + expect(lookup).toHaveBeenCalledOnce() + }) + + test('shares canonical slug resolution with developer commands', async () => { + await expect( + resolveDeveloperEnvironment( + { + env: { + POLYMORFA_API_KEY: + 'pmfa_DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD', + }, + owner: 'project', + project: 'checkout', + }, + fakeProfiles(), + fakeCredentials('unused'), + () => Promise.resolve([fakeProject('checkout')]), + ), + ).resolves.toMatchObject({ + context: {owner: 'project', projectId: PROJECT}, + }) + }) + + test.each([ + {expected: 'not found', projects: []}, + { + expected: 'ambiguous', + projects: [ + fakeProject('checkout'), + fakeProject('checkout', OTHER_PROJECT), + ], + }, + ])( + 'rejects an organization slug that is $expected', + async ({expected, projects}) => { + await expect( + resolveListenerEnvironment( + { + env: { + POLYMORFA_API_KEY: + 'pmfa_DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDD', + }, + project: 'checkout', + }, + fakeProfiles(), + fakeCredentials('unused'), + () => Promise.resolve(projects), + ), + ).rejects.toThrow(expected) + }, + ) + + test('rejects a project-token slug without attempting project discovery', async () => { + const lookup = vi.fn(() => Promise.resolve([fakeProject('checkout')])) + + await expect( + resolveListenerEnvironment( + {env: {POLYMORFA_PROJECT_TOKEN: 'pmfa_pt'}, project: 'checkout'}, + fakeProfiles(), + fakeCredentials('unused'), + lookup, + ), + ).rejects.toThrow('exact project UUID') + expect(lookup).not.toHaveBeenCalled() + }) + + test('requires an owner before developer transport and never infers it from a token', async () => { + await expect( + resolveDeveloperEnvironment({ + env: { + POLYMORFA_PROJECT_TOKEN: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + project: PROJECT, + }), + ).rejects.toThrow('--owner') + }) + test('resolves a platform server key without returning unrelated environment values', () => { expect( resolveEnvironment({ @@ -88,19 +345,28 @@ describe('resolveEnvironment', () => { }) test('prefers an environment credential over the active profile', async () => { - const credentials = fakeCredentials('pmfa_profile-key') + const credentials = fakeCredentials( + 'pmfa_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + ) await expect( resolveCommandEnvironment( { - env: {POLYMORFA_API_KEY: 'pmfa_environment-key'}, + env: { + POLYMORFA_API_KEY: + 'pmfa_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + }, surface: 'platform', }, fakeProfiles(), credentials, ), ).resolves.toMatchObject({ - credential: {type: 'apiKey', value: 'pmfa_environment-key'}, + credential: { + type: 'apiKey', + value: + 'pmfa_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + }, source: 'environment', }) expect(credentials.get).not.toHaveBeenCalled() @@ -111,10 +377,16 @@ describe('resolveEnvironment', () => { resolveCommandEnvironment( {env: {}, surface: 'platform'}, fakeProfiles(), - fakeCredentials('pmfa_profile-key'), + fakeCredentials( + 'pmfa_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + ), ), ).resolves.toMatchObject({ - credential: {type: 'apiKey', value: 'pmfa_profile-key'}, + credential: { + type: 'apiKey', + value: + 'pmfa_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + }, profile: 'work', source: 'profile', }) @@ -124,21 +396,52 @@ describe('resolveEnvironment', () => { await expect( resolveCommandEnvironment( { - env: {POLYMORFA_API_KEY: 'pmfa_environment-key'}, + env: { + POLYMORFA_API_KEY: + 'pmfa_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + }, profile: 'work', surface: 'messaging', }, fakeProfiles(), - fakeCredentials('pmfa_profile-key'), + fakeCredentials( + 'pmfa_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + ), ), ).resolves.toMatchObject({ - credential: {type: 'apiKey', value: 'pmfa_profile-key'}, + credential: { + type: 'apiKey', + value: + 'pmfa_BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB', + }, profile: 'work', source: 'profile', }) }) }) +const PROJECT = '11111111-1111-4111-8111-111111111111' +const OTHER_PROJECT = '22222222-2222-4222-8222-222222222222' + +function fakeProject(slug: string, id: string = PROJECT): ProjectWithStats { + return { + _creationTime: 0, + _id: id, + activeSessions: 0, + defaultTier: 'free', + icon: {type: 'emoji', value: 'x'}, + iconUrl: null, + isActive: true, + lastActivity: null, + name: slug, + orgId: '33333333-3333-4333-8333-333333333333', + slug, + stage: 'development', + totalMessages: 0, + totalSessions: 0, + } +} + function fakeProfiles() { const config: ProfileConfig = { activeProfile: 'work', @@ -147,7 +450,7 @@ function fakeProfiles() { credential: {account: 'profile:work:apiKey', kind: 'apiKey'}, }, }, - version: 1, + version: 2, } return { load: () => Promise.resolve(config), diff --git a/test/context/owner.test.ts b/test/context/owner.test.ts new file mode 100644 index 00000000..cdee4b1c --- /dev/null +++ b/test/context/owner.test.ts @@ -0,0 +1,41 @@ +import {describe, expect, test} from 'vitest' + +import {resolveOwnerContext} from '../../src/context/owner.js' + +describe('resolveOwnerContext', () => { + test('requires explicit owner and exact project context', () => { + expect(() => + resolveOwnerContext({principalType: 'organization_key'}), + ).toThrow('owner') + expect(() => + resolveOwnerContext({owner: 'project', principalType: 'project_token'}), + ).toThrow('project') + expect( + resolveOwnerContext({ + owner: 'project', + principalType: 'project_token', + projectId: 'project_1', + }), + ).toEqual({ + owner: 'project', + principalType: 'project_token', + projectId: 'project_1', + }) + }) + + test('rejects project context and project tokens for organization ownership', () => { + expect(() => + resolveOwnerContext({ + owner: 'organization', + principalType: 'project_token', + }), + ).toThrow('project token') + expect(() => + resolveOwnerContext({ + owner: 'organization', + principalType: 'organization_key', + projectId: 'project_1', + }), + ).toThrow('project override') + }) +}) diff --git a/test/developer/client.test.ts b/test/developer/client.test.ts new file mode 100644 index 00000000..2fd5f7f8 --- /dev/null +++ b/test/developer/client.test.ts @@ -0,0 +1,55 @@ +import {Client} from '@polymorfa/sdk' +import {describe, expect, test} from 'vitest' + +import {createDeveloperSession} from '../../src/developer/client.js' +import {withEnvironment} from '../helpers/http-server.js' + +describe('createDeveloperSession', () => { + test('creates an immutable project view from an organization key', async () => { + const session = await withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: undefined, + }, + () => + createDeveloperSession({ + owner: 'project', + project: PROJECT, + }), + ) + + expect(session.client).toBeInstanceOf(Client) + expect(session.client.owner).toBe('project') + expect(session.client.projectId).toBe(PROJECT) + expect(session.context).toMatchObject({ + owner: 'project', + principalType: 'organization_key', + projectId: PROJECT, + }) + }) + + test('constructs a project-root client for an exact project token', async () => { + const session = await withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + () => + createDeveloperSession({ + owner: 'project', + project: PROJECT, + }), + ) + + expect(session.client).toBeInstanceOf(Client) + expect(session.client.owner).toBe('project') + expect(session.client.projectId).toBe(PROJECT) + expect(session.context.principalType).toBe('project_token') + }) +}) + +const PROJECT = '11111111-1111-4111-8111-111111111111' diff --git a/test/developer/output.test.ts b/test/developer/output.test.ts new file mode 100644 index 00000000..22ce81ac --- /dev/null +++ b/test/developer/output.test.ts @@ -0,0 +1,45 @@ +import {describe, expect, test} from 'vitest' + +import {developerMutation} from '../../src/developer/output.js' + +const base = { + context: { + owner: 'organization' as const, + principalType: 'organization_key' as const, + }, + metadata: {attempts: 1, headers: {}, status: 200}, + resource: null, +} + +describe('developer mutation output', () => { + test('requires a complete idempotency receipt', () => { + expect(() => + developerMutation({ + ...base, + receipt: { + idempotency: {id: 'receipt_1', key: 'key_1', replayed: false}, + operationId: null, + }, + }), + ).not.toThrow() + + expect(() => + developerMutation({ + ...base, + receipt: { + idempotency: {id: 'receipt_1', key: '', replayed: false}, + operationId: null, + }, + }), + ).toThrow('key') + expect(() => + developerMutation({ + ...base, + receipt: { + idempotency: {id: 'receipt_1', key: 'key_1'}, + operationId: null, + }, + }), + ).toThrow('replayed') + }) +}) diff --git a/test/developer/render.test.ts b/test/developer/render.test.ts new file mode 100644 index 00000000..f022b578 --- /dev/null +++ b/test/developer/render.test.ts @@ -0,0 +1,123 @@ +import {describe, expect, test} from 'vitest' + +import { + renderAttemptDetail, + renderDeliveryDetail, + renderEventDetail, + renderWebhookDetail, + terminalText, +} from '../../src/developer/render.js' + +const event = { + createdAt: '2026-09-08T00:00:00Z', + environment: 'development', + id: 'evt_1', + payload: { + contentType: 'application/json' as const, + data: Buffer.from('{"message":"hi\u001b[2J"}').toString('base64'), + encoding: 'base64' as const, + }, + payloadAvailability: 'available' as const, + projectId: null, + replayableUntil: null, + source: 'runtime', + type: 'message.received', +} + +describe('developer detail renderers', () => { + test('renders event metadata and the payload only when requested', () => { + const withoutPayload = renderEventDetail(event, false) + expect(withoutPayload).toContain('Type\tmessage.received') + expect(withoutPayload).toContain('Payload availability\tavailable') + expect(withoutPayload).not.toContain('Payload\t') + + const withPayload = renderEventDetail(event, true) + expect(withPayload).toContain('Payload\t{"message":"hi\\u001b[2J"}') + expect(withPayload).not.toContain(String.fromCharCode(27)) + expect(renderEventDetail({...event, payload: null}, true)).toContain( + 'Payload\tnot included', + ) + }) + + test('renders webhook settings without secret metadata', () => { + const text = renderWebhookDetail({ + createdAt: '2026-09-08T00:00:00Z', + enabled: true, + eventTypes: ['message.received', 'session.connected'], + format: 'native', + headers: [{name: 'x-team'}], + id: 'wh_1', + owner: 'project', + projectId: '11111111-1111-4111-8111-111111111111', + updatedAt: '2026-09-08T01:00:00Z', + url: 'https://hooks.example.test/polymorfa', + }) + expect(text.split('\n')).toEqual([ + 'ID\twh_1', + 'Owner\tproject', + 'Project\t11111111-1111-4111-8111-111111111111', + 'URL\thttps://hooks.example.test/polymorfa', + 'Enabled\ttrue', + 'Format\tnative', + 'Event types\tmessage.received, session.connected', + 'Header names\tx-team', + 'Created\t2026-09-08T00:00:00Z', + 'Updated\t2026-09-08T01:00:00Z', + ]) + }) + + test('renders delivery status and last outcome', () => { + const text = renderDeliveryDetail({ + attemptCount: 2, + capabilities: {retryable: true}, + completedAt: null, + createdAt: '2026-09-08T00:00:00Z', + eventId: 'evt_1', + id: 'delivery_1', + lastAttemptAt: '2026-09-08T00:01:00Z', + lastOutcome: {errorCode: null, statusCode: 503}, + nextAttemptAt: '2026-09-08T00:05:00Z', + status: 'retrying', + webhookId: 'wh_1', + }) + expect(text).toContain('Status\tretrying') + expect(text).toContain('Last status code\t503') + expect(text).toContain('Last error code\t-') + expect(text).toContain('Completed\t-') + }) + + test('renders the redacted attempt response excerpt as inert text', () => { + const attempt = { + completedAt: '2026-09-08T00:00:01Z', + deliveryId: 'delivery_1', + durationMs: 812, + errorCode: null, + id: 'attempt_1', + nextRetryAt: null, + number: 1, + response: null, + startedAt: '2026-09-08T00:00:00Z', + status: 'failed', + statusCode: 500, + } + expect(renderAttemptDetail(attempt)).not.toContain('Response') + + const excerpt = `boom\n${String.fromCharCode(27)}]8;;x${String.fromCharCode(7)}` + const text = renderAttemptDetail({ + ...attempt, + response: {contentType: 'text/plain', excerpt, truncated: true}, + }) + expect(text).toContain('Response content type\ttext/plain') + expect(text).toContain( + 'Response excerpt\tboom\\u000a\\u001b]8;;x\\u0007 (truncated)', + ) + expect(text.split('\n')).toHaveLength(12) + }) + + test('escapes C0, C1, and line-separator characters', () => { + const input = `a\tb${String.fromCharCode(0x9b)}c${String.fromCharCode(0x2028)}` + expect(terminalText(input)).toBe('a\\u0009b\\u009bc\\u2028') + expect(terminalText(null)).toBe('-') + expect(terminalText(false)).toBe('false') + }) +}) diff --git a/test/developer/secrets.test.ts b/test/developer/secrets.test.ts new file mode 100644 index 00000000..541a76dd --- /dev/null +++ b/test/developer/secrets.test.ts @@ -0,0 +1,89 @@ +import {mkdtemp, readFile, rm} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' +import {PassThrough} from 'node:stream' + +import {afterEach, describe, expect, test} from 'vitest' + +import {prepareOneTimeSecretDelivery} from '../../src/developer/secrets.js' + +describe('one-time secret delivery', () => { + let directory: string | undefined + + afterEach(async () => { + if (directory) await rm(directory, {force: true, recursive: true}) + }) + + test('requires a private output file for JSON and noninteractive output', async () => { + await expect( + prepareOneTimeSecretDelivery({interactive: false, json: true}), + ).rejects.toThrow('--secret-out') + }) + + test('rejects an explicitly empty output path', async () => { + await expect( + prepareOneTimeSecretDelivery({ + interactive: false, + json: true, + path: '', + }), + ).rejects.toThrow('must not be empty') + }) + + test('preflights and atomically writes a new private file', async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-secret-')) + const path = join(directory, 'webhook.secret') + const delivery = await prepareOneTimeSecretDelivery({ + interactive: false, + json: true, + path, + }) + + expect(delivery).toMatchObject({mode: 'file', path}) + + await expect(delivery.deliver('pmfa_whsec_value')).resolves.toEqual({ + mode: 'file', + path, + written: true, + }) + await expect(readFile(path, 'utf8')).resolves.toBe('pmfa_whsec_value\n') + }) + + test('reveals once only through the interactive diagnostic stream', async () => { + const output = new PassThrough() + let written = '' + output.on('data', (chunk: unknown) => { + written += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk) + }) + const delivery = await prepareOneTimeSecretDelivery({ + interactive: true, + json: false, + output, + }) + + expect(delivery).toMatchObject({mode: 'tty'}) + + await expect(delivery.deliver('pmfa_whsec_value')).resolves.toEqual({ + mode: 'tty', + written: true, + }) + expect(written).toContain('pmfa_whsec_value') + }) + + test('reports an unavailable replay without writing a placeholder', async () => { + directory = await mkdtemp(join(tmpdir(), 'polymorfa-secret-')) + const path = join(directory, 'webhook.secret') + const delivery = await prepareOneTimeSecretDelivery({ + interactive: false, + json: true, + path, + }) + + await expect(delivery.deliver(null)).resolves.toEqual({ + mode: 'file', + path, + written: false, + }) + await expect(readFile(path, 'utf8')).rejects.toMatchObject({code: 'ENOENT'}) + }) +}) diff --git a/test/developer/support.test.ts b/test/developer/support.test.ts new file mode 100644 index 00000000..d723888c --- /dev/null +++ b/test/developer/support.test.ts @@ -0,0 +1,72 @@ +import {describe, expect, test, vi} from 'vitest' + +import type {CursorPageLike} from '../../src/developer/client.js' +import { + assertCursorNotCombined, + listResources, +} from '../../src/developer/support.js' + +const metadata = {attempts: 1, headers: {}, status: 200} + +function page(items: number[], nextCursor: string | null) { + return { + hasMore: nextCursor !== null, + items, + nextCursor, + response: {data: {data: items}, metadata}, + } as unknown as CursorPageLike +} + +describe('developer command support', () => { + test('rejects filters combined with an opaque cursor', () => { + expect(() => + assertCursorNotCombined('cursor_1', [undefined, 'failed']), + ).toThrow('cursor') + expect(() => assertCursorNotCombined('cursor_1', [undefined])).not.toThrow() + expect(() => assertCursorNotCombined(undefined, ['failed'])).not.toThrow() + }) + + test('caps an oversized server page at --max-items without a resumable cursor', async () => { + const fetchPage = vi.fn(() => Promise.resolve(page([1, 2, 3, 4], 'c2'))) + + const result = await listResources( + {limit: 2, maxItems: 3, paginate: true}, + fetchPage, + ) + + expect(result.items).toEqual([1, 2, 3]) + expect(result.page).toEqual({hasMore: true, nextCursor: null}) + expect(fetchPage).toHaveBeenCalledTimes(1) + }) + + test('rejects an unchanged continuation cursor', async () => { + const fetchPage = vi.fn(() => Promise.resolve(page([1], 'same'))) + + await expect( + listResources( + {cursor: 'same', limit: 1, maxItems: 5, paginate: true}, + fetchPage, + ), + ).rejects.toThrow('same continuation cursor') + expect(fetchPage).toHaveBeenCalledTimes(1) + }) + + test('follows advancing cursors until --max-items is reached', async () => { + const fetchPage = vi + .fn() + .mockResolvedValueOnce(page([1, 2], 'c2')) + .mockResolvedValueOnce(page([3], 'c3')) + + const result = await listResources( + {limit: 2, maxItems: 3, paginate: true}, + fetchPage, + ) + + expect(result.items).toEqual([1, 2, 3]) + expect(result.page).toEqual({hasMore: true, nextCursor: 'c3'}) + expect(fetchPage.mock.calls).toEqual([ + [{limit: 2}], + [{cursor: 'c2', limit: 1}], + ]) + }) +}) diff --git a/test/errors/present.test.ts b/test/errors/present.test.ts index 8e557b71..ac4571dc 100644 --- a/test/errors/present.test.ts +++ b/test/errors/present.test.ts @@ -6,6 +6,11 @@ import { import {describe, expect, test} from 'vitest' import {presentError} from '../../src/errors/present.js' +import { + EventFilterError, + CheckpointError, +} from '../../src/listener/checkpoint.js' +import {ListenerProtocolError} from '../../src/listener/sse.js' describe('presentError', () => { test('preserves the SDK category without exposing a credential value', () => { @@ -16,16 +21,20 @@ describe('presentError', () => { expect(presentError(error, ['pmfa_secret-value'])).toEqual({ category: 'configuration', + code: 'configuration_error', exitCode: 2, message: 'Credential [REDACTED] is invalid.', + retryable: false, }) }) test('uses the internal category for an unknown error', () => { expect(presentError(new Error('socket exploded'))).toEqual({ category: 'internal', + code: 'internal_error', exitCode: 1, message: 'socket exploded', + retryable: false, }) }) @@ -34,8 +43,10 @@ describe('presentError', () => { presentError(new PolymorfaCancelledError('Request cancelled.')), ).toEqual({ category: 'cancelled', + code: 'cancelled_error', exitCode: 130, message: 'Request cancelled.', + retryable: false, }) }) @@ -46,9 +57,46 @@ describe('presentError', () => { expect(presentError(error)).toEqual({ category: 'authentication', + code: 'authentication_error', exitCode: 3, message: 'Invalid API key.', requestId: 'req_123', + retryable: false, + }) + }) + + test('preserves listener protocol codes and classifies local validation', () => { + expect( + presentError( + new ListenerProtocolError('listener_invalid_frame', 'Bad frame.'), + ), + ).toMatchObject({ + category: 'stream', + code: 'listener_invalid_frame', + exitCode: 1, + }) + expect(presentError(new EventFilterError('Bad filters.'))).toMatchObject({ + category: 'validation', + code: 'listener_filter_invalid', + exitCode: 2, + }) + expect(presentError(new CheckpointError('Bad checkpoint.'))).toMatchObject({ + category: 'validation', + code: 'listener_checkpoint_invalid', + exitCode: 2, + }) + }) + + test('keeps retryable listener response categories safe when metadata is absent', () => { + const error = new ListenerProtocolError( + 'listener_state_unavailable', + 'Listener state is unavailable.', + {status: 503}, + ) + + expect(presentError(error)).toMatchObject({ + category: 'server', + retryable: true, }) }) }) diff --git a/test/input/confirmation.test.ts b/test/input/confirmation.test.ts new file mode 100644 index 00000000..0a43a0e8 --- /dev/null +++ b/test/input/confirmation.test.ts @@ -0,0 +1,35 @@ +import {PassThrough, Readable} from 'node:stream' + +import {describe, expect, test} from 'vitest' + +import {confirmDangerous} from '../../src/input/confirmation.js' + +describe('dangerous confirmations', () => { + test('accepts --yes without touching terminal input', async () => { + await expect( + confirmDangerous({prompt: 'Delete?', yes: true}), + ).resolves.toBeUndefined() + }) + + test('requires --yes outside a TTY', async () => { + const input = Readable.from([]) as Readable & {isTTY?: boolean} + input.isTTY = false + const output = new PassThrough() as PassThrough & {isTTY?: boolean} + output.isTTY = false + + await expect( + confirmDangerous({input, output, prompt: 'Delete?', yes: false}), + ).rejects.toThrow('--yes') + }) + + test('accepts an explicit yes from an interactive terminal', async () => { + const input = Readable.from(['yes\n']) as Readable & {isTTY?: boolean} + input.isTTY = true + const output = new PassThrough() as PassThrough & {isTTY?: boolean} + output.isTTY = true + + await expect( + confirmDangerous({input, output, prompt: 'Delete?', yes: false}), + ).resolves.toBeUndefined() + }) +}) diff --git a/test/input/duration.test.ts b/test/input/duration.test.ts new file mode 100644 index 00000000..e42a0c28 --- /dev/null +++ b/test/input/duration.test.ts @@ -0,0 +1,36 @@ +import {describe, expect, test} from 'vitest' + +import { + parseBoundedDurationSeconds, + parseDurationMilliseconds, + parseDurationSeconds, + parsePositiveDurationMilliseconds, +} from '../../src/input/duration.js' + +describe('duration input', () => { + test.each([ + ['250ms', 250], + ['30s', 30_000], + ['5m', 300_000], + ['2h', 7_200_000], + ['1d', 86_400_000], + ])('parses %s', (value, expected) => { + expect(parseDurationMilliseconds(value)).toBe(expected) + }) + + test('requires whole seconds for server overlap windows', () => { + expect(parseDurationSeconds('1h')).toBe(3600) + expect(() => parseDurationSeconds('1500ms')).toThrow( + 'whole number of seconds', + ) + }) + + test('supports command-specific positive and bounded durations', () => { + expect(parsePositiveDurationMilliseconds('1ms')).toBe(1) + expect(() => parsePositiveDurationMilliseconds('0s')).toThrow( + 'greater than zero', + ) + expect(parseBoundedDurationSeconds('24h', 86_400)).toBe(86_400) + expect(() => parseBoundedDurationSeconds('2d', 86_400)).toThrow('at most') + }) +}) diff --git a/test/input/idempotency.test.ts b/test/input/idempotency.test.ts new file mode 100644 index 00000000..e9730d35 --- /dev/null +++ b/test/input/idempotency.test.ts @@ -0,0 +1,21 @@ +import {describe, expect, test} from 'vitest' + +import {idempotencyKey} from '../../src/input/idempotency.js' + +describe('idempotency keys', () => { + test('preserves a caller key and generates a stable CLI-shaped key otherwise', () => { + expect(idempotencyKey(' caller-key ')).toBe('caller-key') + expect(idempotencyKey(undefined, () => '1234')).toBe('pmfa_cli_1234') + }) + + test('rejects an explicitly empty or whitespace key instead of generating one', () => { + const generate = () => 'must-not-be-used' + expect(() => idempotencyKey('', generate)).toThrow('must not be empty') + expect(() => idempotencyKey(' \t ', generate)).toThrow('must not be empty') + }) + + test('rejects keys longer than the 255-byte wire contract', () => { + expect(() => idempotencyKey('a'.repeat(256))).toThrow('255 bytes') + expect(() => idempotencyKey('\u00e9'.repeat(128))).toThrow('255 bytes') + }) +}) diff --git a/test/input/stdin.test.ts b/test/input/stdin.test.ts index 42eb6d72..abaa3e46 100644 --- a/test/input/stdin.test.ts +++ b/test/input/stdin.test.ts @@ -18,7 +18,7 @@ describe('readSecretFromStdin', () => { test('rejects empty and oversized input', async () => { await expect(readSecretFromStdin(Readable.from(['\n']))).rejects.toThrow( - 'Standard input did not contain an API key.', + 'Standard input did not contain a credential.', ) await expect( readSecretFromStdin(Readable.from(['x'.repeat(65 * 1024)])), diff --git a/test/input/time.test.ts b/test/input/time.test.ts new file mode 100644 index 00000000..2003b237 --- /dev/null +++ b/test/input/time.test.ts @@ -0,0 +1,39 @@ +import {describe, expect, test} from 'vitest' + +import {optionalTimeRange} from '../../src/input/time.js' + +describe('time filters', () => { + test('accepts RFC 3339 timestamps and preserves their exact text', () => { + expect( + optionalTimeRange('2026-08-20T10:15:30Z', '2026-08-20T11:15:30+00:00'), + ).toEqual({ + since: '2026-08-20T10:15:30Z', + until: '2026-08-20T11:15:30+00:00', + }) + }) + + test('rejects nonexistent calendar dates and out-of-range times', () => { + for (const value of [ + '2026-02-29T00:00:00Z', + '2026-02-30T00:00:00Z', + '2026-04-31T00:00:00Z', + '2026-00-10T00:00:00Z', + '2026-01-01T24:00:00Z', + '2026-01-01T00:00:00+24:00', + ]) { + expect(() => optionalTimeRange(value, undefined)).toThrow('RFC 3339') + } + expect(optionalTimeRange('2028-02-29T23:59:59.5-05:30', undefined)).toEqual( + { + since: '2028-02-29T23:59:59.5-05:30', + }, + ) + }) + + test('rejects loose dates and non-increasing ranges', () => { + expect(() => optionalTimeRange('2026-08-20', undefined)).toThrow('RFC 3339') + expect(() => + optionalTimeRange('2026-08-20T11:15:30Z', '2026-08-20T10:15:30Z'), + ).toThrow('after') + }) +}) diff --git a/test/listener/checkpoint.test.ts b/test/listener/checkpoint.test.ts new file mode 100644 index 00000000..61c40e9a --- /dev/null +++ b/test/listener/checkpoint.test.ts @@ -0,0 +1,154 @@ +import {chmod, lstat, readFile, symlink, writeFile} from 'node:fs/promises' +import {join} from 'node:path' + +import {describe, expect, test} from 'vitest' + +import { + loadCheckpoint, + normalizeEventFilters, + writeCheckpoint, +} from '../../src/listener/checkpoint.js' +import {temporaryDirectory} from '../support/fs.js' + +const posixOnly = test.skipIf(process.platform === 'win32') + +describe('listener checkpoints', () => { + posixOnly( + 'creates and atomically updates a private normalized checkpoint', + async () => { + const directory = await temporaryDirectory() + const path = join(directory, 'listen.json') + const first = checkpoint({ + events: ['message.*', 'call.received', 'message.*'], + }) + + await writeCheckpoint(path, first) + + expect((await lstat(path)).mode & 0o777).toBe(0o600) + expect(JSON.parse(await readFile(path, 'utf8'))).toEqual({ + ...first, + filters: {events: ['call.received', 'message.*']}, + }) + await expect( + loadCheckpoint(path, 'project_1', ['message.*', 'call.received']), + ).resolves.toMatchObject({lastAcknowledgedCursor: 'cursor_1'}) + + await writeCheckpoint(path, { + ...first, + lastAcknowledgedCursor: 'cursor_2', + }) + await expect( + loadCheckpoint(path, 'project_1', first.filters.events), + ).resolves.toMatchObject({lastAcknowledgedCursor: 'cursor_2'}) + }, + ) + + posixOnly( + 'rejects unsafe files and mismatched context before use', + async () => { + const directory = await temporaryDirectory() + const path = join(directory, 'listen.json') + await writeFile(path, JSON.stringify(checkpoint())) + await chmod(path, 0o644) + await expect(loadCheckpoint(path, 'project_1', [])).rejects.toThrow( + '0600', + ) + + await chmod(path, 0o600) + await expect(loadCheckpoint(path, 'different', [])).rejects.toThrow( + 'project', + ) + + const target = join(directory, 'target') + const link = join(directory, 'link') + await writeFile(target, '{}', {mode: 0o600}) + await symlink(target, link) + await expect(loadCheckpoint(link, 'project_1', [])).rejects.toThrow( + 'regular', + ) + }, + ) + + test('does not apply POSIX mode bits on Windows', async () => { + const directory = await temporaryDirectory() + const path = join(directory, 'listen.json') + await writeFile(path, JSON.stringify(checkpoint())) + await chmod(path, 0o644) + const platform = process.platform + + try { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32', + }) + await expect( + loadCheckpoint(path, 'project_1', []), + ).resolves.toMatchObject({lastAcknowledgedCursor: 'cursor_1'}) + + const writtenPath = join(directory, 'written-on-windows.json') + await writeCheckpoint(writtenPath, checkpoint()) + await expect( + loadCheckpoint(writtenPath, 'project_1', []), + ).resolves.toMatchObject({lastAcknowledgedCursor: 'cursor_1'}) + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform, + }) + } + }) + + test('normalizes filters without changing caller input', () => { + const input = [' z.* ', 'a.b', 'z.*'] + expect(normalizeEventFilters(input)).toEqual(['a.b', 'z.*']) + expect(input).toEqual([' z.* ', 'a.b', 'z.*']) + }) + + test('validates the complete listener filter grammar and byte limits', () => { + expect(normalizeEventFilters([])).toEqual(['*']) + expect( + normalizeEventFilters([ + ' message.received ', + 'message.*', + 'message.received', + ]), + ).toEqual(['message.*', 'message.received']) + + for (const filter of [ + 'Message.received', + 'message.*.received', + 'message.?', + 'message[0]', + ]) { + expect(() => normalizeEventFilters([filter])).toThrow( + 'listener event filter', + ) + } + expect(() => normalizeEventFilters(['a'.repeat(129)])).toThrow( + '128 UTF-8 bytes', + ) + expect(() => + normalizeEventFilters( + Array.from({length: 33}, (_, index) => `event.${index}`), + ), + ).toThrow('32 unique') + expect(() => + normalizeEventFilters( + Array.from({length: 17}, (_, index) => `${'a'.repeat(124)}.${index}`), + ), + ).toThrow('2048 UTF-8 bytes') + }) +}) + +function checkpoint(filters = {events: [] as string[]}) { + return { + cursorExpiresAt: '2026-09-09T00:00:00.000Z', + filters, + kind: 'polymorfa_cli_listener_checkpoint' as const, + lastAcknowledgedCursor: 'cursor_1', + projectId: 'project_1', + protocolVersion: '1' as const, + schemaVersion: 1 as const, + updatedAt: '2026-09-08T00:00:00.000Z', + } +} diff --git a/test/listener/forwarding.test.ts b/test/listener/forwarding.test.ts new file mode 100644 index 00000000..9b9397db --- /dev/null +++ b/test/listener/forwarding.test.ts @@ -0,0 +1,173 @@ +import {createServer} from 'node:http' +import {createHmac} from 'node:crypto' +import {once} from 'node:events' + +import {afterEach, describe, expect, test, vi} from 'vitest' + +import { + createForwardingSecret, + delayForRetry, + forwardEvent, + signLocalPayload, + validateForwardDestination, +} from '../../src/listener/forwarding.js' + +const servers: ReturnType[] = [] + +afterEach(async () => { + vi.restoreAllMocks() + await Promise.all( + servers + .splice(0) + .map( + (server) => + new Promise((resolve) => server.close(() => resolve())), + ), + ) +}) + +describe('local event forwarding', () => { + test('forwards exact bytes to loopback with a verifiable local signature', async () => { + let received: + | {body: Buffer; headers: Record} + | undefined + const server = createServer((request, response) => { + void (async () => { + const parts: Uint8Array[] = [] + for await (const part of request) { + parts.push(Uint8Array.from(part as Uint8Array)) + } + received = { + body: Buffer.concat(parts.map((part) => Buffer.from(part))), + headers: request.headers, + } + response.writeHead(204).end() + })().catch((error: unknown) => { + response.destroy( + error instanceof Error ? error : new Error(String(error)), + ) + }) + }) + servers.push(server) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('missing address') + const body = Uint8Array.from([0, 1, 2, 255]) + const secret = Buffer.alloc(32, 7).toString('base64url') + const now = 1_700_000_000_000 + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(now) + + await forwardEvent({ + body, + eventId: 'evt_1', + eventType: 'message.received', + secret, + targets: [`http://127.0.0.1:${address.port}/hook`], + }) + + expect(received?.body).toEqual(Buffer.from(body)) + expect(received?.headers['polymorfa-local-event-id']).toBe('evt_1') + expect(received?.headers['polymorfa-local-event-type']).toBe( + 'message.received', + ) + const timestamp = Math.floor(now / 1000) + const digest = createHmac('sha256', Buffer.from(secret, 'base64url')) + .update(`${timestamp}.`) + .update(body) + .digest('hex') + expect(received?.headers['polymorfa-local-signature']).toBe( + `t=${timestamp},v1=${digest}`, + ) + nowSpy.mockRestore() + }) + + test('requires explicit remote HTTPS and rejects unsafe destination syntax', async () => { + const publicHost = vi.fn((hostname: string) => { + expect(hostname).toBe('hooks.example.test') + return Promise.resolve([{address: '203.0.114.10', family: 4 as const}]) + }) + await expect( + validateForwardDestination( + 'https://hooks.example.test/hook', + false, + publicHost, + ), + ).rejects.toThrow('allow-remote-forward') + await expect( + validateForwardDestination( + 'http://hooks.example.test/hook', + true, + publicHost, + ), + ).rejects.toThrow('HTTPS') + await expect( + validateForwardDestination( + 'https://hooks.example.test/hook', + true, + publicHost, + ), + ).resolves.toMatchObject({address: '203.0.114.10', family: 4}) + expect(publicHost).toHaveBeenCalledTimes(3) + await expect( + validateForwardDestination('http://user@127.0.0.1/hook', false), + ).rejects.toThrow('userinfo') + await expect( + validateForwardDestination('https://[::ffff:a00:1]/hook', true), + ).rejects.toThrow('private or reserved') + await expect( + validateForwardDestination('http://[::1]/hook', false), + ).resolves.toMatchObject({address: '::1', family: 6}) + }) + + test('creates a 32-byte secret and signs timestamp dot raw body', () => { + const secret = createForwardingSecret() + const body = Uint8Array.from([1, 2]) + const expected = createHmac('sha256', Buffer.from(secret, 'base64url')) + .update('1700000000.') + .update(body) + .digest('hex') + expect(Buffer.from(secret, 'base64url')).toHaveLength(32) + expect(signLocalPayload(secret, 1_700_000_000, body)).toBe( + `t=1700000000,v1=${expected}`, + ) + }) + + test('retry delay rejects an already-aborted signal and removes a settled listener', async () => { + const controller = new AbortController() + controller.abort(new Error('stopped')) + await expect(delayForRetry(60_000, controller.signal)).rejects.toThrow( + 'stopped', + ) + + const active = new AbortController() + const remove = vi.spyOn(active.signal, 'removeEventListener') + await delayForRetry(1, active.signal) + expect(remove).toHaveBeenCalledWith('abort', expect.any(Function)) + }) + + test('rejects an upstream response that aborts before the body completes', async () => { + const server = createServer((_request, response) => { + response.writeHead(200, {'Content-Length': '10'}) + response.write('x') + response.destroy() + }) + servers.push(server) + server.listen(0, '127.0.0.1') + await once(server, 'listening') + const address = server.address() + if (!address || typeof address === 'string') + throw new Error('missing address') + + await expect( + forwardEvent({ + body: new TextEncoder().encode('{}'), + eventId: 'evt_1', + eventType: 'message.received', + secret: Buffer.alloc(32, 7).toString('base64url'), + targets: [`http://127.0.0.1:${address.port}/hook`], + }), + ).rejects.toThrow('could not be reached') + }) +}) diff --git a/test/listener/frames.test.ts b/test/listener/frames.test.ts new file mode 100644 index 00000000..ef44116d --- /dev/null +++ b/test/listener/frames.test.ts @@ -0,0 +1,255 @@ +import {describe, expect, test} from 'vitest' + +import {ListenerFrameDecoder} from '../../src/listener/frames.js' + +describe('ListenerFrameDecoder', () => { + test('accepts a ready frame followed by an event with matching cursor', () => { + const decoder = new ListenerFrameDecoder(STREAM) + expect(decoder.decode(record('ready', ready()))).toMatchObject({ + type: 'ready', + }) + expect( + decoder.decode( + record( + 'event', + { + ...base('event', 2), + body: { + contentType: 'application/json', + data: 'e30=', + encoding: 'base64', + }, + cursor: 'cursor_1', + event: eventMetadata(), + }, + 'cursor_1', + ), + ), + ).toMatchObject({cursor: 'cursor_1', sequence: 2, type: 'event'}) + }) + + test('fails closed on wrong first type, sequence, stream, event name, and id', () => { + expect(() => + new ListenerFrameDecoder(STREAM).decode( + record('heartbeat', { + ...base('heartbeat', 1), + lastCursor: null, + serverTime: 'x', + }), + ), + ).toThrow('first') + + const decoder = new ListenerFrameDecoder(STREAM) + decoder.decode(record('ready', ready())) + expect(() => + decoder.decode( + record('heartbeat', { + ...base('heartbeat', 1), + lastCursor: null, + serverTime: 'x', + }), + ), + ).toThrow('sequence') + expect(() => + new ListenerFrameDecoder(STREAM).decode( + record('ready', {...ready(), streamId: 'stream_2'}), + ), + ).toThrow('stream') + expect(() => + new ListenerFrameDecoder(STREAM).decode(record('unknown', ready())), + ).toThrow('event name') + expect(() => + new ListenerFrameDecoder(STREAM).decode( + record('ready', ready(), 'cursor'), + ), + ).toThrow('must omit') + }) + + test('rejects malformed timestamps, cursors, event metadata, and encoded bodies', () => { + expect(() => + new ListenerFrameDecoder(STREAM).decode( + record('ready', {...ready(), sentAt: 'later'}), + ), + ).toThrow('sentAt') + + const heartbeatDecoder = startedDecoder() + expect(() => + heartbeatDecoder.decode( + record('heartbeat', { + ...base('heartbeat', 2), + cursor: 'forbidden', + lastCursor: null, + serverTime: '2026-09-08T00:00:00Z', + }), + ), + ).toThrow('unknown property') + + const metadataDecoder = startedDecoder() + expect(() => + metadataDecoder.decode( + record( + 'event', + { + ...base('event', 2), + body: { + contentType: 'application/json', + data: 'e30=', + encoding: 'base64', + }, + cursor: 'cursor_1', + event: {id: 'event_1', type: 'message.received'}, + }, + 'cursor_1', + ), + ), + ).toThrow('organizationId') + + const bodyDecoder = startedDecoder() + expect(() => + bodyDecoder.decode( + record( + 'event', + { + ...base('event', 2), + body: { + contentType: 'application/json', + data: 'not base64', + encoding: 'base64', + }, + cursor: 'cursor_1', + event: eventMetadata(), + }, + 'cursor_1', + ), + ), + ).toThrow('body') + }) + + test('accepts only the pinned unavailable and revocation reasons', () => { + for (const [payloadAvailability, reason] of [ + ['not_retained', 'not_retained'], + ['redacted', 'redacted'], + ] as const) { + const decoder = startedDecoder() + expect( + decoder.decode( + record( + 'event_unavailable', + { + ...base('event_unavailable', 2), + cursor: 'cursor_1', + event: {...eventMetadata(), payloadAvailability}, + payloadAvailability, + reason, + }, + 'cursor_1', + ), + ), + ).toMatchObject({reason}) + } + const decoder = startedDecoder() + expect( + decoder.decode( + record('revoked', { + ...base('revoked', 2), + lastCursor: null, + reason: 'principal_revoked', + }), + ), + ).toMatchObject({reason: 'principal_revoked'}) + + expect(() => + startedDecoder().decode( + record('revoked', { + ...base('revoked', 2), + lastCursor: null, + reason: 'scope_revoked', + }), + ), + ).toThrow('revoked') + }) + + test('rejects unknown top-level and nested frame properties', () => { + expect(() => + new ListenerFrameDecoder(STREAM).decode( + record('ready', {...ready(), extra: 'not in the schema'}), + ), + ).toThrow('unknown') + + const decoder = startedDecoder() + expect(() => + decoder.decode( + record( + 'event', + { + ...base('event', 2), + body: { + contentType: 'application/json', + data: 'e30=', + encoding: 'base64', + }, + cursor: 'cursor_1', + event: {...eventMetadata(), secret: 'must not pass through'}, + }, + 'cursor_1', + ), + ), + ).toThrow('unknown') + }) +}) + +function startedDecoder() { + const decoder = new ListenerFrameDecoder(STREAM) + decoder.decode(record('ready', ready())) + return decoder +} + +function eventMetadata() { + return { + createdAt: '2026-09-08T00:00:00Z', + environment: 'development', + id: EVENT, + metadataExpiresAt: '2026-10-08T00:00:00Z', + organizationId: ORGANIZATION, + payloadAvailability: 'available', + projectId: PROJECT, + replayableUntil: null, + source: 'runtime', + type: 'message.received', + } +} + +function record(event: string, data: unknown, id?: string) { + return {data: JSON.stringify(data), event, ...(id === undefined ? {} : {id})} +} + +function base(type: string, sequence: number) { + return { + sentAt: '2026-09-08T00:00:00Z', + sequence, + streamId: STREAM, + type, + v: 1, + } +} + +function ready() { + return { + ...base('ready', 1), + connectionExpiresAt: '2026-09-08T00:30:00Z', + heartbeatIntervalMs: 15_000, + maxInFlight: 256, + projectId: PROJECT, + resume: { + acceptedCursor: null, + cursorExpiresAt: null, + oldestAvailableCursor: null, + startPosition: 'now', + }, + } +} + +const STREAM = '22222222-2222-4222-8222-222222222222' +const PROJECT = '11111111-1111-4111-8111-111111111111' +const ORGANIZATION = '33333333-3333-4333-8333-333333333333' +const EVENT = '44444444-4444-4444-8444-444444444444' diff --git a/test/listener/protocol.test.ts b/test/listener/protocol.test.ts new file mode 100644 index 00000000..0c7c35dc --- /dev/null +++ b/test/listener/protocol.test.ts @@ -0,0 +1,401 @@ +import {describe, expect, test, vi} from 'vitest' + +import { + type ListenerAuthorization, + ListenerProtocolClient, +} from '../../src/listener/protocol.js' + +describe('ListenerProtocolClient', () => { + test('keeps durable and one-use credentials on their exact requests', async () => { + const fetch = vi + .fn() + .mockResolvedValueOnce(json(201, authorization())) + .mockResolvedValueOnce( + new Response('event: ready\ndata: {}\n\n', { + headers: {'content-type': 'text/event-stream'}, + status: 200, + }), + ) + .mockResolvedValueOnce( + json(200, { + data: { + acknowledgedCursor: 'c1', + replayed: false, + sequence: 2, + streamId: STREAM, + }, + }), + ) + .mockResolvedValueOnce( + json(200, {data: {closed: true, streamId: STREAM}}), + ) + const client = new ListenerProtocolClient({ + apiOrigin: 'https://api.example.test', + credential: 'pmfa_pk_primary', + fetch, + version: '0.1.0-dev.0', + }) + + const auth = await client.authorize({ + events: ['message.*'], + projectId: PROJECT, + resumeCursor: null, + }) + await client.open(auth) + await client.ack(auth, 'c1', 2) + await client.close(auth, 'interrupted') + + expect(fetch).toHaveBeenCalledTimes(4) + expect(headers(fetch.mock.calls[0]?.[1]).authorization).toBe( + 'Bearer pmfa_pk_primary', + ) + expect(headers(fetch.mock.calls[1]?.[1]).authorization).toBe( + `Bearer ${LISTENER}`, + ) + expect(headers(fetch.mock.calls[2]?.[1]).authorization).toBe( + 'Bearer pmfa_pk_primary', + ) + expect(headers(fetch.mock.calls[3]?.[1]).authorization).toBe( + 'Bearer pmfa_pk_primary', + ) + for (const [, init] of fetch.mock.calls) { + expect(init?.redirect).toBe('manual') + expect(headers(init)['idempotency-key']).toBeUndefined() + expect(headers(init).cookie).toBeUndefined() + } + }) + + test('rejects foreign or credential-bearing response URLs before stream use', async () => { + const response = authorization() + response.data.streamUrl = `https://evil.example/v1/cli/listener-streams/${STREAM}/events` + const client = new ListenerProtocolClient({ + apiOrigin: 'https://api.example.test', + credential: 'primary', + fetch: vi + .fn() + .mockResolvedValue(json(201, response)), + version: '0.1.0-dev.0', + }) + await expect( + client.authorize({ + events: ['message.*'], + projectId: PROJECT, + resumeCursor: null, + }), + ).rejects.toThrow('trusted API origin') + }) + + test('rejects an authorization whose identity, filters, resume, timestamps, or limits do not match', async () => { + const cases: Array<(data: Record) => void> = [ + (data) => { + data.authorizationId = 'not-a-uuid' + }, + (data) => { + data.projectId = '44444444-4444-4444-8444-444444444444' + }, + (data) => { + data.filters = {events: ['call.*']} + }, + (data) => { + data.resume = {...(data.resume as object), acceptedCursor: 'unexpected'} + }, + (data) => { + data.credentialExpiresAt = 'tomorrow' + }, + (data) => { + data.capabilities = {resume: false} + }, + (data) => { + data.limits = {...(data.limits as object), maxInFlight: 0} + }, + (data) => { + data.limits = {...(data.limits as object), maxEventBytes: 16_777_217} + }, + (data) => { + data.limits = {...(data.limits as object), heartbeatIntervalMs: 999} + }, + (data) => { + data.limits = {...(data.limits as object), ackTimeoutMs: 1_999} + }, + (data) => { + data.unexpected = true + }, + ] + + for (const mutate of cases) { + const response = authorization() + mutate(response.data) + const client = new ListenerProtocolClient({ + apiOrigin: 'https://api.example.test', + credential: 'primary', + fetch: vi + .fn() + .mockResolvedValue(json(201, response)), + version: '0.1.0-dev.0', + }) + await expect( + client.authorize({ + events: ['message.*'], + projectId: PROJECT, + resumeCursor: null, + }), + ).rejects.toMatchObject({code: 'listener_authorization_invalid'}) + } + }) + + test('accepts every authorization limit boundary from the pinned schema', async () => { + const response = authorization() + response.data.filters = {events: ['*']} + response.data.limits = { + ackTimeoutMs: 2_000, + heartbeatIntervalMs: 1_000, + maxEventBytes: 16_777_216, + maxInFlight: 4_096, + } + const client = new ListenerProtocolClient({ + apiOrigin: 'https://api.example.test', + credential: 'primary', + fetch: vi + .fn() + .mockResolvedValue(json(201, response)), + version: '0.1.0-dev.0', + }) + + await expect( + client.authorize({events: ['*'], projectId: PROJECT, resumeCursor: null}), + ).resolves.toMatchObject({limits: response.data.limits}) + }) + + test('retries one authorization attempt with the same request ID', async () => { + const fetch = vi + .fn() + .mockRejectedValueOnce(new Error('socket reset')) + .mockResolvedValueOnce( + json( + 503, + listenerError('listener_authorization_unavailable', true, true), + {'retry-after': '0'}, + ), + ) + .mockResolvedValueOnce(json(201, authorization())) + const client = new ListenerProtocolClient({ + apiOrigin: 'https://api.example.test', + credential: 'primary', + fetch, + version: '0.1.0-dev.0', + }) + + await client.authorize({ + events: ['message.*'], + projectId: PROJECT, + resumeCursor: null, + }) + + const requestIds = fetch.mock.calls.map((call) => + authorizationRequestId(call[1]), + ) + expect(requestIds).toHaveLength(3) + expect(new Set(requestIds).size).toBe(1) + }) + + test('validates ACK and close responses and retries an ambiguous ACK', async () => { + const auth = authorization().data as ListenerAuthorization + const fetch = vi + .fn() + .mockRejectedValueOnce(new Error('socket reset')) + .mockResolvedValueOnce( + json(200, { + data: { + acknowledgedCursor: 'c1', + replayed: true, + sequence: 2, + streamId: STREAM, + }, + }), + ) + .mockResolvedValueOnce( + json(200, {data: {closed: true, streamId: 'wrong'}}), + ) + const client = new ListenerProtocolClient({ + apiOrigin: 'https://api.example.test', + credential: 'primary', + fetch, + version: '0.1.0-dev.0', + }) + + await expect(client.ack(auth, 'c1', 2)).resolves.toBeUndefined() + expect(fetch).toHaveBeenCalledTimes(2) + await expect(client.close(auth, 'interrupted')).rejects.toMatchObject({ + code: 'listener_response_invalid', + }) + }) + + test('preserves status, retry metadata, and a header request ID', async () => { + const client = new ListenerProtocolClient({ + apiOrigin: 'https://api.example.test', + credential: 'primary', + fetch: vi.fn().mockResolvedValue( + json(403, listenerError('project_access_denied', false, false), { + 'x-request-id': 'req_listener', + }), + ), + version: '0.1.0-dev.0', + }) + + await expect( + client.authorize({ + events: ['*'], + projectId: PROJECT, + resumeCursor: null, + }), + ).rejects.toMatchObject({ + code: 'project_access_denied', + requestId: 'req_listener', + retryable: false, + safeToReauthorize: false, + status: 403, + }) + }) + + test('rejects plaintext localhost when DNS includes a non-loopback address', async () => { + const fetch = vi.fn() + const client = new ListenerProtocolClient({ + apiOrigin: 'http://localhost:8787', + credential: 'primary', + fetch, + lookup: () => + Promise.resolve([ + {address: '127.0.0.1', family: 4}, + {address: '203.0.113.1', family: 4}, + ]), + version: '0.1.0-dev.0', + }) + + await expect( + client.authorize({events: ['*'], projectId: PROJECT, resumeCursor: null}), + ).rejects.toMatchObject({code: 'listener_origin_invalid'}) + expect(fetch).not.toHaveBeenCalled() + }) + + test('rejects a non-UUID project before sending durable credentials', async () => { + const fetch = vi.fn() + const client = new ListenerProtocolClient({ + apiOrigin: 'https://api.example.test', + credential: 'primary', + fetch, + version: '0.1.0-dev.0', + }) + + await expect( + client.authorize({ + events: ['*'], + projectId: 'project-slug', + resumeCursor: null, + }), + ).rejects.toMatchObject({code: 'listener_authorization_invalid'}) + expect(fetch).not.toHaveBeenCalled() + }) + + test('rejects an API origin with a path before constructing listener URLs', () => { + expect( + () => + new ListenerProtocolClient({ + apiOrigin: 'https://api.example.test/prefix', + credential: 'primary', + fetch: vi.fn(), + version: '0.1.0-dev.0', + }), + ).toThrow('origin') + }) + + test('describes the loopback-only plaintext exception for unsupported schemes', () => { + expect( + () => + new ListenerProtocolClient({ + apiOrigin: 'ftp://api.example.test', + credential: 'primary', + fetch: vi.fn(), + version: '0.1.0-dev.0', + }), + ).toThrow( + 'must use HTTPS, or HTTP with a host that resolves only to loopback addresses', + ) + }) +}) + +const PROJECT = '11111111-1111-4111-8111-111111111111' +const STREAM = '22222222-2222-4222-8222-222222222222' +const AUTH = '33333333-3333-4333-8333-333333333333' +const LISTENER = `pmfa_ls_${'a'.repeat(43)}` + +function authorization() { + return { + data: { + ackUrl: `https://api.example.test/v1/cli/listener-streams/${STREAM}/ack`, + authorizationId: AUTH, + capabilities: {resume: true}, + closeUrl: `https://api.example.test/v1/cli/listener-streams/${STREAM}/close`, + credentialExpiresAt: '2026-09-08T01:00:00Z', + filters: {events: ['message.*']}, + limits: { + ackTimeoutMs: 30_000, + heartbeatIntervalMs: 15_000, + maxEventBytes: 65_536, + maxInFlight: 32, + }, + listenerCredential: LISTENER, + mediaType: 'text/event-stream', + projectId: PROJECT, + protocolVersion: '1', + resume: { + acceptedCursor: null, + cursorExpiresAt: null, + oldestAvailableCursor: null, + startPosition: 'now', + }, + streamId: STREAM, + streamUrl: `https://api.example.test/v1/cli/listener-streams/${STREAM}/events`, + }, + } +} + +function json( + status: number, + body: unknown, + headers: Record = {}, +): Response { + return new Response(JSON.stringify(body), { + headers: {'content-type': 'application/json', ...headers}, + status, + }) +} + +function listenerError(code: string, retryable: boolean, safe: boolean) { + return { + error: { + code, + details: {}, + message: code, + retryable, + safeToReauthorize: safe, + }, + } +} + +function headers(init: RequestInit | undefined): Record { + return Object.fromEntries(new Headers(init?.headers).entries()) +} + +function authorizationRequestId(init: RequestInit | undefined): string { + if (typeof init?.body !== 'string') throw new Error('missing request body') + const body: unknown = JSON.parse(init.body) + if ( + typeof body !== 'object' || + body === null || + !('authorizationRequestId' in body) || + typeof body.authorizationRequestId !== 'string' + ) { + throw new Error('missing authorization request ID') + } + return body.authorizationRequestId +} diff --git a/test/listener/session.test.ts b/test/listener/session.test.ts new file mode 100644 index 00000000..892fa8e3 --- /dev/null +++ b/test/listener/session.test.ts @@ -0,0 +1,650 @@ +import {describe, expect, test, vi} from 'vitest' + +import type {ListenerAuthorization} from '../../src/listener/protocol.js' +import {runListener} from '../../src/listener/session.js' +import {ListenerProtocolError} from '../../src/listener/sse.js' + +describe('runListener', () => { + test('waits for output and forwarding before cumulatively acknowledging', async () => { + const order: string[] = [] + const protocol = fakeProtocol( + stream([ + frame('ready', ready(), undefined), + frame('event', event(), 'cursor_1'), + frame( + 'revoked', + { + ...base('revoked', 3), + lastCursor: 'cursor_1', + reason: 'principal_revoked', + }, + undefined, + ), + ]), + order, + ) + + const result = await runListener({ + events: ['message.*'], + forward: () => { + order.push('forward') + return Promise.resolve() + }, + output: (line) => { + order.push(line.type === 'event' ? 'output' : line.type) + return Promise.resolve() + }, + projectId: PROJECT, + protocol, + }) + + expect(order.slice(0, 4)).toEqual(['ready', 'output', 'forward', 'ack']) + expect(protocol.ack).toHaveBeenCalledWith( + expect.anything(), + 'cursor_1', + 2, + undefined, + ) + expect(result).toMatchObject({ + condition: 'authorization', + lastAcknowledgedCursor: 'cursor_1', + }) + }) + + test('emits the frozen ready JSONL shape instead of the wire frame shape', async () => { + const protocol = fakeProtocol( + stream([ + frame('ready', ready(), undefined), + frame( + 'revoked', + { + ...base('revoked', 2), + lastCursor: null, + reason: 'principal_revoked', + }, + undefined, + ), + ]), + ) + const output: Array> = [] + + await runListener({ + events: ['message.*'], + output: (line) => { + output.push(line) + return Promise.resolve() + }, + projectId: PROJECT, + protocol, + }) + + const {observedAt, ...readyOutput} = output[0] ?? {} + expect(typeof observedAt).toBe('string') + expect(readyOutput).toEqual({ + acceptedCursor: null, + connectionExpiresAt: '2026-09-08T01:00:00Z', + filters: {events: ['message.*']}, + listenerId: AUTHORIZATION, + projectId: PROJECT, + schemaVersion: 1, + sequence: 1, + startPosition: 'now', + streamId: STREAM, + type: 'ready', + }) + }) + + test('applies gap policy to a rejected resume cursor before stream open', async () => { + const protocol = fakeProtocol(stream([])) + protocol.authorize.mockRejectedValue( + new ListenerProtocolError('resume_cursor_expired', 'expired', { + details: { + cursorExpiresAt: '2026-09-08T00:00:00Z', + oldestAvailableCursor: null, + requestedCursor: 'old-cursor', + }, + safeToReauthorize: true, + status: 410, + }), + ) + const output: Array> = [] + + const result = await runListener({ + events: ['message.*'], + output: (line) => { + output.push(line) + return Promise.resolve() + }, + projectId: PROJECT, + protocol, + resumeCursor: 'old-cursor', + }) + + expect(output.map(({type}) => type)).toEqual(['gap', 'terminal']) + expect(output[0]).toMatchObject({ + cursorExpiresAt: '2026-09-08T00:00:00Z', + oldestAvailableCursor: null, + reason: 'resume_cursor_expired', + requestedCursor: 'old-cursor', + sequence: 0, + streamId: null, + }) + expect(result.condition).toBe('stream_gap') + expect(protocol.open).not.toHaveBeenCalled() + }) + + test('reauthorizes after a stream-open error explicitly marked safe', async () => { + const protocol = fakeProtocol(stream([])) + protocol.open + .mockRejectedValueOnce( + new ListenerProtocolError('listener_credential_expired', 'expired', { + safeToReauthorize: true, + status: 401, + }), + ) + .mockResolvedValueOnce( + stream([ + frame('ready', ready(), undefined), + frame( + 'revoked', + { + ...base('revoked', 2), + lastCursor: null, + reason: 'principal_revoked', + }, + undefined, + ), + ]), + ) + + await expect( + runListener({ + events: ['message.*'], + output: () => Promise.resolve(), + projectId: PROJECT, + protocol, + waitBeforeReconnect: () => Promise.resolve(), + }), + ).resolves.toMatchObject({condition: 'authorization'}) + expect(protocol.authorize).toHaveBeenCalledTimes(2) + }) + + test('turns an unavailable stream-open resume cursor into a visible gap', async () => { + const protocol = fakeProtocol(stream([])) + protocol.open.mockRejectedValue( + new ListenerProtocolError( + 'listener_cursor_unavailable', + 'cursor unavailable', + {safeToReauthorize: true, status: 410}, + ), + ) + const output: Array> = [] + + const result = await runListener({ + events: ['message.*'], + output: (line) => { + output.push(line) + return Promise.resolve() + }, + projectId: PROJECT, + protocol, + resumeCursor: 'old-cursor', + }) + + expect(result.condition).toBe('stream_gap') + expect(output[0]).toMatchObject({ + oldestAvailableCursor: null, + reason: 'listener_cursor_unavailable', + requestedCursor: 'old-cursor', + type: 'gap', + }) + expect(protocol.authorize).toHaveBeenCalledOnce() + }) + + test('deduplicates a repeated event by cursor and event ID after reconnect', async () => { + const protocol = fakeProtocol(stream([])) + protocol.open + .mockResolvedValueOnce( + stream([ + frame('ready', ready(), undefined), + frame('event', event(), 'cursor_1'), + ]), + ) + .mockResolvedValueOnce( + stream([ + frame('ready', ready(), undefined), + frame('event', event(), 'cursor_1'), + frame( + 'revoked', + { + ...base('revoked', 3), + lastCursor: 'cursor_1', + reason: 'principal_revoked', + }, + undefined, + ), + ]), + ) + let eventOutputs = 0 + let forwards = 0 + + const result = await runListener({ + events: ['message.*'], + forward: () => { + forwards += 1 + return Promise.resolve() + }, + output: (line) => { + if (line.type === 'event') eventOutputs += 1 + return Promise.resolve() + }, + projectId: PROJECT, + protocol, + waitBeforeReconnect: () => Promise.resolve(), + }) + + expect(result.condition).toBe('authorization') + expect(eventOutputs).toBe(1) + expect(forwards).toBe(1) + expect(protocol.ack).toHaveBeenCalledTimes(2) + }) + + test('fails closed without acknowledging a malformed record', async () => { + const protocol = fakeProtocol( + stream([ + frame('ready', ready(), undefined), + frame('event', {...event(), sequence: 1}, 'cursor_1'), + ]), + ) + + await expect( + runListener({ + events: ['*'], + output: () => Promise.resolve(), + projectId: PROJECT, + protocol, + }), + ).rejects.toThrow('sequence') + expect(protocol.ack).not.toHaveBeenCalled() + }) + + test('fails closed when ready does not repeat the authorized project and limits', async () => { + const protocol = fakeProtocol( + stream([ + frame( + 'ready', + {...ready(), projectId: '44444444-4444-4444-8444-444444444444'}, + undefined, + ), + frame('event', event(), 'cursor_1'), + ]), + ) + + await expect( + runListener({ + events: ['*'], + output: () => Promise.resolve(), + projectId: PROJECT, + protocol, + }), + ).rejects.toThrow('authorization') + expect(protocol.ack).not.toHaveBeenCalled() + }) + + test('reports forwarding failure without acknowledging the event', async () => { + const protocol = fakeProtocol( + stream([ + frame('ready', ready(), undefined), + frame('event', event(), 'cursor_1'), + ]), + ) + + const result = await runListener({ + events: ['*'], + forward: () => Promise.reject(new Error('destination failed')), + output: () => Promise.resolve(), + projectId: PROJECT, + protocol, + }) + + expect(result.condition).toBe('forwarding_failed') + expect(protocol.ack).not.toHaveBeenCalled() + expect(protocol.close).toHaveBeenCalledWith( + expect.anything(), + 'forwarding_failed', + expect.any(AbortSignal), + ) + }) + + test('enforces the negotiated whole-record byte limit without acknowledging', async () => { + const protocol = fakeProtocol( + stream([ + frame('ready', ready(), undefined), + frame('event', event(), 'cursor_1'), + ]), + undefined, + {maxEventBytes: 128}, + ) + + await expect( + runListener({ + events: ['*'], + maxBuffer: 1, + output: () => Promise.resolve(), + projectId: PROJECT, + protocol, + }), + ).rejects.toThrow('maximum') + expect(protocol.ack).not.toHaveBeenCalled() + }) + + test('rejects an invalid local buffer before requesting authorization', async () => { + const protocol = fakeProtocol(stream([])) + await expect( + runListener({ + events: ['*'], + maxBuffer: 0, + output: () => Promise.resolve(), + projectId: PROJECT, + protocol, + }), + ).rejects.toThrow('1 through 4096') + expect(protocol.authorize).not.toHaveBeenCalled() + }) + + test('holds at most one decoded event while its active sink is pending', async () => { + let releaseFirst: (() => void) | undefined + let observeFirst: (() => void) | undefined + const firstObserved = new Promise((resolve) => { + observeFirst = resolve + }) + const firstReleased = new Promise((resolve) => { + releaseFirst = resolve + }) + const second = { + ...event(), + sequence: 3, + cursor: 'cursor_2', + event: {...event().event, id: SECOND_EVENT}, + } + const protocol = fakeProtocol( + stream([ + frame('ready', ready(), undefined), + frame('event', event(), 'cursor_1'), + frame('event', second, 'cursor_2'), + frame( + 'revoked', + { + ...base('revoked', 4), + lastCursor: 'cursor_2', + reason: 'principal_revoked', + }, + undefined, + ), + ]), + ) + const observed: string[] = [] + + const running = runListener({ + events: ['*'], + maxBuffer: 4096, + output: async (line) => { + if (line.type !== 'event') return + observed.push(String(line.cursor)) + if (line.cursor === 'cursor_1') { + observeFirst?.() + await firstReleased + } + }, + projectId: PROJECT, + protocol, + }) + + await firstObserved + await Promise.resolve() + expect(observed).toEqual(['cursor_1']) + expect(protocol.ack).not.toHaveBeenCalled() + releaseFirst?.() + await expect(running).resolves.toMatchObject({ + acknowledged: 2, + condition: 'authorization', + }) + expect(observed).toEqual(['cursor_1', 'cursor_2']) + }) + + test('closes with interrupted and emits a terminal line on local abort', async () => { + const controller = new AbortController() + let streamStarted: (() => void) | undefined + const started = new Promise((resolve) => { + streamStarted = resolve + }) + const body = new ReadableStream({ + start(streamController) { + streamController.enqueue( + new TextEncoder().encode(frame('ready', ready(), undefined)), + ) + streamStarted?.() + controller.signal.addEventListener( + 'abort', + () => streamController.error(new Error('aborted')), + {once: true}, + ) + }, + }) + const protocol = fakeProtocol(body) + const output: string[] = [] + const running = runListener({ + events: ['message.*'], + output: (line) => { + output.push(line.type) + return Promise.resolve() + }, + projectId: PROJECT, + protocol, + signal: controller.signal, + }) + + await started + controller.abort(new Error('interrupted')) + + await expect(running).resolves.toMatchObject({condition: 'interrupted'}) + expect(output).toContain('terminal') + expect(protocol.close).toHaveBeenCalledWith( + expect.anything(), + 'interrupted', + expect.any(AbortSignal), + ) + }) + + test('backs off exponentially across empty reconnects and resets after a delivered frame', async () => { + const protocol = fakeProtocol(stream([])) + protocol.open + .mockResolvedValueOnce(stream([frame('ready', ready(), undefined)])) + .mockResolvedValueOnce(stream([frame('ready', ready(), undefined)])) + .mockResolvedValueOnce( + stream([ + frame('ready', ready(), undefined), + frame( + 'heartbeat', + { + ...base('heartbeat', 2), + lastCursor: null, + serverTime: '2026-09-08T00:00:01Z', + }, + undefined, + ), + ]), + ) + .mockResolvedValueOnce( + stream([ + frame('ready', ready(), undefined), + frame( + 'revoked', + { + ...base('revoked', 2), + lastCursor: null, + reason: 'principal_revoked', + }, + undefined, + ), + ]), + ) + const attempts: number[] = [] + + const result = await runListener({ + events: ['*'], + output: () => Promise.resolve(), + projectId: PROJECT, + protocol, + waitBeforeReconnect: (attempt) => { + attempts.push(attempt) + return Promise.resolve() + }, + }) + + expect(attempts).toEqual([0, 1, 0]) + expect(result.condition).toBe('authorization') + }) + + test('emits an interrupted terminal when aborted during reconnect backoff', async () => { + const controller = new AbortController() + const protocol = fakeProtocol(stream([])) + const output: string[] = [] + + const result = await runListener({ + events: ['*'], + output: (line) => { + output.push(line.type) + return Promise.resolve() + }, + projectId: PROJECT, + protocol, + signal: controller.signal, + waitBeforeReconnect: () => { + controller.abort(new Error('interrupted')) + return Promise.resolve() + }, + }) + + expect(result.condition).toBe('interrupted') + expect(output.at(-1)).toBe('terminal') + expect(protocol.close).toHaveBeenCalledWith( + expect.anything(), + 'interrupted', + expect.any(AbortSignal), + ) + }) +}) + +const PROJECT = '11111111-1111-4111-8111-111111111111' +const STREAM = '22222222-2222-4222-8222-222222222222' +const AUTHORIZATION = '33333333-3333-4333-8333-333333333333' +const ORGANIZATION = '44444444-4444-4444-8444-444444444444' +const EVENT = '55555555-5555-4555-8555-555555555555' +const SECOND_EVENT = '66666666-6666-4666-8666-666666666666' + +function fakeProtocol( + body: ReadableStream, + order?: string[], + limits?: Partial, +) { + const authorization = { + ackUrl: `https://api.example/v1/cli/listener-streams/${STREAM}/ack`, + authorizationId: AUTHORIZATION, + capabilities: {resume: true}, + closeUrl: `https://api.example/v1/cli/listener-streams/${STREAM}/close`, + credentialExpiresAt: '2026-09-08T01:00:00Z', + filters: {events: ['message.*']}, + limits: { + ackTimeoutMs: 30_000, + heartbeatIntervalMs: 15_000, + maxEventBytes: 1024, + maxInFlight: 32, + ...limits, + }, + listenerCredential: `pmfa_ls_${'a'.repeat(43)}`, + mediaType: 'text/event-stream', + projectId: PROJECT, + protocolVersion: '1', + resume: { + acceptedCursor: null, + cursorExpiresAt: null, + oldestAvailableCursor: null, + startPosition: 'now', + }, + streamId: STREAM, + streamUrl: `https://api.example/v1/cli/listener-streams/${STREAM}/events`, + } satisfies ListenerAuthorization + return { + ack: vi.fn(() => { + order?.push('ack') + return Promise.resolve() + }), + authorize: vi.fn(() => Promise.resolve(authorization)), + close: vi.fn(() => Promise.resolve()), + open: vi.fn(() => Promise.resolve(body)), + } +} + +function stream(records: string[]): ReadableStream { + return new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode(records.join(''))) + controller.close() + }, + }) +} + +function frame(type: string, data: unknown, id: string | undefined): string { + return `event: ${type}\n${id ? `id: ${id}\n` : ''}data: ${JSON.stringify(data)}\n\n` +} + +function base(type: string, sequence: number) { + return { + sentAt: '2026-09-08T00:00:00Z', + sequence, + streamId: STREAM, + type, + v: 1, + } +} + +function ready() { + return { + ...base('ready', 1), + connectionExpiresAt: '2026-09-08T01:00:00Z', + heartbeatIntervalMs: 15_000, + maxInFlight: 32, + projectId: PROJECT, + resume: { + acceptedCursor: null, + cursorExpiresAt: null, + oldestAvailableCursor: null, + startPosition: 'now', + }, + } +} + +function event() { + return { + ...base('event', 2), + body: { + contentType: 'application/json', + data: 'eyJvayI6dHJ1ZX0=', + encoding: 'base64', + }, + cursor: 'cursor_1', + event: { + createdAt: '2026-09-08T00:00:00Z', + environment: 'development', + id: EVENT, + metadataExpiresAt: '2026-10-08T00:00:00Z', + organizationId: ORGANIZATION, + payloadAvailability: 'available', + projectId: PROJECT, + replayableUntil: null, + source: 'runtime', + type: 'message.received', + }, + } +} diff --git a/test/listener/sse.test.ts b/test/listener/sse.test.ts new file mode 100644 index 00000000..4aed2bca --- /dev/null +++ b/test/listener/sse.test.ts @@ -0,0 +1,106 @@ +import {describe, expect, test} from 'vitest' + +import {decodeSse} from '../../src/listener/sse.js' + +describe('decodeSse', () => { + test('decodes BOM, CRLF, split UTF-8, comments, and multiline data', async () => { + const bytes = new TextEncoder().encode( + '\uFEFF: ready\r\nevent: event\r\nid: cursor_1\r\ndata: {"text":"hé"}\r\ndata: tail\r\n\r\n', + ) + + const records = await collect( + decodeSse(chunks(bytes, [1, 2, 17, 23, bytes.length]), 1024), + ) + + expect(records).toEqual([ + { + data: '{"text":"hé"}\ntail', + event: 'event', + id: 'cursor_1', + }, + ]) + }) + + test('rejects retry, NUL IDs, invalid UTF-8, and oversized records', async () => { + await expect( + collect(decodeSse(stream('retry: 100\n\n'), 1024)), + ).rejects.toThrow('retry') + await expect( + collect( + decodeSse(stream('event: event\nid: bad\0id\ndata: {}\n\n'), 1024), + ), + ).rejects.toThrow('NUL') + await expect( + collect(decodeSse(chunks(Uint8Array.from([0xc3, 0x28]), [1, 2]), 1024)), + ).rejects.toThrow('UTF-8') + await expect( + collect(decodeSse(stream(`data: ${'x'.repeat(20)}\n\n`), 12)), + ).rejects.toThrow('maximum') + await expect(collect(decodeSse(stream('data: {}\n\n'), 0))).rejects.toThrow( + 'positive safe integer', + ) + }) + + test('rejects duplicate event or id fields even when their values agree', async () => { + await expect( + collect( + decodeSse(stream('event: event\nevent: event\ndata: {}\n\n'), 1024), + ), + ).rejects.toThrow('event fields') + await expect( + collect( + decodeSse( + stream('event: event\nid: cursor_1\nid: cursor_1\ndata: {}\n\n'), + 1024, + ), + ), + ).rejects.toThrow('id fields') + }) + + test('cancels the reader when the consumer closes the generator early', async () => { + let cancelled = false + const source = new ReadableStream({ + cancel() { + cancelled = true + }, + start(controller) { + controller.enqueue( + new TextEncoder().encode('event: event\ndata: {}\n\n'), + ) + }, + }) + const iterator = decodeSse(source, 1024) + + await expect(iterator.next()).resolves.toMatchObject({done: false}) + await iterator.return(undefined) + + expect(cancelled).toBe(true) + }) +}) + +function stream(value: string): ReadableStream { + return chunks(new TextEncoder().encode(value), [value.length]) +} + +function chunks( + bytes: Uint8Array, + ends: readonly number[], +): ReadableStream { + return new ReadableStream({ + start(controller) { + let start = 0 + for (const end of ends) { + controller.enqueue(bytes.slice(start, end)) + start = end + } + if (start < bytes.length) controller.enqueue(bytes.slice(start)) + controller.close() + }, + }) +} + +async function collect(values: AsyncIterable): Promise { + const result: T[] = [] + for await (const value of values) result.push(value) + return result +} diff --git a/test/mcp/client-command.test.ts b/test/mcp/client-command.test.ts new file mode 100644 index 00000000..28634498 --- /dev/null +++ b/test/mcp/client-command.test.ts @@ -0,0 +1,90 @@ +import {EventEmitter} from 'node:events' +import {PassThrough} from 'node:stream' +import {afterEach, describe, expect, test, vi} from 'vitest' + +const childProcess = vi.hoisted(() => ({spawn: vi.fn()})) + +vi.mock('node:child_process', () => ({spawn: childProcess.spawn})) + +import { + mcpClientInvocation, + runMcpClientCommand, +} from '../../src/mcp/client-command.js' + +afterEach(() => { + vi.clearAllTimers() + vi.useRealTimers() + childProcess.spawn.mockReset() +}) + +describe('mcpClientInvocation', () => { + test('uses current Codex and Claude stdio MCP command shapes', () => { + expect(mcpClientInvocation('codex', 'install')).toEqual({ + command: 'codex', + args: ['mcp', 'add', 'polymorfa', '--', 'polymorfa', 'mcp', 'serve'], + }) + expect(mcpClientInvocation('claude', 'install')).toEqual({ + command: 'claude', + args: [ + 'mcp', + 'add', + '--transport', + 'stdio', + '--scope', + 'user', + 'polymorfa', + '--', + 'polymorfa', + 'mcp', + 'serve', + ], + }) + expect(mcpClientInvocation('codex', 'uninstall')).toEqual({ + command: 'codex', + args: ['mcp', 'remove', 'polymorfa'], + }) + }) + + test('terminates a client command after the hard timeout', async () => { + vi.useFakeTimers() + const child = fakeChild() + childProcess.spawn.mockReturnValue(child) + + const pending = runMcpClientCommand('codex', 'status') + const rejected = expect(pending).rejects.toThrow( + 'codex status timed out after 30 seconds and was terminated', + ) + await vi.advanceTimersByTimeAsync(30_000) + + await rejected + expect(child.kill).toHaveBeenCalledWith('SIGTERM') + + await vi.advanceTimersByTimeAsync(1000) + expect(child.kill).toHaveBeenLastCalledWith('SIGKILL') + }) + + test('reports the signal when a client command is terminated', async () => { + const child = fakeChild() + childProcess.spawn.mockReturnValue(child) + + const pending = runMcpClientCommand('claude', 'install') + child.stderr.write('client stopped unexpectedly') + child.emit('close', null, 'SIGKILL') + + await expect(pending).rejects.toThrow( + 'claude install was terminated by signal SIGKILL. client stopped unexpectedly', + ) + }) +}) + +function fakeChild() { + const child = new EventEmitter() as EventEmitter & { + kill: ReturnType + stderr: PassThrough + stdout: PassThrough + } + child.kill = vi.fn(() => true) + child.stderr = new PassThrough() + child.stdout = new PassThrough() + return child +} diff --git a/test/mcp/listener-manager.test.ts b/test/mcp/listener-manager.test.ts new file mode 100644 index 00000000..8a5ca07c --- /dev/null +++ b/test/mcp/listener-manager.test.ts @@ -0,0 +1,144 @@ +import {describe, expect, test, vi} from 'vitest' + +import {ListenerManager} from '../../src/mcp/listener-manager.js' + +describe('ListenerManager', () => { + test('owns in-process streams, rejects duplicates, and stops idempotently', async () => { + const pending: Array<() => void> = [] + const manager = new ListenerManager(async (_input, signal, progress) => { + progress({acknowledged: 1, forwarded: 1, received: 2}) + await new Promise((resolve) => { + pending.push(resolve) + signal.addEventListener('abort', () => resolve(), {once: true}) + }) + return { + acknowledged: 1, + condition: 'interrupted', + forwarded: 1, + lastAcknowledgedCursor: 'c1', + received: 1, + } + }) + const input = { + events: ['message.*'], + forwardSecretOut: '/tmp/secret', + forwardTo: ['http://127.0.0.1:3000'], + projectId: '11111111-1111-4111-8111-111111111111', + } + + const started = await manager.start(input) + expect(started.status).toBe('starting') + await Promise.resolve() + expect( + (await manager.status({listenerId: started.listenerId})).listeners[0] + ?.counters, + ).toMatchObject({ + acknowledged: 1, + forwarded: 1, + received: 2, + }) + await expect(manager.start(input)).rejects.toThrow('already running') + await expect(manager.stop(started.listenerId)).resolves.toEqual({ + listenerId: started.listenerId, + stopped: true, + }) + await expect(manager.stop(started.listenerId)).resolves.toEqual({ + listenerId: started.listenerId, + stopped: true, + }) + expect( + (await manager.status({listenerId: started.listenerId})).listeners[0], + ).toMatchObject({status: 'stopped'}) + pending.forEach((resolve) => resolve()) + }) + + test('limits one MCP server to four active listeners', async () => { + const manager = new ListenerManager( + vi.fn(async () => new Promise(() => {})), + ) + for (let index = 0; index < 4; index += 1) { + await manager.start({ + forwardSecretOut: `/tmp/s${index}`, + forwardTo: [`http://127.0.0.1:${3000 + index}`], + projectId: `11111111-1111-4111-8111-${String(index).padStart(12, '0')}`, + }) + } + await expect( + manager.start({ + forwardSecretOut: '/tmp/s4', + forwardTo: ['http://127.0.0.1:3004'], + projectId: '11111111-1111-4111-8111-000000000004', + }), + ).rejects.toThrow('limit') + }) + + test('treats a runner rejection caused by stop as interrupted, not failed', async () => { + const manager = new ListenerManager( + (_input, signal) => + new Promise((_, reject) => { + signal.addEventListener( + 'abort', + () => reject(new DOMException('aborted', 'AbortError')), + {once: true}, + ) + }), + ) + const started = await manager.start({ + forwardSecretOut: '/tmp/secret', + forwardTo: ['http://127.0.0.1:3000'], + projectId: '11111111-1111-4111-8111-111111111111', + }) + await Promise.resolve() + + await manager.stop(started.listenerId) + + expect( + (await manager.status({listenerId: started.listenerId})).listeners[0], + ).toMatchObject({ + counters: {failed: 0}, + errorCode: null, + status: 'stopped', + }) + }) + + test('remains stopping after the stop timeout until the runner settles', async () => { + vi.useFakeTimers() + let settle: (() => void) | undefined + const manager = new ListenerManager( + () => + new Promise((resolve) => { + settle = () => + resolve({ + acknowledged: 0, + condition: 'interrupted', + forwarded: 0, + lastAcknowledgedCursor: null, + received: 0, + }) + }), + ) + try { + const started = await manager.start({ + forwardSecretOut: '/tmp/secret', + forwardTo: ['http://127.0.0.1:3000'], + projectId: '11111111-1111-4111-8111-111111111111', + }) + await Promise.resolve() + const stopping = manager.stop(started.listenerId) + await vi.advanceTimersByTimeAsync(5000) + await stopping + + expect( + (await manager.status({listenerId: started.listenerId})).listeners[0], + ).toMatchObject({status: 'stopping', stoppedAt: null}) + + settle?.() + await vi.advanceTimersByTimeAsync(0) + expect( + (await manager.status({listenerId: started.listenerId})).listeners[0], + ).toMatchObject({status: 'stopped'}) + } finally { + vi.useRealTimers() + } + }) +}) diff --git a/test/mcp/server.test.ts b/test/mcp/server.test.ts new file mode 100644 index 00000000..38c6e8a4 --- /dev/null +++ b/test/mcp/server.test.ts @@ -0,0 +1,104 @@ +import {PassThrough} from 'node:stream' + +import {StdioServerTransport} from '@modelcontextprotocol/server/stdio' +import {afterEach, describe, expect, test} from 'vitest' + +import {ListenerManager} from '../../src/mcp/listener-manager.js' +import {serveMcp} from '../../src/mcp/server.js' + +describe('MCP stdio server', () => { + let close: (() => Promise) | undefined + + afterEach(async () => close?.()) + + test('negotiates and lists the three in-process listener tools', async () => { + const input = new PassThrough() + const output = new PassThrough() + const transport = new StdioServerTransport(input, output) + const manager = new ListenerManager( + async () => new Promise(() => {}), + ) + const handle = serveMcp(manager, '0.1.0-test', transport) + close = () => handle.close() + + const messages = collectMessages(output) + input.write( + `${JSON.stringify({ + id: 1, + jsonrpc: '2.0', + method: 'initialize', + params: { + capabilities: {}, + clientInfo: {name: 'test', version: '1.0.0'}, + protocolVersion: '2025-06-18', + }, + })}\n`, + ) + await waitFor(messages, 1) + input.write( + `${JSON.stringify({jsonrpc: '2.0', method: 'notifications/initialized'})}\n`, + ) + input.write( + `${JSON.stringify({id: 2, jsonrpc: '2.0', method: 'tools/list', params: {}})}\n`, + ) + await waitFor(messages, 2) + + expect(messages[0]).toMatchObject({ + id: 1, + result: {serverInfo: {name: '@polymorfa/cli', version: '0.1.0-test'}}, + }) + expect( + ( + (messages[1] as {result: {tools: Array<{name: string}>}}).result + .tools ?? [] + ).map(({name}) => name), + ).toEqual([ + 'polymorfa_listen_start', + 'polymorfa_listen_status', + 'polymorfa_listen_stop', + ]) + const start = ( + messages[1] as { + result: { + tools: Array<{ + name: string + inputSchema: {properties: Record} + }> + } + } + ).result.tools.find(({name}) => name === 'polymorfa_listen_start') + const pattern = new RegExp( + start?.inputSchema.properties.projectId?.pattern ?? '', + ) + expect(pattern.test('11111111-1111-4111-8111-111111111111')).toBe(true) + expect(pattern.test('project_123')).toBe(false) + expect(pattern.test('11111111-1111-4111-8111-11111111111G')).toBe(false) + }) +}) + +function collectMessages(stream: PassThrough): Array> { + const messages: Array> = [] + let pending = '' + stream.on('data', (chunk: unknown) => { + pending += Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk) + const lines = pending.split('\n') + pending = lines.pop() ?? '' + for (const line of lines) { + if (line.length > 0) + messages.push(JSON.parse(line) as Record) + } + }) + return messages +} + +async function waitFor( + messages: readonly unknown[], + count: number, +): Promise { + const deadline = Date.now() + 2000 + while (messages.length < count) { + if (Date.now() >= deadline) + throw new Error('Timed out waiting for MCP output.') + await new Promise((resolve) => setTimeout(resolve, 5)) + } +} diff --git a/test/output/adapters.test.ts b/test/output/adapters.test.ts new file mode 100644 index 00000000..dd774404 --- /dev/null +++ b/test/output/adapters.test.ts @@ -0,0 +1,13 @@ +import {describe, expect, test} from 'vitest' + +import {outputRequest} from '../../src/output/adapters.js' + +describe('output adapters', () => { + test('normalizes SDK response metadata to stable nullable keys', () => { + expect(outputRequest({attempts: 1, headers: {}, status: 200})).toEqual({ + apiVersion: null, + id: null, + status: 200, + }) + }) +}) diff --git a/test/output/contracts.test.ts b/test/output/contracts.test.ts new file mode 100644 index 00000000..fa867334 --- /dev/null +++ b/test/output/contracts.test.ts @@ -0,0 +1,52 @@ +import {describe, expect, test} from 'vitest' + +import { + readEnvelope, + mutationEnvelope, + errorEnvelope, +} from '../../src/output/contracts.js' + +const context = { + owner: 'project' as const, + principalType: 'project_token' as const, + projectId: 'project_1', +} +const request = {apiVersion: '1.0.0', id: 'req_1', status: 200} + +describe('machine output contracts', () => { + test('uses schemaVersion 1 for reads, lists, mutations, and errors', () => { + expect(readEnvelope({data: {id: 'x'}, context, request})).toEqual({ + schemaVersion: 1, + data: {id: 'x'}, + context, + request, + }) + expect( + readEnvelope({ + data: [], + context, + page: {hasMore: false, nextCursor: null}, + request, + }), + ).toMatchObject({schemaVersion: 1, page: {hasMore: false}}) + expect( + mutationEnvelope({ + context, + idempotency: null, + operation: null, + request, + resource: null, + result: {ok: true}, + }), + ).toMatchObject({schemaVersion: 1, operation: null}) + expect( + errorEnvelope({ + category: 'conflict', + code: 'payload_not_replayable', + context, + message: 'Unavailable.', + retryable: false, + }), + ).toMatchObject({schemaVersion: 1, error: {code: 'payload_not_replayable'}}) + }) +}) diff --git a/test/pagination/collect.test.ts b/test/pagination/collect.test.ts new file mode 100644 index 00000000..df7b4bb5 --- /dev/null +++ b/test/pagination/collect.test.ts @@ -0,0 +1,56 @@ +import {describe, expect, test, vi} from 'vitest' + +import {collectPages} from '../../src/pagination/collect.js' + +describe('collectPages', () => { + test('never fetches and discards part of a page', async () => { + const fetchPage = vi + .fn() + .mockResolvedValueOnce({ + items: [1, 2], + page: {hasMore: true, nextCursor: 'c2'}, + }) + .mockResolvedValueOnce({ + items: [3], + page: {hasMore: true, nextCursor: 'c3'}, + }) + const result = await collectPages({fetchPage, limit: 2, maxItems: 3}) + + expect(fetchPage.mock.calls).toEqual([ + [{cursor: undefined, limit: 2}], + [{cursor: 'c2', limit: 1}], + ]) + expect(result).toEqual({ + items: [1, 2, 3], + page: {hasMore: true, nextCursor: 'c3'}, + }) + }) + + test('caps a nonconforming oversized server page at maxItems', async () => { + const fetchPage = vi.fn(() => + Promise.resolve({ + items: [1, 2, 3], + page: {hasMore: true, nextCursor: 'c2'}, + }), + ) + + const result = await collectPages({fetchPage, limit: 2, maxItems: 2}) + + expect(result).toEqual({ + items: [1, 2], + page: {hasMore: true, nextCursor: null}, + }) + expect(fetchPage).toHaveBeenCalledTimes(1) + }) + + test('rejects an unchanged continuation cursor instead of refetching a page', async () => { + const fetchPage = vi.fn(() => + Promise.resolve({items: [1], page: {hasMore: true, nextCursor: 'same'}}), + ) + + await expect( + collectPages({cursor: 'same', fetchPage, limit: 1, maxItems: 5}), + ).rejects.toThrow('same continuation cursor') + expect(fetchPage).toHaveBeenCalledTimes(1) + }) +}) diff --git a/test/sdk/client.test.ts b/test/sdk/client.test.ts index 29ab2877..ef7a8423 100644 --- a/test/sdk/client.test.ts +++ b/test/sdk/client.test.ts @@ -1,7 +1,7 @@ import { + Client, isEvent, MessagingClient, - Client as PlatformClient, RawClient, type WebhookEvent, } from '@polymorfa/sdk' @@ -12,6 +12,7 @@ import { createPlatformClient, createRawClient, } from '../../src/sdk/client.js' +import {startHttpServer} from '../helpers/http-server.js' describe('createRawClient', () => { test('returns the public Platform raw client', () => { @@ -47,7 +48,7 @@ describe('createRawClient', () => { ) }) - test('creates a typed Messaging client with session pairing and operation polling', () => { + test('creates a typed Messaging client with session and operation resources', () => { const client = createMessagingClient({ surface: 'messaging', credential: { @@ -61,10 +62,27 @@ describe('createRawClient', () => { expect(client).toBeInstanceOf(MessagingClient) expect(client.sessions).toHaveProperty('qr') expect(client.sessions).toHaveProperty('requestPairingCode') + expect(client.quickLinks).toHaveProperty('create') + expect(client.quickLinks).toHaveProperty('retrieve') + expect(client.quickLinks).toHaveProperty('cancel') expect(client).not.toHaveProperty('operations') }) - test('creates a typed Platform client and rejects the wrong surface', () => { + test('creates a server-only Messaging client from a project token', () => { + const client = createMessagingClient({ + credential: { + type: 'projectToken', + value: + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + surface: 'messaging', + }) + + expect(client).toBeInstanceOf(MessagingClient) + expect(client.quickLinks).toHaveProperty('create') + }) + + test('creates the unified organization client and rejects the wrong surface', () => { const client = createPlatformClient({ surface: 'platform', credential: { @@ -74,7 +92,8 @@ describe('createRawClient', () => { }, }) - expect(client).toBeInstanceOf(PlatformClient) + expect(client).toBeInstanceOf(Client) + expect(client.owner).toBe('organization') expect(client.billing).toHaveProperty('retrieve') expect(client.billing).toHaveProperty('usage') expect(client.billing).toHaveProperty('listTransactions') @@ -97,6 +116,39 @@ describe('createRawClient', () => { }), ).toThrow('requires the Platform API surface') }) + + test('uses the unified project view for typed developer resources', async () => { + const server = await startHttpServer({ + body: {data: [], page: {nextCursor: null}}, + headers: {'x-request-id': 'req_project_events'}, + }) + try { + const client = createPlatformClient({ + baseUrl: server.baseUrl, + credential: { + type: 'apiKey', + value: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + }, + surface: 'platform', + }).project('11111111-1111-4111-8111-111111111111') + + const page = await client.events.list({limit: 25}) + + expect(page.items).toEqual([]) + expect(page.response.metadata.requestId).toBe('req_project_events') + expect(server.requests).toHaveLength(1) + expect(server.requests[0]).toMatchObject({ + method: 'GET', + url: '/platform/projects/11111111-1111-4111-8111-111111111111/events?limit=25', + }) + expect(server.requests[0]?.headers.authorization).toBe( + 'Bearer pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ) + } finally { + await server.close() + } + }) }) describe('typed webhook events', () => { diff --git a/test/security/secret-file.test.ts b/test/security/secret-file.test.ts new file mode 100644 index 00000000..113541b3 --- /dev/null +++ b/test/security/secret-file.test.ts @@ -0,0 +1,47 @@ +import {lstat, readFile, writeFile} from 'node:fs/promises' +import {join} from 'node:path' + +import {describe, expect, test} from 'vitest' + +import {prepareSecretFile} from '../../src/security/secret-file.js' +import {temporaryDirectory} from '../support/fs.js' + +const posixOnly = test.skipIf(process.platform === 'win32') + +describe('prepareSecretFile', () => { + posixOnly('writes once to a regular private file', async () => { + const path = join(await temporaryDirectory(), 'secret') + const destination = await prepareSecretFile(path) + await destination.write('private-value') + + expect(await readFile(path, 'utf8')).toBe('private-value\n') + expect((await lstat(path)).mode & 0o777).toBe(0o600) + }) + + test('refuses an existing destination before a remote mutation', async () => { + const path = join(await temporaryDirectory(), 'secret') + await writeFile(path, 'keep', {mode: 0o600}) + await expect(prepareSecretFile(path)).rejects.toThrow('already exists') + expect(await readFile(path, 'utf8')).toBe('keep') + }) + + test('writes on Windows without requiring directory fsync support', async () => { + const path = join(await temporaryDirectory(), 'secret') + const platform = process.platform + + try { + Object.defineProperty(process, 'platform', { + configurable: true, + value: 'win32', + }) + const destination = await prepareSecretFile(path) + await destination.write('private-value') + expect(await readFile(path, 'utf8')).toBe('private-value\n') + } finally { + Object.defineProperty(process, 'platform', { + configurable: true, + value: platform, + }) + } + }) +}) diff --git a/test/services/auth.test.ts b/test/services/auth.test.ts index f1b58813..d0501c27 100644 --- a/test/services/auth.test.ts +++ b/test/services/auth.test.ts @@ -30,7 +30,7 @@ describe('AuthService', () => { credential: {account: 'profile:work:apiKey', kind: 'apiKey'}, }, }, - version: 1, + version: 2, }) expect(JSON.stringify(fixture.saved())).not.toContain( 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', @@ -59,6 +59,127 @@ describe('AuthService', () => { expect(fixture.credentials.set).not.toHaveBeenCalled() }) + test('stores an exact-project token only with explicit project ownership', async () => { + const fixture = createFixture() + const token = `pmfa_pt_${'A'.repeat(94)}` + + await expect( + fixture.service.loginProjectToken({ + profile: 'project', + project: 'project_1', + projectToken: token, + }), + ).resolves.toEqual({ + active: true, + credentialKind: 'projectToken', + name: 'project', + owner: 'project', + project: 'project_1', + }) + expect(fixture.credentials.set).toHaveBeenCalledWith( + 'profile:project:projectToken', + token, + ) + }) + + test('delegates project-token format validation to the public SDK', async () => { + const fixture = createFixture() + + await fixture.service.loginProjectToken({ + profile: 'project', + project: 'project_1', + projectToken: + ' pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ', + }) + + expect(fixture.credentials.set).toHaveBeenCalledWith( + 'profile:project:projectToken', + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + ) + await expect( + fixture.service.loginProjectToken({ + profile: 'invalid', + project: 'project_1', + projectToken: 'pmfa_pt_', + }), + ).rejects.toThrow('canonical pmfa_pt_ v1 format') + }) + + test('removes the previous project-token keyring entry after switching a profile to an API key', async () => { + const fixture = createFixture({ + profiles: { + work: { + credential: { + account: 'profile:work:projectToken', + kind: 'projectToken', + }, + owner: 'project', + project: 'project_1', + }, + }, + version: 2, + }) + vi.mocked(fixture.credentials.get).mockResolvedValue( + `pmfa_pt_${'A'.repeat(94)}`, + ) + + await fixture.service.login({ + apiKey: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + profile: 'work', + }) + + expect(fixture.credentials.delete).toHaveBeenCalledWith( + 'profile:work:projectToken', + ) + expect(fixture.saved()).toEqual({ + activeProfile: 'work', + profiles: { + work: { + credential: {account: 'profile:work:apiKey', kind: 'apiKey'}, + }, + }, + version: 2, + }) + }) + + test('removes a newly written API-key entry when a credential-kind switch cannot save its profile', async () => { + const fixture = createFixture({ + profiles: { + work: { + credential: { + account: 'profile:work:projectToken', + kind: 'projectToken', + }, + owner: 'project', + project: 'project_1', + }, + }, + version: 2, + }) + const oldToken = `pmfa_pt_${'A'.repeat(94)}` + vi.mocked(fixture.credentials.get).mockResolvedValue(oldToken) + vi.mocked(fixture.repository.save).mockRejectedValue( + new Error('profile save failed'), + ) + + await expect( + fixture.service.login({ + apiKey: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + profile: 'work', + }), + ).rejects.toThrow('profile save failed') + + expect(fixture.credentials.delete).toHaveBeenCalledWith( + 'profile:work:apiKey', + ) + expect(fixture.credentials.set).toHaveBeenLastCalledWith( + 'profile:work:projectToken', + oldToken, + ) + }) + test('lists profiles without reading secure credentials', async () => { const fixture = createFixture({ activeProfile: 'work', @@ -72,7 +193,7 @@ describe('AuthService', () => { organization: 'org_work', }, }, - version: 1, + version: 2, }) await expect(fixture.service.list()).resolves.toEqual([ @@ -113,6 +234,33 @@ describe('AuthService', () => { }) }) + test('validates a stored project token with its own credential format', async () => { + const fixture = createFixture({ + activeProfile: 'project', + profiles: { + project: { + credential: { + account: 'profile:project:projectToken', + kind: 'projectToken', + }, + owner: 'project', + project: 'project_1', + }, + }, + version: 2, + }) + vi.mocked(fixture.credentials.get).mockResolvedValue( + `pmfa_pt_${'A'.repeat(94)}`, + ) + + await expect(fixture.service.status('project')).resolves.toMatchObject({ + credentialFormatValid: true, + credentialKind: 'projectToken', + owner: 'project', + project: 'project_1', + }) + }) + test('logs out the selected profile and preserves unrelated profiles', async () => { const fixture = createFixture({ activeProfile: 'work', @@ -124,7 +272,7 @@ describe('AuthService', () => { credential: {account: 'profile:work:apiKey', kind: 'apiKey'}, }, }, - version: 1, + version: 2, }) await expect(fixture.service.logout('work')).resolves.toEqual({ @@ -140,7 +288,7 @@ describe('AuthService', () => { credential: {account: 'profile:personal:apiKey', kind: 'apiKey'}, }, }, - version: 1, + version: 2, }) }) }) @@ -152,11 +300,11 @@ function profileConfig(): ProfileConfig { credential: {account: 'profile:work:apiKey', kind: 'apiKey'}, }, }, - version: 1, + version: 2, } } -function createFixture(initial: ProfileConfig = {profiles: {}, version: 1}) { +function createFixture(initial: ProfileConfig = {profiles: {}, version: 2}) { let config = initial const repository = { load: vi.fn(() => Promise.resolve(config)), @@ -173,6 +321,7 @@ function createFixture(initial: ProfileConfig = {profiles: {}, version: 1}) { return { credentials, + repository, saved: () => config, service: new AuthService(repository, credentials), } diff --git a/test/support/fs.ts b/test/support/fs.ts new file mode 100644 index 00000000..1c9808ce --- /dev/null +++ b/test/support/fs.ts @@ -0,0 +1,7 @@ +import {mkdtemp} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +export function temporaryDirectory(): Promise { + return mkdtemp(join(tmpdir(), 'polymorfa-cli-test-')) +}