diff --git a/.prettierignore b/.prettierignore index 4cc05dbb..d8a14825 100644 --- a/.prettierignore +++ b/.prettierignore @@ -4,3 +4,6 @@ node_modules/ package-lock.json *.tgz .sdk-source/ + +# Preserve the upstream golden vectors byte-for-byte. +test/fixtures/machine-credentials-v1.json diff --git a/README.md b/README.md index d4cd6e06..317ef972 100644 --- a/README.md +++ b/README.md @@ -13,9 +13,51 @@ polymorfa version ## Requirements - Node.js 22 or newer -- A Polymorfa organization API key or exact-project token for developer requests +- A Polymorfa team key or exact-project token for developer requests - A server API key or short-lived client token for Messaging requests +## Account browser sign-in + +The separate `account` commands require an API environment that registers the +Polymorfa CLI device client. Installing a CLI package does not enable that server +feature. Supply the trusted API and Console origins for the same environment: + +```shell +polymorfa account login --profile personal \ + --api-origin https://api.example.invalid \ + --console-origin https://console.example.invalid +polymorfa account status --profile personal --json +polymorfa account teams --profile personal --limit 25 --json +polymorfa account projects --profile personal --team TEAM_UUID --json +polymorfa account logout --profile personal --json +``` + +Replace the example origins with your configured environment. Login prints the +validated approval URL and user code to standard error; open that URL and approve +the request in the browser. It never opens a browser automatically or accepts a +session token on the command line. `--json` prints one final result to standard +output. `--timeout` cancels the operation without extending the server's original +device-code expiry. Interrupting an already redeemed sign-in attempts to revoke +that new session; an uncertain revocation is reported as uncertain. + +Account sessions use their own profile file and operating-system keyring +namespace. The opaque session stays in the keyring; short-lived verified JWTs +stay in memory. `account list` and `account use NAME` inspect or select only these +account profiles. They do not change the active machine profile. API-key, +project-token and Messaging commands cannot use an account session. + +The only account resource commands are the read-only `account teams` and +`account projects`. Each response is one page; pass `--after` the returned +`nextAfter` UUID to continue. Current membership and approved live team/project +grants are checked by the API on every read. Approval for all current and future +resources does not add command permissions. There is no account raw-request, +key-minting or messaging command. These commands require the local Node.js CLI +and are refused by the browser build before reading its environment or fetching. + +Logout reports remote session revocation and local profile/keyring cleanup +separately. Incomplete cleanup or unconfirmed revocation exits unsuccessfully; +local removal alone is not proof that a remote session was revoked. + ## Development installation With access to the private source repository, install the current development @@ -140,7 +182,7 @@ The registry comes from the real command files. Commands needing the installed C ## Authentication profiles Until the backend exposes a revocable CLI device-authorization flow, login -accepts an organization API key or exact-project token through standard input: +accepts an team key or exact-project token through standard input: ```shell printf '%s' "$POLYMORFA_API_KEY" | \ @@ -160,6 +202,12 @@ Secret Service. A keyring failure stops the command; the CLI never silently falls back to plaintext. `auth status` reports local secure-store availability in this development slice and does not yet claim remote identity validation. +Treat all Polymorfa credentials as opaque strings. Pass them unchanged; never +parse them or assume a fixed length. Credential columns must store at least 512 +characters, preferably unrestricted text. Client-token length varies with its +bindings, Customer block and signing key ID. Local prefix, encoding and checksum +checks do not authenticate a credential; the service checks live authority. + Environment credentials take precedence over the active profile for automation. An explicit `--profile ` takes precedence over environment credentials. @@ -257,7 +305,7 @@ your shell to its startup file if you want completion in later sessions. ## Inspect sessions -Use an organization API key with `sessions:read` to inspect stored session +Use an team key with `sessions:read` to inspect stored session metadata: ```shell @@ -272,7 +320,7 @@ Use a session ID from the list for `view` or `status`; these commands do not accept `--project`. `status` reads the stored status and optional reason from the session record. It does not probe the device or wait for a connection. Key metadata listing and revocation are available through `key list` and -`key revoke`; the CLI does not mint or rotate organization keys. +`key revoke`; the CLI does not mint or rotate team keys. ## QuickLinks @@ -290,7 +338,7 @@ 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 +Inspect or cancel a link with an team key or an exact-project token that has `quicklink:manage`: ```shell @@ -493,7 +541,7 @@ header. ### Flow drafts and provider lifecycle -Flow commands require a project scope and an organization API key or project +Flow commands require a project scope and an team key or project token. Reads use `sessions:read`; mutations use `sessions:manage`. ```shell @@ -576,7 +624,7 @@ linked to another project; the API checks that the credential can access it. ### Official API observations and templates -The `cloud` commands require an organization API key or a project token with the +The `cloud` commands require an team key or a project token with the listed scope. Client tokens cannot use these commands. Server-side feature access still applies: an installed command does not enroll a Number or enable a beta feature. @@ -1243,7 +1291,7 @@ sox greeting.wav -t raw -r 16000 -e signed -b 16 -c 1 - | \ Polymorfa checks every call against the team's call policy before the destination rings. `calls policy` and `calls opt-outs` read and change that -policy and require an organization API key (`POLYMORFA_API_KEY` or a profile). +policy and require an team key (`POLYMORFA_API_KEY` or a profile). A project token used as the API key reaches the API, which refuses it with `403` (exit code 3). A client token is not a Platform credential, so the CLI refuses the command before sending a request (exit code 2). @@ -1322,9 +1370,9 @@ polymorfa calls check +14155550123 --session support ### Call statistics and export `calls stats` summarizes calls and `calls export` writes call detail records. -Both accept an organization API key or an exact-project token with the +Both accept an team key or an exact-project token with the `sessions:read` scope. Pass `--project` to read one project. Without it, an -organization API key reads every project in the organization. A project token +team key reads every project in the organization. A project token reads only its own project and requires `--project`. ```shell @@ -1413,7 +1461,7 @@ or times out, the command keeps the password on screen and exits successfully. Voice Automation is a beta (`calls.voice-automation`) for enrolled teams only. For other teams, every change except delete fails with `voice_not_enabled`. Reads, previews and deletes keep working after a team leaves the beta. The -commands require an organization server API key with `voice:read` (lists, +commands require an team key with `voice:read` (lists, views, previews) or `voice:manage` (changes). ### Audio library @@ -1690,7 +1738,7 @@ request metadata. ## Platform access and security -Organization server API keys can inspect the credential, membership, audit, +Team keys can inspect the credential, membership, audit, ban, incident, saved QuickLink settings, and project-token metadata made available by the Platform API: @@ -2253,7 +2301,7 @@ status, and safe counters, never primary credentials, listener credentials, forwarding secrets, or unrestricted payloads. `polymorfa_session_list` is a read-only discovery tool for one explicit project -UUID. It uses the selected CLI organization API key with `sessions:read` and +UUID. It uses the selected CLI team key with `sessions:read` and returns session ID, name, stored status, and test-mode flag. It omits phone numbers, credentials, and message data. The tool does not probe the live device. Project tokens and Messaging client tokens cannot use this tool. A failed read @@ -2465,7 +2513,7 @@ the CLI never creates or confirms a replacement purchase automatically. ### Number tier changes -Tier changes use an organization API key. Quote first, review the credit charge +Tier changes use an team key. Quote first, review the credit charge and effective time, then confirm that exact quote: ```shell @@ -2554,7 +2602,7 @@ polymorfa hybrid-link resume --session NUMBER --expected-revision REVISION For project and session routing scopes, `--project`, the nearest linked project, and the selected profile determine the project in that order. A project-token profile fails when its project differs from the linked directory. Team routing -scope uses an organization API key and ignores directory project links. `quicklink +scope uses an team key and ignores directory project links. `quicklink availability` shows existing connection status and any QuickLink that can be resumed. @@ -2575,7 +2623,7 @@ transports within the restrictions inherited from the team and project. ### Functions -Functions are project-owned. Select `--project` or use the linked project/profile context. The commands accept an organization API key or a project token; browser client tokens are refused. Access also depends on the team's enabled Functions release and the configured execution region. +Functions are project-owned. Select `--project` or use the linked project/profile context. The commands accept an team key or a project token; browser client tokens are refused. Access also depends on the team's enabled Functions release and the configured execution region. ```bash polymorfa function list --project PROJECT_UUID @@ -2660,7 +2708,7 @@ does not retrieve media or webhook deliveries. ### Spending limits and funding priority -With an organization key carrying `billing:read`, inspect current controls: +With an team key carrying `billing:read`, inspect current controls: ```bash polymorfa billing limits list --project --json diff --git a/contracts/cli-reference.json b/contracts/cli-reference.json index 432e7abe..7255a5f2 100644 --- a/contracts/cli-reference.json +++ b/contracts/cli-reference.json @@ -2,6 +2,435 @@ "schemaVersion": 1, "cliVersion": "0.1.0-dev.0", "commands": [ + { + "id": "account list", + "summary": "List local account-device profile metadata without reading credentials or making requests.", + "description": "List local account-device profile metadata without reading credentials or making requests.", + "usage": ["polymorfa account list [flags]"], + "aliases": [], + "hidden": false, + "args": [], + "flags": [ + { + "name": "json", + "char": null, + "type": "boolean", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Print one JSON result.", + "env": null, + "helpGroup": null + }, + { + "name": "profile", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Select an account-device profile, separate from machine profiles.", + "env": null, + "helpGroup": null + }, + { + "name": "timeout", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Cancel this operation after this many milliseconds.", + "env": null, + "helpGroup": null + } + ], + "examples": [], + "state": null + }, + { + "id": "account login", + "summary": "Sign in through browser approval and store only the account session in its separate OS keyring.", + "description": "Sign in through browser approval and store only the account session in its separate OS keyring.", + "usage": ["polymorfa account login [flags]"], + "aliases": [], + "hidden": false, + "args": [], + "flags": [ + { + "name": "api-origin", + "char": null, + "type": "option", + "required": true, + "multiple": false, + "default": null, + "options": [], + "description": "Trusted HTTPS API origin for this Polymorfa environment.", + "env": null, + "helpGroup": null + }, + { + "name": "console-origin", + "char": null, + "type": "option", + "required": true, + "multiple": false, + "default": null, + "options": [], + "description": "Trusted HTTPS Console origin for browser approval.", + "env": null, + "helpGroup": null + }, + { + "name": "json", + "char": null, + "type": "boolean", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Print one JSON result.", + "env": null, + "helpGroup": null + }, + { + "name": "profile", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Select an account-device profile, separate from machine profiles.", + "env": null, + "helpGroup": null + }, + { + "name": "timeout", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Cancel this operation after this many milliseconds.", + "env": null, + "helpGroup": null + } + ], + "examples": [], + "state": null + }, + { + "id": "account logout", + "summary": "Revoke only the selected account session and report remote revocation separately from local cleanup.", + "description": "Revoke only the selected account session and report remote revocation separately from local cleanup.", + "usage": ["polymorfa account logout [flags]"], + "aliases": [], + "hidden": false, + "args": [], + "flags": [ + { + "name": "json", + "char": null, + "type": "boolean", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Print one JSON result.", + "env": null, + "helpGroup": null + }, + { + "name": "profile", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Select an account-device profile, separate from machine profiles.", + "env": null, + "helpGroup": null + }, + { + "name": "timeout", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Cancel this operation after this many milliseconds.", + "env": null, + "helpGroup": null + } + ], + "examples": [], + "state": null + }, + { + "id": "account projects", + "summary": "Read one page of projects in a currently allowed team; this grants no command or messaging access.", + "description": "Read one page of projects in a currently allowed team; this grants no command or messaging access.", + "usage": ["polymorfa account projects [flags]"], + "aliases": [], + "hidden": false, + "args": [], + "flags": [ + { + "name": "after", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Continue after this project UUID.", + "env": null, + "helpGroup": null + }, + { + "name": "json", + "char": null, + "type": "boolean", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Print one JSON result.", + "env": null, + "helpGroup": null + }, + { + "name": "limit", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Page size, up to 100.", + "env": null, + "helpGroup": null + }, + { + "name": "profile", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Select an account-device profile, separate from machine profiles.", + "env": null, + "helpGroup": null + }, + { + "name": "team", + "char": null, + "type": "option", + "required": true, + "multiple": false, + "default": null, + "options": [], + "description": "Team UUID returned by account teams.", + "env": null, + "helpGroup": null + }, + { + "name": "timeout", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Cancel this operation after this many milliseconds.", + "env": null, + "helpGroup": null + } + ], + "examples": [], + "state": null + }, + { + "id": "account status", + "summary": "Check the selected account session against its pinned API issuer.", + "description": "Check the selected account session against its pinned API issuer.", + "usage": ["polymorfa account status [flags]"], + "aliases": [], + "hidden": false, + "args": [], + "flags": [ + { + "name": "json", + "char": null, + "type": "boolean", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Print one JSON result.", + "env": null, + "helpGroup": null + }, + { + "name": "profile", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Select an account-device profile, separate from machine profiles.", + "env": null, + "helpGroup": null + }, + { + "name": "timeout", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Cancel this operation after this many milliseconds.", + "env": null, + "helpGroup": null + } + ], + "examples": [], + "state": null + }, + { + "id": "account teams", + "summary": "Read one page of teams currently allowed to the selected account session.", + "description": "Read one page of teams currently allowed to the selected account session.", + "usage": ["polymorfa account teams [flags]"], + "aliases": [], + "hidden": false, + "args": [], + "flags": [ + { + "name": "after", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Continue after this team UUID.", + "env": null, + "helpGroup": null + }, + { + "name": "json", + "char": null, + "type": "boolean", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Print one JSON result.", + "env": null, + "helpGroup": null + }, + { + "name": "limit", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Page size, up to 100.", + "env": null, + "helpGroup": null + }, + { + "name": "profile", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Select an account-device profile, separate from machine profiles.", + "env": null, + "helpGroup": null + }, + { + "name": "timeout", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Cancel this operation after this many milliseconds.", + "env": null, + "helpGroup": null + } + ], + "examples": [], + "state": null + }, + { + "id": "account use", + "summary": "Select a local account-device profile without changing the active machine profile or its authority.", + "description": "Select a local account-device profile without changing the active machine profile or its authority.", + "usage": ["polymorfa account use NAME [flags]"], + "aliases": [], + "hidden": false, + "args": [ + {"name": "name", "description": "", "required": true, "options": []} + ], + "flags": [ + { + "name": "json", + "char": null, + "type": "boolean", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Print one JSON result.", + "env": null, + "helpGroup": null + }, + { + "name": "profile", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Select an account-device profile, separate from machine profiles.", + "env": null, + "helpGroup": null + }, + { + "name": "timeout", + "char": null, + "type": "option", + "required": false, + "multiple": false, + "default": null, + "options": [], + "description": "Cancel this operation after this many milliseconds.", + "env": null, + "helpGroup": null + } + ], + "examples": [], + "state": null + }, { "id": "agent setup", "summary": "Install the Polymorfa agent kit in detected coding agents.", @@ -1675,8 +2104,8 @@ }, { "id": "auth login", - "summary": "Store an organization key or project token in the operating-system keyring.", - "description": "Store an organization key or project token in the operating-system keyring.", + "summary": "Store an opaque team key or project token in the operating-system keyring. Never parse credentials or assume a fixed length.", + "description": "Store an opaque team key or project token in the operating-system keyring. Never parse credentials or assume a fixed length.", "usage": ["polymorfa auth login [flags]"], "aliases": [], "hidden": false, @@ -5881,7 +6310,7 @@ { "id": "billing limits list", "summary": "Read monthly project and number spending limits.", - "description": "Requires an organization key with billing:read. Use --project to include its number limits. Months reset on the first day at 00:00 UTC.", + "description": "Requires an team key with billing:read. Use --project to include its number limits. Months reset on the first day at 00:00 UTC.", "usage": ["polymorfa billing limits list [flags]"], "aliases": [], "hidden": false, @@ -6002,7 +6431,7 @@ { "id": "billing limits set", "summary": "Set or remove a monthly spending limit.", - "description": "Requires Pay-As-You-Go and an organization key with billing:manage. Zero blocks new charges; already-paid service continues. A stale observed revision fails without overwriting newer settings.", + "description": "Requires Pay-As-You-Go and an team key with billing:manage. Zero blocks new charges; already-paid service continues. A stale observed revision fails without overwriting newer settings.", "usage": ["polymorfa billing limits set ID [flags]"], "aliases": [], "hidden": false, @@ -6178,7 +6607,7 @@ { "id": "billing priority list", "summary": "Read project, customer and number funding priorities.", - "description": "Requires an organization key with billing:read. Project priority takes precedence. Numbers inherit customer priority; a higher number priority overrides it. Use --project for its funding controls. Includes the revision used for changes.", + "description": "Requires an team key with billing:read. Project priority takes precedence. Numbers inherit customer priority; a higher number priority overrides it. Use --project for its funding controls. Includes the revision used for changes.", "usage": ["polymorfa billing priority list [flags]"], "aliases": [], "hidden": false, @@ -6299,7 +6728,7 @@ { "id": "billing priority reorder", "summary": "Save a complete funding order atomically.", - "description": "Requires Pay-As-You-Go and an organization key with billing:manage. Pass every active project, customer or real number for its scope. Resource scope mixes all customers and numbers in --project, highest first. Incomplete or stale orders fail without partial changes.", + "description": "Requires Pay-As-You-Go and an team key with billing:manage. Pass every active project, customer or real number for its scope. Resource scope mixes all customers and numbers in --project, highest first. Incomplete or stale orders fail without partial changes.", "usage": ["polymorfa billing priority reorder [flags]"], "aliases": [], "hidden": false, @@ -6468,7 +6897,7 @@ { "id": "billing priority set", "summary": "Set a project or number funding priority.", - "description": "Requires Pay-As-You-Go and an organization key with billing:manage. Changes future renewal protection and recovery; paid windows keep their expiry.", + "description": "Requires Pay-As-You-Go and an team key with billing:manage. Changes future renewal protection and recovery; paid windows keep their expiry.", "usage": ["polymorfa billing priority set ID [flags]"], "aliases": [], "hidden": false, @@ -11295,7 +11724,7 @@ { "id": "calls export", "summary": "Export call detail records.", - "description": "Export call detail records as CSV or newline-delimited JSON, newest first.\n\nEach record has callId, projectId, sessionId, direction, upstream, outcome, state, hasVideo, peerRef, startedAt, connectedAt, endedAt, durationSeconds, and endReason. peerRef is a team-specific pseudonym of the other party, never a phone number.\n\nThe command requests every page until no records remain and writes one CSV header. Records go to standard output, or to --output. Progress and the next cursor go to standard error, as JSON lines with --json. --max-pages stops early; resume with --cursor and the same filters. When a page fails, the command reports the cursor that resumes from that page and exits non-zero.\n\nPass --project to read one project. Without it, an organization API key reads every project in the organization. A project token reads only its own project and requires --project. Credentials need the sessions:read scope.", + "description": "Export call detail records as CSV or newline-delimited JSON, newest first.\n\nEach record has callId, projectId, sessionId, direction, upstream, outcome, state, hasVideo, peerRef, startedAt, connectedAt, endedAt, durationSeconds, and endReason. peerRef is a team-specific pseudonym of the other party, never a phone number.\n\nThe command requests every page until no records remain and writes one CSV header. Records go to standard output, or to --output. Progress and the next cursor go to standard error, as JSON lines with --json. --max-pages stops early; resume with --cursor and the same filters. When a page fails, the command reports the cursor that resumes from that page and exits non-zero.\n\nPass --project to read one project. Without it, an team key reads every project in the organization. A project token reads only its own project and requires --project. Credentials need the sessions:read scope.", "usage": ["polymorfa calls export [flags]"], "aliases": [], "hidden": false, @@ -12837,7 +13266,7 @@ { "id": "calls opt-outs add", "summary": "Add a person to the do-not-call list.", - "description": "Add a person to the team’s do-not-call list by phone number or BSUID. From then on, calls to them are refused with call_recipient_opted_out, including calls your PBX places through a SIP trunk. Adding a person already on the list changes nothing and returns their entry (metadata.status 200 instead of 201). The list holds up to 100,000 entries; a full list fails with call_opt_out_limit (exit code 4). Requires an organization API key.", + "description": "Add a person to the team’s do-not-call list by phone number or BSUID. From then on, calls to them are refused with call_recipient_opted_out, including calls your PBX places through a SIP trunk. Adding a person already on the list changes nothing and returns their entry (metadata.status 200 instead of 201). The list holds up to 100,000 entries; a full list fails with call_opt_out_limit (exit code 4). Requires an team key.", "usage": ["polymorfa calls opt-outs add [flags]"], "aliases": [], "hidden": false, @@ -13003,7 +13432,7 @@ { "id": "calls opt-outs import", "summary": "Import entries into the do-not-call list.", - "description": "Add many people to the team’s do-not-call list from a file of up to 10 MiB. CSV needs a header row naming phoneNumber, bsuid and optionally note; each row names a phone number (E.164) or a BSUID. JSON is an array of {\"phoneNumber\" | \"bsuid\", \"note\"} objects, or an object with that array as \"entries\". The CLI sends 5,000 entries per request and reports the entries added, already listed and rejected, with each rejected entry's zero-based position (and CSV line). Running an import again is safe: entries already on the list are counted as already listed. Exit codes: 0 when every entry is listed; 12 when some entries were rejected, or when a later request failed after earlier batches were stored (run the import again); the request's own exit code when nothing was stored. Requires an organization API key.", + "description": "Add many people to the team’s do-not-call list from a file of up to 10 MiB. CSV needs a header row naming phoneNumber, bsuid and optionally note; each row names a phone number (E.164) or a BSUID. JSON is an array of {\"phoneNumber\" | \"bsuid\", \"note\"} objects, or an object with that array as \"entries\". The CLI sends 5,000 entries per request and reports the entries added, already listed and rejected, with each rejected entry's zero-based position (and CSV line). Running an import again is safe: entries already on the list are counted as already listed. Exit codes: 0 when every entry is listed; 12 when some entries were rejected, or when a later request failed after earlier batches were stored (run the import again); the request's own exit code when nothing was stored. Requires an team key.", "usage": ["polymorfa calls opt-outs import FILE [flags]"], "aliases": [], "hidden": false, @@ -13156,7 +13585,7 @@ { "id": "calls opt-outs list", "summary": "List the do-not-call list.", - "description": "List the team’s do-not-call list, newest first. One page is returned unless --paginate follows page.nextCursor up to --max-items entries; human output ends with the --cursor for the next page. --phone-number or --bsuid returns only that person’s entry, if any. Requires an organization API key.", + "description": "List the team’s do-not-call list, newest first. One page is returned unless --paginate follows page.nextCursor up to --max-items entries; human output ends with the --cursor for the next page. --phone-number or --bsuid returns only that person’s entry, if any. Requires an team key.", "usage": ["polymorfa calls opt-outs list [flags]"], "aliases": [], "hidden": false, @@ -13359,7 +13788,7 @@ { "id": "calls opt-outs remove", "summary": "Remove a person from the do-not-call list.", - "description": "Remove one entry from the team’s do-not-call list. The person can be called again at once. Requires --yes and an organization API key; an unknown ID fails with exit code 4.", + "description": "Remove one entry from the team’s do-not-call list. The person can be called again at once. Requires --yes and an team key; an unknown ID fails with exit code 4.", "usage": ["polymorfa calls opt-outs remove OPTOUTID [flags]"], "aliases": [], "hidden": false, @@ -14209,7 +14638,7 @@ { "id": "calls policy get", "summary": "Read the team call policy.", - "description": "Read the team call policy: the blocked country codes, the number of entries on the do-not-call list, and the revision to pass to `calls policy set --expected-revision`. The revision is 0 before the codes are first saved. Requires an organization API key: a project token used as the API key is refused by the API (exit code 3), and a client token is not accepted for this command (exit code 2).", + "description": "Read the team call policy: the blocked country codes, the number of entries on the do-not-call list, and the revision to pass to `calls policy set --expected-revision`. The revision is 0 before the codes are first saved. Requires an team key: a project token used as the API key is refused by the API (exit code 3), and a client token is not accepted for this command (exit code 2).", "usage": ["polymorfa calls policy get [flags]"], "aliases": [], "hidden": false, @@ -14332,7 +14761,7 @@ { "id": "calls policy set", "summary": "Replace the blocked country codes.", - "description": "Replace the team’s blocked country codes. Calls to phone numbers that start with a blocked code are refused with call_destination_blocked, and while any code is blocked, calls to a person whose phone number is not known are refused too. The list you pass replaces the stored list; --clear allows every country. Codes are country calling codes or longer dialing prefixes of 1 to 4 digits (44, 1876), at most 300. New calls use the new list at once; calls in progress continue. --expected-revision applies the change only if the policy still has that revision (see `calls policy get`); otherwise the command fails with state_conflict (exit code 4) and changes nothing. Requires an organization API key.", + "description": "Replace the team’s blocked country codes. Calls to phone numbers that start with a blocked code are refused with call_destination_blocked, and while any code is blocked, calls to a person whose phone number is not known are refused too. The list you pass replaces the stored list; --clear allows every country. Codes are country calling codes or longer dialing prefixes of 1 to 4 digits (44, 1876), at most 300. New calls use the new list at once; calls in progress continue. --expected-revision applies the change only if the policy still has that revision (see `calls policy get`); otherwise the command fails with state_conflict (exit code 4) and changes nothing. Requires an team key.", "usage": ["polymorfa calls policy set [flags]"], "aliases": [], "hidden": false, @@ -15681,7 +16110,7 @@ { "id": "calls stats", "summary": "Summarize call volume, outcomes, and duration.", - "description": "Summarize calls: counts by outcome, answer rate, and connected time, grouped by day, hour, session, or outcome.\n\nThe range defaults to the last 7 days. --group-by day covers at most 366 days and --group-by hour at most 31. Day and hour buckets, and the heatmap, use --timezone (UTC by default).\n\nPass --project to read one project. Without it, an organization API key reads every project in the organization. A project token reads only its own project and requires --project. Credentials need the sessions:read scope.", + "description": "Summarize calls: counts by outcome, answer rate, and connected time, grouped by day, hour, session, or outcome.\n\nThe range defaults to the last 7 days. --group-by day covers at most 366 days and --group-by hour at most 31. Day and hour buckets, and the heatmap, use --timezone (UTC by default).\n\nPass --project to read one project. Without it, an team key reads every project in the organization. A project token reads only its own project and requires --project. Credentials need the sessions:read scope.", "usage": ["polymorfa calls stats [flags]"], "aliases": [], "hidden": false, @@ -39014,8 +39443,8 @@ }, { "id": "key revoke", - "summary": "Revoke an organization API key.", - "description": "Revoke an organization API key.", + "summary": "Revoke an team key.", + "description": "Revoke an team key.", "usage": ["polymorfa key revoke KEY [flags]"], "aliases": [], "hidden": false, @@ -40483,7 +40912,7 @@ "multiple": false, "default": null, "options": [], - "description": "Use a project UUID; organization API keys may use a unique slug. Defaults to the linked or profile project.", + "description": "Use a project UUID; team keys may use a unique slug. Defaults to the linked or profile project.", "env": null, "helpGroup": null }, @@ -53041,8 +53470,8 @@ }, { "id": "session list", - "summary": "List session metadata for the team or one project. Requires an organization API key with sessions:read.", - "description": "List session metadata for the team or one project. Requires an organization API key with sessions:read.", + "summary": "List session metadata for the team or one project. Requires an team key with sessions:read.", + "description": "List session metadata for the team or one project. Requires an team key with sessions:read.", "usage": ["polymorfa session list [flags]"], "aliases": [], "hidden": false, @@ -53168,8 +53597,8 @@ }, { "id": "session status", - "summary": "Read the stored session status and reason. Requires an organization API key with sessions:read.", - "description": "Read the stored session status and reason. Requires an organization API key with sessions:read.", + "summary": "Read the stored session status and reason. Requires an team key with sessions:read.", + "description": "Read the stored session status and reason. Requires an team key with sessions:read.", "usage": ["polymorfa session status SESSION [flags]"], "aliases": [], "hidden": false, @@ -53963,8 +54392,8 @@ }, { "id": "session view", - "summary": "Read one session by its ID. Requires an organization API key with sessions:read.", - "description": "Read one session by its ID. Requires an organization API key with sessions:read.", + "summary": "Read one session by its ID. Requires an team key with sessions:read.", + "description": "Read one session by its ID. Requires an team key with sessions:read.", "usage": ["polymorfa session view SESSION [flags]"], "aliases": [], "hidden": false, diff --git a/docs/command-research/call-consent.md b/docs/command-research/call-consent.md index 1838411e..13ebb9ea 100644 --- a/docs/command-research/call-consent.md +++ b/docs/command-research/call-consent.md @@ -69,7 +69,7 @@ package metadata. ## Credential rules - `calls policy` and `calls opt-outs` resolve Platform credentials, so they - require an organization API key. The API answers a project token or client + require an team key. The API answers a project token or client token with `403 permission_denied`, which exits with code 3. - `calls permission get` and `calls check` require a server API key. The CLI refuses a Messaging client token before sending a request, because the API diff --git a/docs/command-research/calls.md b/docs/command-research/calls.md index 040d451e..9db67db6 100644 --- a/docs/command-research/calls.md +++ b/docs/command-research/calls.md @@ -125,10 +125,10 @@ internal SDK module. mode to satisfy the older validator. Other settings-only updates retain the typed SDK method. The dependency pin is unchanged, and the local type extensions remain until a matching SDK package is accepted. -- `calls stats` and `calls export` accept an organization API key or an +- `calls stats` and `calls export` accept a team key or an exact-project token (`sessions:read`), resolved like the developer commands. `--project` (or a project token, which requires it) reads one - project; an organization key without `--project` reads the whole + project; a team key without `--project` reads the whole organization. `--owner` still overrides the inferred owner. - `calls retention` resolves a team API key or a project token (`resolveTeamSettingEnvironment`), from the environment or a profile, with diff --git a/docs/command-research/customers.md b/docs/command-research/customers.md index 36148233..26faea55 100644 --- a/docs/command-research/customers.md +++ b/docs/command-research/customers.md @@ -1,7 +1,7 @@ # Customer command contract The Customer command family maps the complete public Customers surface in the -Platform contract. Every request uses an organization server API key and an +Platform contract. Every request uses an team key and an explicit project ID. | Command | Platform operation | diff --git a/docs/command-research/platform-management.md b/docs/command-research/platform-management.md index 7ee1b727..8fbbfed4 100644 --- a/docs/command-research/platform-management.md +++ b/docs/command-research/platform-management.md @@ -57,7 +57,7 @@ session ``` The management commands use the public typed organization resources on the -unified SDK `Client`. They require an organization server API key. Reads require +unified SDK `Client`. They require an team 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 @@ -85,7 +85,7 @@ without cursor semantics. `session-ban list --active` keeps one discovery command while selecting the 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. +an team key performed the acknowledgement. The original singular Platform operation view was removed. Operation inspection is console-only at API contract 1600403, and the pinned SDK has no diff --git a/docs/command-research/quicklinks.md b/docs/command-research/quicklinks.md index 6afffae7..ad119f98 100644 --- a/docs/command-research/quicklinks.md +++ b/docs/command-research/quicklinks.md @@ -21,16 +21,16 @@ on the hosted page and preserves entitlement boundaries around direct pairing. | `quicklink view` | `MessagingClient.quickLinks.retrieve` | `GET /messaging/quicklinks/{id}` | Read one link's status and onboarding progress | | `quicklink cancel` | `MessagingClient.quickLinks.cancel` | `DELETE /messaging/quicklinks/{id}` | Invalidate an unconnected link; initial Number/session cleanup can continue after acceptance | -All three routes require `quicklink:manage`. The API accepts organization keys +All three routes require `quicklink:manage`. The API accepts team 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 +(`widget view|update`). It uses an team key; project-token support for creation is not exposed by this command. -`quicklink view` and `quicklink cancel` accept an organization API key or an +`quicklink view` and `quicklink cancel` accept an team 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 diff --git a/docs/command-research/voice.md b/docs/command-research/voice.md index 1cb61ad9..3b398555 100644 --- a/docs/command-research/voice.md +++ b/docs/command-research/voice.md @@ -79,7 +79,7 @@ URL itself with no credential, no redirects, a bounded response, and a timeout. ## Credential rules -- The commands resolve Platform credentials: an organization server API key +- The commands resolve Platform credentials: an team key (`POLYMORFA_API_KEY` or an API-key profile). Client tokens fail before any request. The API also accepts project tokens; the CLI does not use them for these commands, matching `sip-trunk`. diff --git a/docs/development/bansafe-sdk-integration.md b/docs/development/bansafe-sdk-integration.md index 2dc9b2fc..c74543d2 100644 --- a/docs/development/bansafe-sdk-integration.md +++ b/docs/development/bansafe-sdk-integration.md @@ -64,7 +64,7 @@ dependency. The CLI mapping covers all 25 Platform BanSafe operations in the SDK coverage ledger. The SDK also covers the 10 equivalent Messaging settings routes through `MessagingClient.banSafe`; the CLI uses the Platform routes for those settings -because BanSafe commands authenticate with an organization server API key. +because BanSafe commands authenticate with an team key. | SDK operations | CLI commands | | ------------------------------------------------------------ | ---------------------------------------------------------- | diff --git a/docs/internal/2026-11-05-cli-account-device-source.md b/docs/internal/2026-11-05-cli-account-device-source.md new file mode 100644 index 00000000..5e6ff6ce --- /dev/null +++ b/docs/internal/2026-11-05-cli-account-device-source.md @@ -0,0 +1,110 @@ +# Account-device consumer source checkpoint + +LOCAL SOURCE / UNPUBLISHED. Existing owning PR: https://github.com/polymorfa/cli/pull/86. + +The candidate normally integrates actual CLI main +`b7ad48a3393f83b14058069f6948bfd9fb492c00` into the existing owning head +`233ec3b20425e6a69cd9b6caab49a29d39773926`. It adds seven separate `account` +commands: login, list, use, status, logout, teams and projects. Machine auth +commands, profile schema and credential types remain separate. Unknown or +account-session kinds fail before any machine SDK factory can coerce their +value. There is no account raw path, key mint, pairing, messaging or grant edit. + +## Producer and credential boundary + +The consumer binds the actual API880 LOCAL producer +`89e59833a9d7478a862e7b0b00c411e3ae22e689`. Its later identity-only successor +`389990d89728a633a14bd30ec678d5b5604e137a` retains the same API, CLI, grant, +Console, SQL, lock and runtime bodies. API880 owns CLI registration, session +classification, live grants, RLS, resource selection, approval notices and +server revocation. These local inputs do not establish hosted availability. + +Login pins the configured HTTPS API and Console origins and accepts only the +exact configured Console `/device` approval URL. The client ID is +`polymorfa-cli`; flags cannot replace it, the JWT audience or an endpoint. +The opaque session is keyring-only in a separate namespace. JWTs are memory-only +and verified with the pinned issuer's credentialless JWKS, Ed25519 signature, +audience, session/user binding, expiry and live session identity. Refresh does +not rotate the opaque session or extend its server lifetime. + +The only resource requests are paginated GET teams and GET projects with a +required team UUID. Current/future resource approval is not command authority. +Stored profile issuers cannot be overridden on later commands. The browser +artifact rejects all seven commands before environment, keyring or fetch access. + +Metadata publication has a separate revision guard. Late, stale or concurrent +results cannot overwrite another profile update. An uncertain save is read back; +an unavailable readback preserves the sole credential. Logout distinguishes +remote revocation from local metadata and keyring cleanup. Revocation of another +bound session is refused. No OS keyring, real account, provider or real Number +was exercised by these checks. + +## Actual dependency and checks + +A fresh frozen `npm ci --ignore-scripts --no-audit --no-fund` installed the exact +unchanged source manifest and lock, including the declared public SDK +`0.1.0-dev.20261004024706`. No source dependency pin, lock row or SDK body changed. +The earlier private SDK graph and all its receipts remain separate historical +inputs; the results below use the real public dependency. The earlier suspected +public-package blocker is closed for this candidate. + +All commands used Node 24.22.2. On the combined source and public graph: + +- All five discovered account-device files passed: 73 tests, zero failed, + skipped or todo. They cover ephemeral Ed25519 verification, intercepted exact + API payloads, owned filesystem/CAS behavior, cancellation, refresh races, + uncertain publication, machine-factory refusal and mounted commands. +- Full source and test typechecking, configured ESLint, project formatting and + the clean native build passed. +- The genuine compiled-command reference and its Node regression passed; + the unchanged-output check reported 388 public commands. +- The real browser producers and stock emitted-module verifier passed with + 389 command IDs and 461 emitted files. All seven account commands refused + before environment/fetch. Native keyring and account-session code were absent + from the browser graph. The existing message-send, global-fetch restoration + and agent-setup exclusion assertions remained intact. +- The actual package dry-run passed and the production dependency audit + reported zero vulnerabilities. + +The machine-credential golden fixture remains byte-identical by its stored Git +entry and unchanged filesystem metadata. Its body was not read, hashed, copied, +formatted or executed. The original formatter exclusion remains unchanged. +The complete credential-fixture runtime suite is UNRUN; the five selected files +and their actual 73 assertions are recorded without claiming that larger suite. + +## Integration and provenance + +The real current-main integration had one text conflict in the call research +guide. It retains main's simultaneous-call risk and SDK-limit guidance together +with the authored team-key terminology. The conflicted index and stages were +saved before resolution. All nonowned target identities are preserved. +README insertion was limited to the account section; reference and browser +registry outputs came from the real producers on the combined source. + +The exact type-only Campaign predecessor in `src/services/campaigns.ts` comes +from the original Campaign writer's checked LOCAL +`7a52263470b6c8563de17e436e128bf1ecc36e0b`. Its runtime call, query and renderer +are unchanged; author provenance and the inverse comparison remain in the +private source receipt. No Campaign authority or SDK release is inferred. + +Original source, indexes, refs, earlier setup graphs and checked postimages are +preserved under the existing task directory. The actual final local commit, +tree, selected-path equality and postcommit artifact identity are recorded in +the companion receipt, rather than embedding a self-referential commit here. + +## Six-part assessment and remaining gates + +| Part | Disposition | +| --------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| API | Consumer updated against actual LOCAL API880 source. No server, schema, role, grant, signed native wire or feature authority changed here. Hosted producer availability remains unqualified. | +| SDKs | The unchanged declared public package passes the actual local consumer checks. Account transport stays separate from machine SDK clients. No new SDK package or publication is required by this consumer delta. | +| CLI | Separate account metadata/keyring, seven commands, strict transport and machine refusal implemented and checked on the current-main composition. The complete protected credential-fixture runtime remains UNRUN. | +| Docs | README and genuine command reference are updated. Public-site release composition remains tied to the producer's actual release; no shipped changelog or availability claim was added. | +| Feature release | No flag, entitlement, enrollment, command grant, package/tag, deployment or production setting changed. Server registration and live grants remain authoritative. | +| Admin | No new staff state or action: API880 retains session/grant authority and existing revocation controls. No Admin credential, capability or bypass was introduced. | + +Current-head hosted CI, code/security reviews, CodeRabbit disposition and the +complete ready stack remain required before merge. No automated review was +requested. No push, workflow dispatch, rerun, stack edit, shared database, SSH, +provider or deployment action occurred. CI-triggering publication remains held +until this PR receives its serial queue turn. diff --git a/docs/opaque-credentials-cutover.md b/docs/opaque-credentials-cutover.md new file mode 100644 index 00000000..eae33ced --- /dev/null +++ b/docs/opaque-credentials-cutover.md @@ -0,0 +1,25 @@ +# Credential cutover acceptance + +API source: polymorfa/polymorfa #647 at f4a82c014e8ea8deb476fce4d81bf27591981c63. +SDK source: polymorfa/sdks #355. CLI credential validation continues to use the +public SDK; it adds no parser or exact-length validator. Tests use the public +20-byte CRC32 opaque-key fixture and check that invalid checksums fail before +secure-storage writes. + +The committed SDK dependency predates this contract. Before merging/releasing, +pin the exact accepted SDK registry version and update package-lock.json. A local +candidate tarball can prove behavior but cannot substitute for registry integrity, +matching environment acceptance or the release receipt required by RELEASING.md. +No guessed package version or permissive compatibility fallback is allowed. + +Test keys are ordinary project tokens. The CLI never reveals or creates a Test +key and never attempts to infer test-only authority from credential contents. + +The copied `test/fixtures/machine-credentials-v1.json` is byte-for-byte identical +to that API source, SHA-256 +`7694d8eb61dca6aed69f41392d2e5fae585084e330b276ee6a71844ef9e2365d`. +The CLI accepts the public opaque key vectors through the published SDK, without +adding a second parser. Current registry dependency +`0.1.0-dev.20261004024706` rejects these vectors as legacy-format credentials. +The SDKs thread owns #355. Its accepted registry artifact is the external gate; +this branch keeps the published dependency and does not publish packages. 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 b1b24a4e..1c88fdb9 100644 --- a/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md +++ b/docs/superpowers/specs/2026-08-19-polymorfa-cli-design.md @@ -136,7 +136,7 @@ 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 +media, team keys, members, audit logs, session bans, security 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 @@ -585,7 +585,7 @@ group ``` Number pairing uses a persistent hosted QuickLink. `quicklink create` accepts a -JSON request with an organization API key and returns the link URL, +JSON request with an team 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 @@ -814,7 +814,7 @@ bounded concurrency, and machine-readable partial-failure artifacts. The `flow` top-level command name remains reserved but is not shipped against this SDK baseline. The separate Platform template and Flow routes require -dashboard JWTs and reject organization keys. The CLI must not collect, persist, +dashboard JWTs and reject team keys. The CLI must not collect, persist, or replay dashboard session JWTs to bypass that boundary, and neither MCP tools nor skills may expose those routes. Platform template management and Flow commands can enter the implementation plan only after the Platform API provides @@ -1079,8 +1079,8 @@ slices: 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; +- persistent QuickLink create with team keys, view/cancel with + team 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; diff --git a/package.json b/package.json index a0f048b0..2cb03b57 100644 --- a/package.json +++ b/package.json @@ -46,6 +46,9 @@ "commands": "./dist/commands", "topicSeparator": " ", "topics": { + "account": { + "description": "Sign in with browser approval and inspect allowed account resources." + }, "audience": { "description": "Manage campaign audiences and their members." }, @@ -167,7 +170,7 @@ "description": "Resolve phone numbers and usernames to Polymorfa user IDs." }, "key": { - "description": "Inspect and revoke organization API keys." + "description": "Inspect and revoke team keys." }, "label": { "description": "Manage Messaging labels and chat assignments." diff --git a/scripts/verify-browser.mjs b/scripts/verify-browser.mjs index 500db3e7..da6bc197 100644 --- a/scripts/verify-browser.mjs +++ b/scripts/verify-browser.mjs @@ -32,6 +32,14 @@ for (const local of [ !inputs.some((path) => path.endsWith(local)), local + ' was bundled', ) +assert.ok( + !inputs.some( + (path) => + path.startsWith('src/account-device/') || + path.startsWith('src/commands/account/'), + ), + 'Local account-session implementation was bundled', +) for (const nativePackage of [ 'node_modules/@napi-rs/keyring/', 'node_modules/@modelcontextprotocol/server/', @@ -82,6 +90,39 @@ assert.ok( ), ) assert.equal(globalThis.fetch, beforeFetch) +for (const command of [ + 'login', + 'list', + 'use', + 'status', + 'logout', + 'teams', + 'projects', +]) { + const id = `account ${command}` + assert.ok(actual.COMMAND_IDS.includes(id)) + const output = [] + const result = await actual.runCommand(['account', command], { + get env() { + throw new Error( + 'account refusal touched caller credentials/configuration', + ) + }, + get fetch() { + throw new Error('account refusal touched fetch') + }, + write: (stream, text) => output.push({stream, text}), + }) + assert.deepEqual(result, {exitCode: 1, command: id}) + assert.ok( + output.some( + (entry) => + entry.stream === 'stderr' && + entry.text.includes('operating-system keyring'), + ), + ) +} +assert.equal(globalThis.fetch, beforeFetch) const requests = [] const output = [] const sent = await actual.runCommand( @@ -97,7 +138,7 @@ const sent = await actual.runCommand( 'Hello from Polymorfa', ], { - env: {POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_' + 'fixture'.repeat(6)}, + env: {POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_' + 'A'.repeat(43)}, fetch: async (input, init = {}) => { requests.push({ path: new URL(String(input)).pathname, @@ -134,6 +175,7 @@ console.log( commands: actual.COMMAND_IDS.length, localInstallerBundled: false, agentSetup: 'refused before environment/fetch', + accountDevice: 'all seven commands refused before environment/fetch', send: {method: requests[0].method, path: requests[0].path}, files: manifest.files, bytes: manifest.bytes, diff --git a/skills/polymorfa/SKILL.md b/skills/polymorfa/SKILL.md index bdd25c97..c923f9b3 100644 --- a/skills/polymorfa/SKILL.md +++ b/skills/polymorfa/SKILL.md @@ -14,11 +14,11 @@ Use `polymorfa --help` and `polymorfa --help` as the installed command a 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. +Team 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. Voice Automation commands (`polymorfa voice audio`, `polymorfa voice credential`) are a beta for enrolled teams; `voice_not_enabled` (exit 6) means the team is not enrolled and `gate_limit_reached` (exit 7) means a voice limit is reached, so do not retry either. Pass a provider key to `polymorfa voice credential add` only through standard input or the environment variable named by `--key-env`, never as an argument. -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. +Use `polymorfa quicklink create --project PROJECT --input request.json` with an team 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 diff --git a/src/account-device/command.ts b/src/account-device/command.ts new file mode 100644 index 00000000..bb9e380b --- /dev/null +++ b/src/account-device/command.ts @@ -0,0 +1,88 @@ +import {Command, Flags} from '@oclif/core' +import {setTimeout} from 'node:timers/promises' + +import {AccountDeviceLoginError} from './service.js' +import {AccountDeviceStorageError} from './storage.js' +import {AccountDeviceError} from './transport.js' + +interface AccountFlags { + readonly json?: boolean | undefined + readonly profile?: string | undefined + readonly timeout?: number | undefined +} + +// This command family has no machine environment resolver, API-version/owner +// override, raw route, credential argument or SDK client factory. +export abstract class AccountDeviceCommand extends Command { + static override baseFlags = { + json: Flags.boolean({description: 'Print one JSON result.'}), + profile: Flags.string({ + description: + 'Select an account-device profile, separate from machine profiles.', + }), + timeout: Flags.integer({ + min: 1, + description: 'Cancel this operation after this many milliseconds.', + }), + } + + protected async accountOperation( + flags: AccountFlags, + operation: (signal: AbortSignal) => Promise, + ): Promise { + const canceled = new AbortController() + const interrupt = () => canceled.abort() + process.once('SIGINT', interrupt) + process.once('SIGTERM', interrupt) + const signal = + flags.timeout === undefined + ? canceled.signal + : AbortSignal.any([canceled.signal, AbortSignal.timeout(flags.timeout)]) + try { + return await operation(signal) + } catch (error) { + const known = + error instanceof AccountDeviceError || + error instanceof AccountDeviceStorageError || + error instanceof AccountDeviceLoginError + const message = known + ? error.message + : 'The account-device operation could not be completed.' + if (flags.json) { + process.stderr.write( + `${JSON.stringify({ + error: { + code: + error instanceof AccountDeviceError || + error instanceof AccountDeviceStorageError + ? error.code + : error instanceof AccountDeviceLoginError + ? 'login_not_confirmed' + : 'account_device_failed', + message, + ...(error instanceof AccountDeviceLoginError + ? {recovery: error.recovery} + : {}), + }, + })}\n`, + ) + this.exit(1) + } + this.error(message, {exit: 1}) + } finally { + process.removeListener('SIGINT', interrupt) + process.removeListener('SIGTERM', interrupt) + } + } + + protected accountResult(value: unknown, flags: AccountFlags): void { + this.log(JSON.stringify(value, null, flags.json ? undefined : 2)) + } +} + +export async function waitForAccountDevicePoll( + milliseconds: number, + signal: AbortSignal, +): Promise { + await setTimeout(milliseconds, undefined, {signal}) +} diff --git a/src/account-device/factory.ts b/src/account-device/factory.ts new file mode 100644 index 00000000..6b15c0ae --- /dev/null +++ b/src/account-device/factory.ts @@ -0,0 +1,12 @@ +import {AccountDeviceAuthService} from './service.js' +import { + AccountDeviceProfileRepository, + SecureAccountDeviceCredentials, +} from './storage.js' + +export function createAccountDeviceAuthService(): AccountDeviceAuthService { + return new AccountDeviceAuthService( + new AccountDeviceProfileRepository(), + new SecureAccountDeviceCredentials(), + ) +} diff --git a/src/account-device/service.ts b/src/account-device/service.ts new file mode 100644 index 00000000..01c8b886 --- /dev/null +++ b/src/account-device/service.ts @@ -0,0 +1,478 @@ +import { + AccountDeviceApi, + AccountDeviceError, + type AccountDeviceConfiguration, + type AccountDeviceJwt, + type AccountDevicePage, +} from './transport.js' +import { + AccountDeviceStorageError, + accountDeviceCredentialAccount, + type AccountDeviceCredentials, + type AccountDeviceProfile, + type AccountDeviceProfiles, + type AccountDeviceProfilesLike, +} from './storage.js' + +export interface AccountDeviceLogin { + readonly profile: string + readonly userCode: string + readonly verificationUri: string +} +interface Pending { + readonly api: AccountDeviceApi + readonly code: string + readonly config: AccountDeviceProfiles + readonly epoch: symbol + readonly deadline: number + nextPoll: number + interval: number + busy: boolean +} +interface Binding { + readonly name: string + readonly config: AccountDeviceProfiles + readonly profile: AccountDeviceProfile + readonly api: AccountDeviceApi +} + +export class AccountDeviceLoginError extends Error { + constructor( + readonly recovery: { + readonly profileSaved: false | 'unknown' + readonly credentialPreserved: boolean + readonly remoteRevocation: 'confirmed' | 'unknown' | 'not_exercised' + }, + ) { + super( + recovery.profileSaved === 'unknown' + ? 'The account profile update could not be confirmed. Its credential was preserved.' + : recovery.remoteRevocation === 'confirmed' + ? 'Sign-in was not saved. The new account session was revoked.' + : 'Sign-in was not saved. Revocation of the new account session could not be confirmed.', + ) + this.name = 'AccountDeviceLoginError' + } +} + +// Machine profiles, SDK credential factories, arbitrary raw routes and account +// grant mutation are deliberately absent. Every resource read still reaches +// the API's live user/session/client/grant checks; a verified JWT is no grant. +export class AccountDeviceAuthService { + readonly #pending = new WeakMap() + readonly #epochs = new Map() + readonly #cached = new Map< + string, + {revision: string | null; jwt: AccountDeviceJwt} + >() + readonly #refreshing = new Map>() + + constructor( + private readonly profiles: AccountDeviceProfilesLike, + private readonly credentials: AccountDeviceCredentials, + private readonly api: ( + configuration: AccountDeviceConfiguration, + ) => AccountDeviceApi = (configuration) => + new AccountDeviceApi(configuration), + private readonly monotonic: () => number = () => performance.now(), + private readonly now: () => number = Date.now, + ) {} + + async startLogin( + profile: string, + configuration: AccountDeviceConfiguration, + signal?: AbortSignal, + ): Promise { + // Validate before any network or keyring work. Existing profiles require + // explicit logout; a login cannot silently discard a prior remote session. + accountDeviceCredentialAccount(profile) + const config = await this.profiles.load() + if (Object.hasOwn(config.profiles, profile)) + throw new AccountDeviceError('refused') + const epoch = this.invalidate(profile), + api = this.api(configuration), + started = this.monotonic() + checkSignal(signal) + const issued = await api.start(signal) + checkSignal(signal) + const login = Object.freeze({ + profile, + userCode: issued.userCode, + verificationUri: issued.verificationUri, + }) + this.#pending.set(login, { + api, + code: issued.deviceCode, + config, + epoch, + deadline: started + issued.expiresInSeconds * 1000, + nextPoll: this.monotonic() + issued.intervalSeconds * 1000, + interval: issued.intervalSeconds * 1000, + busy: false, + }) + return login + } + + pollDelay(login: AccountDeviceLogin): number { + const pending = this.pending(login) + return Math.max(0, pending.nextPoll - this.monotonic()) + } + + async pollLogin(login: AccountDeviceLogin, signal?: AbortSignal) { + const pending = this.pending(login) + checkSignal(signal) + if (pending.busy || this.monotonic() < pending.nextPoll) + throw new AccountDeviceError('refused') + pending.busy = true + const started = this.monotonic() + let token: string | undefined + try { + const result = await pending.api.poll(pending.code, signal) + if (result.kind === 'session') token = result.sessionToken + this.pending(login) + checkSignal(signal) + if (result.kind !== 'session') { + if (result.kind === 'slowDown') pending.interval += 5000 + pending.nextPoll = started + pending.interval + return { + kind: 'pending', + waitMilliseconds: this.pollDelay(login), + } as const + } + token = result.sessionToken + const identity = await pending.api.identity(token, signal) + const jwt = await pending.api.jwt(token, identity, signal) + this.pending(login) + checkSignal(signal) + if (token === identity.userId || token === identity.sessionId) + throw new AccountDeviceError('invalid_response') + const account = accountDeviceCredentialAccount(login.profile) + const profile: AccountDeviceProfile = { + kind: 'accountDevice', + ...pending.api.configuration, + ...identity, + credentialAccount: account, + } + let stored: AccountDeviceProfiles + let cleanupConfirmed = true + try { + await this.credentials.set(account, token) + stored = await this.profiles.saveIfRevision( + pending.config.revision, + { + version: 1, + activeProfile: login.profile, + profiles: {...pending.config.profiles, [login.profile]: profile}, + }, + () => { + this.pending(login) + checkSignal(signal) + }, + ) + } catch { + let actual: AccountDeviceProfiles + try { + actual = await this.profiles.load() + } catch { + // Save may have renamed successfully before cleanup failed. Retain + // the only credential; absence of a return is never rollback proof. + throw new AccountDeviceLoginError({ + profileSaved: 'unknown', + credentialPreserved: true, + remoteRevocation: 'not_exercised', + }) + } + if (actual.profiles[login.profile]?.credentialAccount === account) { + stored = actual + cleanupConfirmed = false + } else { + let credentialPreserved = false + try { + await this.credentials.delete(account) + } catch { + credentialPreserved = true + } + throw new AccountDeviceLoginError({ + profileSaved: false, + credentialPreserved, + remoteRevocation: await revokeFresh(pending.api, token), + }) + } + } + this.#pending.delete(login) + if (this.#epochs.get(login.profile) === pending.epoch) + this.#cached.set(login.profile, {revision: stored.revision, jwt}) + return { + kind: 'signedIn', + profile: login.profile, + issuer: profile.issuer, + userId: profile.userId, + sessionId: profile.sessionId, + expiresAtMs: profile.expiresAtMs, + remoteIdentityValidated: true, + localCleanupConfirmed: cleanupConfirmed, + } as const + } catch (error) { + this.#pending.delete(login) + if (error instanceof AccountDeviceLoginError) throw error + if (token !== undefined) + throw new AccountDeviceLoginError({ + profileSaved: false, + credentialPreserved: false, + remoteRevocation: await revokeFresh(pending.api, token), + }) + throw error + } finally { + pending.busy = false + } + } + + cancelLogin(login: AccountDeviceLogin): void { + this.#pending.delete(login) + this.invalidate(login.profile) + } + + async list() { + const config = await this.profiles.load() + return Object.entries(config.profiles).map(([name, profile]) => ({ + name, + active: config.activeProfile === name, + kind: 'accountDevice' as const, + issuer: profile.issuer, + userId: profile.userId, + sessionId: profile.sessionId, + expiresAtMs: profile.expiresAtMs, + })) + } + + async use(name: string): Promise { + const binding = await this.binding(name) + this.invalidate(name) + await this.profiles.saveIfRevision(binding.config.revision, { + version: 1, + activeProfile: name, + profiles: binding.config.profiles, + }) + } + + async status(name?: string, signal?: AbortSignal) { + const binding = await this.binding(name), + token = await this.credentials.get(binding.profile.credentialAccount) + if (token === undefined) + return { + profile: binding.name, + kind: 'accountDevice', + credentialPresent: false, + remoteIdentityValidated: false, + } as const + const live = await binding.api.identity(token, signal) + assertIdentity(binding.profile, live) + return { + profile: binding.name, + kind: 'accountDevice', + credentialPresent: true, + remoteIdentityValidated: true, + ...live, + } as const + } + + async teams( + name?: string, + page: AccountDevicePage = {}, + signal?: AbortSignal, + ) { + const binding = await this.binding(name), + jwt = await this.jwt(binding, signal) + try { + return await binding.api.teams(jwt, page, signal) + } catch (error) { + this.#cached.delete(binding.name) + throw error + } + } + async projects( + teamId: string, + name?: string, + page: AccountDevicePage = {}, + signal?: AbortSignal, + ) { + const binding = await this.binding(name), + jwt = await this.jwt(binding, signal) + try { + return await binding.api.projects(jwt, teamId, page, signal) + } catch (error) { + this.#cached.delete(binding.name) + throw error + } + } + + async logout(name?: string, signal?: AbortSignal) { + if (name !== undefined) this.invalidate(name) + const binding = await this.binding(name) + this.invalidate(binding.name) + const token = await this.credentials.get(binding.profile.credentialAccount) + let remoteRevocation: 'confirmed' | 'unknown' | 'not_exercised' = + 'not_exercised' + if (token !== undefined) { + try { + try { + assertIdentity( + binding.profile, + await binding.api.identity(token, signal), + ) + } catch (error) { + // Only absent/expired credentials may use the idempotent sign-out. + // A bound identity mismatch must never revoke another device. + if (!( + error instanceof AccountDeviceError && + error.code === 'login_required' + )) + throw error + } + await binding.api.signOut(token, signal) + remoteRevocation = 'confirmed' + } catch { + remoteRevocation = 'unknown' + } + } + const current = await this.profiles.load(), + profile = current.profiles[binding.name] + if (profile?.credentialAccount !== binding.profile.credentialAccount) + return { + profile: binding.name, + remoteRevocation, + localMetadataRemoved: false, + localCredentialRemoved: false, + } as const + const profiles = {...current.profiles} + delete profiles[binding.name] + let metadataRemoved = false + try { + await this.profiles.saveIfRevision(current.revision, { + version: 1, + profiles, + ...(current.activeProfile !== undefined && + current.activeProfile !== binding.name + ? {activeProfile: current.activeProfile} + : {}), + }) + metadataRemoved = true + } catch { + try { + metadataRemoved = !Object.hasOwn( + (await this.profiles.load()).profiles, + binding.name, + ) + } catch { + /* Unknown publication: preserve the credential. */ + } + } + let credentialRemoved = token === undefined + if (metadataRemoved) + try { + await this.credentials.delete(binding.profile.credentialAccount) + credentialRemoved = true + } catch { + credentialRemoved = false + } + return { + profile: binding.name, + remoteRevocation, + localMetadataRemoved: metadataRemoved, + localCredentialRemoved: credentialRemoved, + } as const + } + + private pending(login: AccountDeviceLogin): Pending { + const pending = this.#pending.get(login) + if ( + !pending || + this.#epochs.get(login.profile) !== pending.epoch || + this.monotonic() >= pending.deadline + ) + throw new AccountDeviceError('login_required') + return pending + } + private invalidate(name: string): symbol { + const epoch = Symbol() + this.#epochs.set(name, epoch) + this.#cached.delete(name) + return epoch + } + private async binding(name?: string): Promise { + const config = await this.profiles.load(), + selected = name ?? config.activeProfile + if (selected === undefined || !Object.hasOwn(config.profiles, selected)) + throw new AccountDeviceError('login_required') + const profile = config.profiles[selected]! + return {name: selected, config, profile, api: this.api(profile)} + } + private async jwt( + binding: Binding, + signal?: AbortSignal, + ): Promise { + checkSignal(signal) + const cached = this.#cached.get(binding.name) + if ( + cached?.revision === binding.config.revision && + cached.jwt.expiresAtMs > this.now() + ) + return cached.jwt + const key = `${binding.name}:${binding.config.revision ?? ''}` + const existing = this.#refreshing.get(key) + if (existing) return existing + const epoch = + this.#epochs.get(binding.name) ?? this.invalidate(binding.name) + const refresh = (async () => { + const token = await this.credentials.get( + binding.profile.credentialAccount, + ) + if (token === undefined) throw new AccountDeviceError('login_required') + const jwt = await binding.api.jwt(token, binding.profile, signal) + checkSignal(signal) + const current = await this.profiles.load() + if ( + this.#epochs.get(binding.name) !== epoch || + current.revision !== binding.config.revision || + current.profiles[binding.name]?.credentialAccount !== + binding.profile.credentialAccount + ) + throw new AccountDeviceStorageError('changed') + this.#cached.set(binding.name, {revision: current.revision, jwt}) + return jwt + })() + this.#refreshing.set(key, refresh) + try { + return await refresh + } finally { + if (this.#refreshing.get(key) === refresh) this.#refreshing.delete(key) + } + } +} + +function checkSignal(signal?: AbortSignal) { + if (signal?.aborted) throw new AccountDeviceError('refused') +} +function assertIdentity( + expected: AccountDeviceProfile, + actual: {sessionId: string; userId: string}, +) { + if ( + expected.sessionId !== actual.sessionId || + expected.userId !== actual.userId + ) + throw new AccountDeviceStorageError('changed') +} +async function revokeFresh( + api: AccountDeviceApi, + token: string, +): Promise<'confirmed' | 'unknown'> { + // Cleanup is the same pinned issuer's own-session operation, never a grant, + // retry, profile fallback or extension of the canceled authorization request. + try { + await api.signOut(token, AbortSignal.timeout(5000)) + return 'confirmed' + } catch { + return 'unknown' + } +} diff --git a/src/account-device/storage.ts b/src/account-device/storage.ts new file mode 100644 index 00000000..1136277c --- /dev/null +++ b/src/account-device/storage.ts @@ -0,0 +1,324 @@ +import {AsyncEntry} from '@napi-rs/keyring' +import {randomUUID} from 'node:crypto' +import { + chmod, + type FileHandle, + mkdir, + open, + readFile, + rename, + unlink, + writeFile, +} from 'node:fs/promises' +import {dirname, join} from 'node:path' + +import type {KeyringEntryFactory} from '../config/credentials.js' +import {defaultConfigDirectory} from '../config/paths.js' +import {AccountDeviceApi, type AccountDeviceEnvironment} from './transport.js' + +const KEYRING_SERVICE = 'com.polymorfa.cli.account-device-sessions' +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/ +const NAME = /^[a-zA-Z0-9][a-zA-Z0-9._-]{0,63}$/ + +export class AccountDeviceStorageError extends Error { + constructor(readonly code: 'busy' | 'changed' | 'invalid' | 'keyring') { + super( + { + busy: 'Account-device profiles are being updated. No profile was changed.', + changed: + 'The account-device profile changed while this operation was in progress.', + invalid: 'The account-device profile has an invalid shape.', + keyring: + 'Account-device secure storage is unavailable. No other credential was used.', + }[code], + ) + this.name = 'AccountDeviceStorageError' + } +} + +export interface AccountDeviceProfile extends Omit< + AccountDeviceEnvironment, + 'surface' +> { + readonly kind: 'accountDevice' + readonly teamId?: string + readonly projectId?: string +} +export interface AccountDeviceProfiles { + readonly version: 1 + readonly revision: string | null + readonly activeProfile?: string + readonly profiles: Readonly> +} +export interface AccountDeviceProfilesLike { + load(): Promise + saveIfRevision( + expected: string | null, + next: Omit, + guard?: () => void, + ): Promise +} +export interface AccountDeviceCredentials { + get(account: string): Promise + set(account: string, token: string): Promise + delete(account: string): Promise +} + +export function accountDeviceCredentialAccount(profile: string): string { + if (!NAME.test(profile)) throw new AccountDeviceStorageError('invalid') + return `account-device:${profile}:${randomUUID()}` +} + +function reference(value: string): void { + const parts = value.split(':') + if ( + parts.length !== 3 || + parts[0] !== 'account-device' || + !parts[1] || + !NAME.test(parts[1]) || + !parts[2] || + !UUID.test(parts[2]) + ) + throw new AccountDeviceStorageError('invalid') +} + +export class SecureAccountDeviceCredentials implements AccountDeviceCredentials { + constructor( + private readonly entry: KeyringEntryFactory = (service, account) => + new AsyncEntry(service, account), + ) {} + + async get(account: string): Promise { + reference(account) + try { + const value = await this.entry(KEYRING_SERVICE, account).getPassword() + if (value == null) return undefined + if ( + !value || + value.length > 16_384 || + [...value].some( + (char) => char.charCodeAt(0) < 33 || char.charCodeAt(0) > 126, + ) + ) + throw new AccountDeviceStorageError('keyring') + return value + } catch { + throw new AccountDeviceStorageError('keyring') + } + } + async set(account: string, token: string): Promise { + reference(account) + if ( + !token || + token.length > 16_384 || + [...token].some( + (char) => char.charCodeAt(0) < 33 || char.charCodeAt(0) > 126, + ) + ) + throw new AccountDeviceStorageError('keyring') + try { + await this.entry(KEYRING_SERVICE, account).setPassword(token) + } catch { + throw new AccountDeviceStorageError('keyring') + } + } + async delete(account: string): Promise { + reference(account) + try { + await this.entry(KEYRING_SERVICE, account).deletePassword() + } catch { + throw new AccountDeviceStorageError('keyring') + } + } +} + +// Separate file and keyring namespace: machine config v2 and active machine +// profiles never acquire an account-session kind or fall back to this file. +export class AccountDeviceProfileRepository implements AccountDeviceProfilesLike { + constructor( + readonly path = join(defaultConfigDirectory(), 'account-device.json'), + ) {} + + async load(): Promise { + let value: unknown + try { + value = JSON.parse(await readFile(this.path, 'utf8')) as unknown + } catch (error) { + if (error instanceof Error && 'code' in error && error.code === 'ENOENT') + return {version: 1, revision: null, profiles: {}} + throw new AccountDeviceStorageError('invalid') + } + return config(value) + } + + // Cross-process metadata CAS uses a short exclusive file, never a network or + // browser wait. Busy/stale ownership is refused; no PID scan or automatic + // lock reclamation can release another process's update. Atomic rename alone + // would lose a newer login/logout or another profile's selection. + async saveIfRevision( + expected: string | null, + next: Omit, + guard?: () => void, + ): Promise { + const directory = dirname(this.path) + const made = await mkdir(directory, {recursive: true, mode: 0o700}) + if (made !== undefined) await chmod(made, 0o700) + const lockPath = `${this.path}.lock` + let lock + try { + lock = await open(lockPath, 'wx', 0o600) + } catch { + throw new AccountDeviceStorageError('busy') + } + let owned: {dev: number | bigint; ino: number | bigint} | undefined + try { + owned = await lock.stat() + const current = await this.load() + if (current.revision !== expected) + throw new AccountDeviceStorageError('changed') + // Final local publication checkpoint under the actual metadata writer. + // Cancellation after this point can leave a published profile; callers + // must read back uncertain outcomes, never infer that a save rolled back. + guard?.() + const value = config({...next, revision: randomUUID()}) + const temporary = join(directory, `.account-device-${randomUUID()}.tmp`) + try { + await writeFile(temporary, `${JSON.stringify(value, null, 2)}\n`, { + mode: 0o600, + flag: 'wx', + }) + await rename(temporary, this.path) + await chmod(this.path, 0o600) + } finally { + await unlink(temporary).catch((error) => { + if (!( + error instanceof Error && + 'code' in error && + error.code === 'ENOENT' + )) + throw error + }) + } + return value + } finally { + // Refuse an observed replacement. Cooperating writers never reclaim or + // replace a live lock. This is not protection against arbitrary filesystem + // mutation by another program with the same OS user's privileges. + await releaseMetadataLock(lockPath, lock, owned) + } + } +} + +async function releaseMetadataLock( + path: string, + lock: FileHandle, + owned: {dev: number | bigint; ino: number | bigint} | undefined, +): Promise { + try { + if (owned === undefined) throw new AccountDeviceStorageError('busy') + const current = await open(path, 'r') + try { + const state = await current.stat() + if (state.dev !== owned.dev || state.ino !== owned.ino) + throw new AccountDeviceStorageError('busy') + await unlink(path) + } finally { + await current.close() + } + } finally { + await lock.close() + } +} + +function object(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} +function config(value: unknown): AccountDeviceProfiles { + if ( + !object(value) || + value.version !== 1 || + typeof value.revision !== 'string' || + !UUID.test(value.revision) || + !object(value.profiles) || + Object.keys(value).some( + (key) => + !['version', 'revision', 'activeProfile', 'profiles'].includes(key), + ) + ) + throw new AccountDeviceStorageError('invalid') + const profiles: Record = {} + for (const [name, entry] of Object.entries(value.profiles)) { + if (!NAME.test(name) || !profile(entry, name)) + throw new AccountDeviceStorageError('invalid') + profiles[name] = Object.freeze({...entry}) + } + if ( + value.activeProfile !== undefined && + (typeof value.activeProfile !== 'string' || + !Object.hasOwn(profiles, value.activeProfile)) + ) + throw new AccountDeviceStorageError('invalid') + return Object.freeze({ + version: 1 as const, + revision: value.revision, + profiles: Object.freeze(profiles), + ...(value.activeProfile === undefined + ? {} + : {activeProfile: value.activeProfile}), + }) +} +function profile(value: unknown, name: string): value is AccountDeviceProfile { + if ( + !object(value) || + value.kind !== 'accountDevice' || + Object.keys(value).some( + (key) => + ![ + 'kind', + 'issuer', + 'consoleOrigin', + 'userId', + 'sessionId', + 'expiresAtMs', + 'credentialAccount', + 'teamId', + 'projectId', + ].includes(key), + ) || + typeof value.issuer !== 'string' || + typeof value.consoleOrigin !== 'string' || + typeof value.userId !== 'string' || + !value.userId || + typeof value.sessionId !== 'string' || + !value.sessionId || + typeof value.expiresAtMs !== 'number' || + !Number.isSafeInteger(value.expiresAtMs) || + value.expiresAtMs <= 0 || + typeof value.credentialAccount !== 'string' + ) + return false + try { + reference(value.credentialAccount) + if (!value.credentialAccount.startsWith(`account-device:${name}:`)) + return false + const parsed = new AccountDeviceApi({ + issuer: value.issuer, + consoleOrigin: value.consoleOrigin, + }).configuration + if ( + parsed.issuer !== value.issuer || + parsed.consoleOrigin !== value.consoleOrigin + ) + return false + } catch { + return false + } + return ( + (value.teamId === undefined || + (typeof value.teamId === 'string' && UUID.test(value.teamId))) && + (value.projectId === undefined || + (typeof value.projectId === 'string' && + UUID.test(value.projectId) && + value.teamId !== undefined)) + ) +} diff --git a/src/account-device/transport.ts b/src/account-device/transport.ts new file mode 100644 index 00000000..ebeb4b54 --- /dev/null +++ b/src/account-device/transport.ts @@ -0,0 +1,624 @@ +// Private account-device transport. This is not a Messaging/Platform SDK +// credential and accepts no caller-selected raw path, header or HTTP method. +export const ACCOUNT_DEVICE_CLIENT_ID = 'polymorfa-cli' +const AUDIENCE = 'polymorfa-dashboard-api' +const UUID = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i +const MAX_BODY = 1024 * 1024 + +export class AccountDeviceError extends Error { + constructor( + readonly code: + | 'authority_unavailable' + | 'invalid_response' + | 'login_required' + | 'refused', + ) { + super( + { + authority_unavailable: + 'Account access could not be checked. Try again shortly.', + invalid_response: 'The account server returned an invalid response.', + login_required: 'This account session requires browser sign-in.', + refused: 'This account operation was refused.', + }[code], + ) + this.name = 'AccountDeviceError' + } +} + +export interface AccountDeviceConfiguration { + readonly issuer: string + readonly consoleOrigin: string +} + +export interface AccountDeviceIdentity { + readonly userId: string + readonly sessionId: string + readonly expiresAtMs: number +} + +export interface AccountDeviceEnvironment extends AccountDeviceIdentity { + readonly surface: 'accountDevice' + readonly issuer: string + readonly consoleOrigin: string + readonly credentialAccount: string +} + +export interface AccountDevicePage { + readonly after?: string + readonly limit?: number +} + +export interface AccountDeviceTeam { + readonly teamId: string + readonly teamName: string + readonly teamSlug: string +} + +export interface AccountDeviceProject { + readonly projectId: string + readonly name: string + readonly slug: string + readonly role: 'admin' | 'member' | 'owner' +} + +export interface AccountDeviceJwt extends AccountDeviceIdentity { + readonly issuer: string +} + +// Tokens stay in memory and cannot be reconstructed by spreading or parsing a +// JSON object. A saved profile contains a keyring reference, never this token. +const verifiedTokens = new WeakMap() + +type Path = + | '/api/auth/device/code' + | '/api/auth/device/token' + | '/api/auth/get-session' + | '/api/auth/token' + | '/api/auth/sign-out' + | '/api/auth/jwks' + | '/api/account/device/teams' + | '/api/account/device/projects' + +export class AccountDeviceApi { + readonly #configuration: AccountDeviceConfiguration + readonly #request: typeof fetch + readonly #now: () => number + + constructor( + configuration: AccountDeviceConfiguration, + request: typeof fetch = globalThis.fetch, + now: () => number = Date.now, + ) { + this.#request = request + this.#now = now + this.#configuration = Object.freeze({ + issuer: httpsOrigin(configuration.issuer), + consoleOrigin: httpsOrigin(configuration.consoleOrigin), + }) + } + + get configuration(): AccountDeviceConfiguration { + return this.#configuration + } + + async start(signal?: AbortSignal) { + const response = await this.#send( + '/api/auth/device/code', + 'POST', + undefined, + {client_id: ACCOUNT_DEVICE_CLIENT_ID}, + undefined, + signal, + ) + const value = success(response) + const deviceCode = secret(value.device_code) + if ( + !text(value.user_code) || + !integer(value.interval, 1, 300) || + !integer(value.expires_in, 1, 300) || + value.interval > value.expires_in || + value.verification_uri !== `${this.configuration.consoleOrigin}/device` + ) { + throw new AccountDeviceError('invalid_response') + } + return { + deviceCode, + userCode: value.user_code, + verificationUri: value.verification_uri, + intervalSeconds: value.interval, + expiresInSeconds: value.expires_in, + } + } + + async poll(deviceCode: string, signal?: AbortSignal) { + const response = await this.#send( + '/api/auth/device/token', + 'POST', + undefined, + { + client_id: ACCOUNT_DEVICE_CLIENT_ID, + device_code: secret(deviceCode), + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }, + undefined, + signal, + ) + if (response.status === 400 && object(response.value)) { + if (response.value.error === 'authorization_pending') + return {kind: 'pending'} as const + if (response.value.error === 'slow_down') + return {kind: 'slowDown'} as const + } + const value = success(response) + if ( + typeof value.token_type !== 'string' || + value.token_type.toLowerCase() !== 'bearer' || + !integer(value.expires_in, 1, Number.MAX_SAFE_INTEGER) + ) + throw new AccountDeviceError('invalid_response') + return {kind: 'session', sessionToken: secret(value.access_token)} as const + } + + async identity( + sessionToken: string, + signal?: AbortSignal, + ): Promise { + const response = await this.#send( + '/api/auth/get-session', + 'GET', + secret(sessionToken), + undefined, + undefined, + signal, + ) + const value = success(response) + if ( + !object(value.session) || + !object(value.user) || + !text(value.session.id) || + !text(value.user.id) || + value.session.userId !== value.user.id || + typeof value.session.expiresAt !== 'string' + ) + throw new AccountDeviceError('login_required') + const expiresAtMs = Date.parse(value.session.expiresAt) + if (!Number.isFinite(expiresAtMs) || expiresAtMs <= this.#now()) + throw new AccountDeviceError('login_required') + // Raw session/user objects can include token or unrelated account fields. + return Object.freeze({ + sessionId: value.session.id, + userId: value.user.id, + expiresAtMs, + }) + } + + async jwt( + sessionToken: string, + expected: AccountDeviceIdentity, + signal?: AbortSignal, + ): Promise { + const live = await this.identity(sessionToken, signal) + if ( + live.sessionId !== expected.sessionId || + live.userId !== expected.userId + ) + throw new AccountDeviceError('login_required') + const response = await this.#send( + '/api/auth/token', + 'GET', + secret(sessionToken), + undefined, + undefined, + signal, + ) + const value = success(response) + const token = secret(value.token) + const jwks = success( + await this.#send( + '/api/auth/jwks', + 'GET', + undefined, + undefined, + undefined, + signal, + ), + ) + const expiresAtMs = await verifyJwt( + token, + jwks, + this.configuration.issuer, + live, + this.#now(), + ) + if (signal?.aborted) throw new AccountDeviceError('refused') + if (expiresAtMs <= this.#now()) + throw new AccountDeviceError('login_required') + const jwt = Object.freeze({ + ...live, + issuer: this.configuration.issuer, + expiresAtMs, + }) + verifiedTokens.set(jwt, token) + return jwt + } + + async teams( + jwt: AccountDeviceJwt, + page: AccountDevicePage = {}, + signal?: AbortSignal, + ) { + const value = success( + await this.#send( + '/api/account/device/teams', + 'GET', + this.#token(jwt), + undefined, + pageQuery(page), + signal, + ), + ) + if (!Array.isArray(value.teams) || !value.teams.every(team)) + throw new AccountDeviceError('invalid_response') + const teams = value.teams.map((row) => ({ + teamId: row.teamId, + teamName: row.teamName, + teamSlug: row.teamSlug, + })) + return {teams, nextAfter: cursor(value.nextAfter, teams.at(-1)?.teamId)} + } + + async projects( + jwt: AccountDeviceJwt, + teamId: string, + page: AccountDevicePage = {}, + signal?: AbortSignal, + ) { + const query = pageQuery(page) + query.set('teamId', uuid(teamId)) + const value = success( + await this.#send( + '/api/account/device/projects', + 'GET', + this.#token(jwt), + undefined, + query, + signal, + ), + ) + if (!Array.isArray(value.projects) || !value.projects.every(project)) + throw new AccountDeviceError('invalid_response') + const projects = value.projects.map((row) => ({ + projectId: row.projectId, + name: row.name, + slug: row.slug, + role: row.role, + })) + return { + projects, + nextAfter: cursor(value.nextAfter, projects.at(-1)?.projectId), + } + } + + async signOut(sessionToken: string, signal?: AbortSignal): Promise { + const value = success( + await this.#send( + '/api/auth/sign-out', + 'POST', + secret(sessionToken), + undefined, + undefined, + signal, + ), + ) + if (value.success !== true) throw new AccountDeviceError('invalid_response') + } + + #token(jwt: AccountDeviceJwt): string { + const token = verifiedTokens.get(jwt) + if ( + !token || + jwt.issuer !== this.configuration.issuer || + jwt.expiresAtMs <= this.#now() + ) + throw new AccountDeviceError('login_required') + return token + } + + async #send( + path: Path, + method: 'GET' | 'POST', + token?: string, + body?: Record, + query?: URLSearchParams, + signal?: AbortSignal, + ) { + if (signal?.aborted) throw new AccountDeviceError('refused') + const allowed: Readonly> = { + '/api/auth/device/code': 'POST', + '/api/auth/device/token': 'POST', + '/api/auth/get-session': 'GET', + '/api/auth/token': 'GET', + '/api/auth/sign-out': 'POST', + '/api/auth/jwks': 'GET', + '/api/account/device/teams': 'GET', + '/api/account/device/projects': 'GET', + } + if (!Object.hasOwn(allowed, path) || allowed[path] !== method) + throw new AccountDeviceError('refused') + const url = new URL(path, this.configuration.issuer) + if (query) url.search = query.toString() + const headers: Record = {accept: 'application/json'} + if (token) headers.authorization = `Bearer ${token}` + if (body) headers['content-type'] = 'application/json' + let response: Response + try { + response = await this.#request(url, { + method, + headers, + credentials: 'omit', + redirect: 'error', + cache: 'no-store', + referrerPolicy: 'no-referrer', + ...(signal ? {signal} : {}), + ...(body ? {body: JSON.stringify(body)} : {}), + }) + } catch { + throw new AccountDeviceError('authority_unavailable') + } + const contentType = response.headers + .get('content-type') + ?.split(';')[0] + ?.trim() + .toLowerCase() + if ( + response.redirected || + (response.status >= 300 && response.status < 400) || + (contentType !== 'application/json' && !contentType?.endsWith('+json')) + ) + throw new AccountDeviceError('invalid_response') + const value = await jsonBody(response) + if (signal?.aborted) throw new AccountDeviceError('refused') + return {status: response.status, value} + } +} + +async function jsonBody(response: Response): Promise { + const reader = response.body?.getReader() + if (!reader) throw new AccountDeviceError('invalid_response') + const chunks: Uint8Array[] = [] + let length = 0 + try { + for (;;) { + const {value, done} = await reader.read() + if (done) break + length += value.byteLength + if (length > MAX_BODY) { + await reader.cancel() + throw new AccountDeviceError('invalid_response') + } + chunks.push(value) + } + const bytes = new Uint8Array(length) + let offset = 0 + for (const chunk of chunks) { + bytes.set(chunk, offset) + offset += chunk.length + } + return JSON.parse( + new TextDecoder('utf-8', {fatal: true}).decode(bytes), + ) as unknown + } catch { + throw new AccountDeviceError('invalid_response') + } finally { + reader.releaseLock() + } +} + +function success(response: { + status: number + value: unknown +}): Record { + if ( + response.status === 401 || + response.status === 403 || + (response.status === 200 && response.value === null) + ) + throw new AccountDeviceError('login_required') + if (response.status >= 500) + throw new AccountDeviceError('authority_unavailable') + if (response.status < 200 || response.status >= 300) + throw new AccountDeviceError('refused') + if (!object(response.value)) throw new AccountDeviceError('invalid_response') + return response.value +} + +function httpsOrigin(value: string): string { + let url: URL + try { + url = new URL(value) + } catch { + throw new AccountDeviceError('refused') + } + if ( + value.trim() !== value || + url.protocol !== 'https:' || + url.username || + url.password || + url.search || + url.hash || + url.pathname !== '/' + ) + throw new AccountDeviceError('refused') + return url.origin +} +function object(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} +function text(value: unknown): value is string { + return typeof value === 'string' && value.length > 0 +} +function secret(value: unknown): string { + if ( + !text(value) || + [...value].some( + (char) => char.charCodeAt(0) < 33 || char.charCodeAt(0) > 126, + ) || + value.length > 16_384 + ) + throw new AccountDeviceError('invalid_response') + return value +} +function integer( + value: unknown, + from: number, + through: number, +): value is number { + return ( + typeof value === 'number' && + Number.isSafeInteger(value) && + value >= from && + value <= through + ) +} +function uuid(value: string): string { + if (!UUID.test(value)) throw new AccountDeviceError('refused') + return value.toLowerCase() +} +function pageQuery(page: AccountDevicePage): URLSearchParams { + const result = new URLSearchParams() + if (page.limit !== undefined) { + if (!integer(page.limit, 1, 100)) throw new AccountDeviceError('refused') + result.set('limit', String(page.limit)) + } + if (page.after !== undefined) result.set('after', uuid(page.after)) + return result +} +function cursor(value: unknown, last?: string): string | null { + if (value === null) return null + if (typeof value !== 'string' || !UUID.test(value) || value !== last) + throw new AccountDeviceError('invalid_response') + return value +} +function team(value: unknown): value is AccountDeviceTeam { + return ( + object(value) && + typeof value.teamId === 'string' && + UUID.test(value.teamId) && + typeof value.teamName === 'string' && + typeof value.teamSlug === 'string' + ) +} +function project(value: unknown): value is AccountDeviceProject { + return ( + object(value) && + typeof value.projectId === 'string' && + UUID.test(value.projectId) && + typeof value.name === 'string' && + typeof value.slug === 'string' && + (value.role === 'admin' || + value.role === 'member' || + value.role === 'owner') + ) +} + +function base64url(value: string): Uint8Array { + if (!/^[A-Za-z0-9_-]+$/.test(value)) + throw new AccountDeviceError('invalid_response') + let binary: string + try { + binary = atob(value.replaceAll('-', '+').replaceAll('_', '/')) + } catch { + throw new AccountDeviceError('invalid_response') + } + if ( + btoa(binary) + .replaceAll('+', '-') + .replaceAll('/', '_') + .replace(/=+$/, '') !== value + ) + throw new AccountDeviceError('invalid_response') + return Uint8Array.from(binary, (char) => char.charCodeAt(0)) +} +function jwtJson(value: string): Record { + try { + const result: unknown = JSON.parse( + new TextDecoder('utf-8', {fatal: true}).decode(base64url(value)), + ) + if (object(result)) return result + } catch { + /* Refuse without echoing token bytes. */ + } + throw new AccountDeviceError('invalid_response') +} +async function verifyJwt( + token: string, + jwks: Record, + issuer: string, + identity: AccountDeviceIdentity, + now: number, +): Promise { + const parts = token.split('.') + if (parts.length !== 3 || !parts[0] || !parts[1] || !parts[2]) + throw new AccountDeviceError('invalid_response') + const header = jwtJson(parts[0]), + claims = jwtJson(parts[1]) + if ( + header.alg !== 'EdDSA' || + !text(header.kid) || + (header.typ !== undefined && header.typ !== 'JWT') || + Object.keys(header).some((key) => !['alg', 'kid', 'typ'].includes(key)) || + !Array.isArray(jwks.keys) + ) + throw new AccountDeviceError('invalid_response') + const matches = jwks.keys.filter( + (key: unknown) => object(key) && key.kid === header.kid, + ) + if (matches.length !== 1 || !object(matches[0])) + throw new AccountDeviceError('invalid_response') + const jwk = matches[0] + if ( + jwk.kty !== 'OKP' || + jwk.crv !== 'Ed25519' || + typeof jwk.x !== 'string' || + base64url(jwk.x).length !== 32 || + jwk.d !== undefined || + (jwk.alg !== undefined && jwk.alg !== 'EdDSA') || + (jwk.use !== undefined && jwk.use !== 'sig') + ) + throw new AccountDeviceError('invalid_response') + try { + const key = await crypto.subtle.importKey( + 'jwk', + {kty: 'OKP', crv: 'Ed25519', x: jwk.x}, + 'Ed25519', + false, + ['verify'], + ) + if ( + !(await crypto.subtle.verify( + 'Ed25519', + key, + base64url(parts[2]), + new TextEncoder().encode(`${parts[0]}.${parts[1]}`), + )) + ) + throw new AccountDeviceError('invalid_response') + } catch { + throw new AccountDeviceError('invalid_response') + } + if ( + claims.iss !== issuer || + claims.aud !== AUDIENCE || + claims.sub !== identity.userId || + claims.sid !== identity.sessionId || + !integer(claims.iat, 0, Number.MAX_SAFE_INTEGER) || + !integer(claims.exp, 0, Number.MAX_SAFE_INTEGER) || + claims.exp <= claims.iat || + claims.exp - claims.iat > 900 || + claims.iat * 1000 > now || + claims.exp * 1000 <= now + ) + throw new AccountDeviceError('login_required') + return Math.min(identity.expiresAtMs, claims.exp * 1000) +} diff --git a/src/base-command.ts b/src/base-command.ts index 16567413..597ea74d 100644 --- a/src/base-command.ts +++ b/src/base-command.ts @@ -102,9 +102,9 @@ export abstract class BaseCommand extends Command { } /** - * Resolve an organization API key or exact-project token without building a + * Resolve an team key or exact-project token without building a * typed client, for commands that span projects. With `inferOwner`, --project - * or a project token reads one project and an organization key otherwise + * or a project token reads one project and an team key otherwise * reads the whole organization. */ protected async developerEnvironment( diff --git a/src/browser/command-support.ts b/src/browser/command-support.ts index 9e95b496..5f7a0cee 100644 --- a/src/browser/command-support.ts +++ b/src/browser/command-support.ts @@ -1,4 +1,18 @@ export const UNSUPPORTED_COMMANDS: Readonly> = { + 'account login': + 'requires local account profiles and the operating-system keyring', + 'account list': + 'requires local account profiles and the operating-system keyring', + 'account use': + 'requires local account profiles and the operating-system keyring', + 'account status': + 'requires local account profiles and the operating-system keyring', + 'account logout': + 'requires local account profiles and the operating-system keyring', + 'account teams': + 'requires local account profiles and the operating-system keyring', + 'account projects': + 'requires local account profiles and the operating-system keyring', 'calls media': 'streams call audio as binary PCM', 'agent setup': 'configures coding agents on the local computer', } diff --git a/src/browser/registry.gen.ts b/src/browser/registry.gen.ts index d2d15878..3f11a33c 100644 --- a/src/browser/registry.gen.ts +++ b/src/browser/registry.gen.ts @@ -3,6 +3,48 @@ import {BrowserUnsupportedError} from './stubs/unsupported.js' export const COMMAND_LOADERS: Readonly< Record Promise<{default: unknown}>> > = { + 'account list': () => + Promise.reject( + new BrowserUnsupportedError( + 'requires local account profiles and the operating-system keyring', + ), + ), + 'account login': () => + Promise.reject( + new BrowserUnsupportedError( + 'requires local account profiles and the operating-system keyring', + ), + ), + 'account logout': () => + Promise.reject( + new BrowserUnsupportedError( + 'requires local account profiles and the operating-system keyring', + ), + ), + 'account projects': () => + Promise.reject( + new BrowserUnsupportedError( + 'requires local account profiles and the operating-system keyring', + ), + ), + 'account status': () => + Promise.reject( + new BrowserUnsupportedError( + 'requires local account profiles and the operating-system keyring', + ), + ), + 'account teams': () => + Promise.reject( + new BrowserUnsupportedError( + 'requires local account profiles and the operating-system keyring', + ), + ), + 'account use': () => + Promise.reject( + new BrowserUnsupportedError( + 'requires local account profiles and the operating-system keyring', + ), + ), 'agent setup': () => Promise.reject( new BrowserUnsupportedError( diff --git a/src/commands/account/list.ts b/src/commands/account/list.ts new file mode 100644 index 00000000..e0a31e81 --- /dev/null +++ b/src/commands/account/list.ts @@ -0,0 +1,16 @@ +import {AccountDeviceCommand} from '../../account-device/command.js' +import {createAccountDeviceAuthService} from '../../account-device/factory.js' + +export default class AccountList extends AccountDeviceCommand { + static override description = + 'List local account-device profile metadata without reading credentials or making requests.' + async run(): Promise { + const {flags} = await this.parse(AccountList) + this.accountResult( + await this.accountOperation(flags, () => + createAccountDeviceAuthService().list(), + ), + flags, + ) + } +} diff --git a/src/commands/account/login.ts b/src/commands/account/login.ts new file mode 100644 index 00000000..b2079e21 --- /dev/null +++ b/src/commands/account/login.ts @@ -0,0 +1,52 @@ +import {Flags} from '@oclif/core' + +import { + AccountDeviceCommand, + waitForAccountDevicePoll, +} from '../../account-device/command.js' +import {createAccountDeviceAuthService} from '../../account-device/factory.js' + +export default class AccountLogin extends AccountDeviceCommand { + static override description = + 'Sign in through browser approval and store only the account session in its separate OS keyring.' + static override flags = { + 'api-origin': Flags.string({ + required: true, + description: 'Trusted HTTPS API origin for this Polymorfa environment.', + }), + 'console-origin': Flags.string({ + required: true, + description: 'Trusted HTTPS Console origin for browser approval.', + }), + } + + async run(): Promise { + const {flags} = await this.parse(AccountLogin) + const service = createAccountDeviceAuthService() + const result = await this.accountOperation(flags, async (signal) => { + const login = await service.startLogin( + flags.profile ?? 'default', + { + issuer: flags['api-origin'], + consoleOrigin: flags['console-origin'], + }, + signal, + ) + // Only the validated approval URL and user code are displayed. No + // browser is launched, and opaque device/session/JWT bytes stay private. + process.stderr.write( + `${JSON.stringify({verificationUri: login.verificationUri, userCode: login.userCode})}\n`, + ) + try { + for (;;) { + await waitForAccountDevicePoll(service.pollDelay(login), signal) + const polled = await service.pollLogin(login, signal) + if (polled.kind === 'signedIn') return polled + } + } finally { + service.cancelLogin(login) + } + }) + this.accountResult(result, flags) + } +} diff --git a/src/commands/account/logout.ts b/src/commands/account/logout.ts new file mode 100644 index 00000000..cb700e63 --- /dev/null +++ b/src/commands/account/logout.ts @@ -0,0 +1,20 @@ +import {AccountDeviceCommand} from '../../account-device/command.js' +import {createAccountDeviceAuthService} from '../../account-device/factory.js' + +export default class AccountLogout extends AccountDeviceCommand { + static override description = + 'Revoke only the selected account session and report remote revocation separately from local cleanup.' + async run(): Promise { + const {flags} = await this.parse(AccountLogout) + const result = await this.accountOperation(flags, (signal) => + createAccountDeviceAuthService().logout(flags.profile, signal), + ) + this.accountResult(result, flags) + if ( + result.remoteRevocation !== 'confirmed' || + !result.localMetadataRemoved || + !result.localCredentialRemoved + ) + this.exit(1) + } +} diff --git a/src/commands/account/projects.ts b/src/commands/account/projects.ts new file mode 100644 index 00000000..9a13862a --- /dev/null +++ b/src/commands/account/projects.ts @@ -0,0 +1,36 @@ +import {Flags} from '@oclif/core' + +import {AccountDeviceCommand} from '../../account-device/command.js' +import {createAccountDeviceAuthService} from '../../account-device/factory.js' + +export default class AccountProjects extends AccountDeviceCommand { + static override description = + 'Read one page of projects in a currently allowed team; this grants no command or messaging access.' + static override flags = { + team: Flags.string({ + required: true, + description: 'Team UUID returned by account teams.', + }), + after: Flags.string({description: 'Continue after this project UUID.'}), + limit: Flags.integer({ + min: 1, + max: 100, + description: 'Page size, up to 100.', + }), + } + async run(): Promise { + const {flags} = await this.parse(AccountProjects) + const result = await this.accountOperation(flags, (signal) => + createAccountDeviceAuthService().projects( + flags.team, + flags.profile, + { + ...(flags.after === undefined ? {} : {after: flags.after}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + }, + signal, + ), + ) + this.accountResult(result, flags) + } +} diff --git a/src/commands/account/status.ts b/src/commands/account/status.ts new file mode 100644 index 00000000..5e99b0d9 --- /dev/null +++ b/src/commands/account/status.ts @@ -0,0 +1,15 @@ +import {AccountDeviceCommand} from '../../account-device/command.js' +import {createAccountDeviceAuthService} from '../../account-device/factory.js' + +export default class AccountStatus extends AccountDeviceCommand { + static override description = + 'Check the selected account session against its pinned API issuer.' + async run(): Promise { + const {flags} = await this.parse(AccountStatus) + const result = await this.accountOperation(flags, (signal) => + createAccountDeviceAuthService().status(flags.profile, signal), + ) + this.accountResult(result, flags) + if (!result.credentialPresent) this.exit(1) + } +} diff --git a/src/commands/account/teams.ts b/src/commands/account/teams.ts new file mode 100644 index 00000000..3e7a69cf --- /dev/null +++ b/src/commands/account/teams.ts @@ -0,0 +1,31 @@ +import {Flags} from '@oclif/core' + +import {AccountDeviceCommand} from '../../account-device/command.js' +import {createAccountDeviceAuthService} from '../../account-device/factory.js' + +export default class AccountTeams extends AccountDeviceCommand { + static override description = + 'Read one page of teams currently allowed to the selected account session.' + static override flags = { + after: Flags.string({description: 'Continue after this team UUID.'}), + limit: Flags.integer({ + min: 1, + max: 100, + description: 'Page size, up to 100.', + }), + } + async run(): Promise { + const {flags} = await this.parse(AccountTeams) + const result = await this.accountOperation(flags, (signal) => + createAccountDeviceAuthService().teams( + flags.profile, + { + ...(flags.after === undefined ? {} : {after: flags.after}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + }, + signal, + ), + ) + this.accountResult(result, flags) + } +} diff --git a/src/commands/account/use.ts b/src/commands/account/use.ts new file mode 100644 index 00000000..a699fb13 --- /dev/null +++ b/src/commands/account/use.ts @@ -0,0 +1,20 @@ +import {Args} from '@oclif/core' + +import {AccountDeviceCommand} from '../../account-device/command.js' +import {createAccountDeviceAuthService} from '../../account-device/factory.js' + +export default class AccountUse extends AccountDeviceCommand { + static override description = + 'Select a local account-device profile without changing the active machine profile or its authority.' + static override args = {name: Args.string({required: true})} + async run(): Promise { + const {args, flags} = await this.parse(AccountUse) + await this.accountOperation(flags, () => + createAccountDeviceAuthService().use(args.name), + ) + this.accountResult( + {profile: args.name, active: true, kind: 'accountDevice'}, + flags, + ) + } +} diff --git a/src/commands/auth/login.ts b/src/commands/auth/login.ts index 0292b780..8f724188 100644 --- a/src/commands/auth/login.ts +++ b/src/commands/auth/login.ts @@ -7,7 +7,7 @@ import {readSecretFromStdin} from '../../input/stdin.js' export default class AuthLogin extends BaseCommand { static override description = - 'Store an organization key or project token in the operating-system keyring.' + 'Store an opaque team key or project token in the operating-system keyring. Never parse credentials or assume a fixed length.' static override flags = { 'with-api-key': Flags.boolean({ diff --git a/src/commands/billing/limits/list.ts b/src/commands/billing/limits/list.ts index 0e2a2f2b..91e89bda 100644 --- a/src/commands/billing/limits/list.ts +++ b/src/commands/billing/limits/list.ts @@ -3,7 +3,7 @@ import {getLimits, renderLimits} from '../../../services/billing-controls.js' export default class LimitsList extends BaseCommand { static override summary = 'Read monthly project and number spending limits.' static override description = - 'Requires an organization key with billing:read. Use --project to include its number limits. Months reset on the first day at 00:00 UTC.' + 'Requires an team key with billing:read. Use --project to include its number limits. Months reset on the first day at 00:00 UTC.' async run(): Promise { const {flags} = await this.parse(LimitsList) const result = await getLimits( diff --git a/src/commands/billing/limits/set.ts b/src/commands/billing/limits/set.ts index 9e321787..efe94b00 100644 --- a/src/commands/billing/limits/set.ts +++ b/src/commands/billing/limits/set.ts @@ -4,7 +4,7 @@ import {setLimit, limitValue} from '../../../services/billing-controls.js' export default class LimitsSet extends BaseCommand { static override summary = 'Set or remove a monthly spending limit.' static override description = - 'Requires Pay-As-You-Go and an organization key with billing:manage. Zero blocks new charges; already-paid service continues. A stale observed revision fails without overwriting newer settings.' + 'Requires Pay-As-You-Go and an team key with billing:manage. Zero blocks new charges; already-paid service continues. A stale observed revision fails without overwriting newer settings.' static override args = { id: Args.string({required: true, description: 'Project or number UUID.'}), } diff --git a/src/commands/billing/priority/list.ts b/src/commands/billing/priority/list.ts index de5a123e..1292d586 100644 --- a/src/commands/billing/priority/list.ts +++ b/src/commands/billing/priority/list.ts @@ -7,7 +7,7 @@ export default class PriorityList extends BaseCommand { static override summary = 'Read project, customer and number funding priorities.' static override description = - 'Requires an organization key with billing:read. Project priority takes precedence. Numbers inherit customer priority; a higher number priority overrides it. Use --project for its funding controls. Includes the revision used for changes.' + 'Requires an team key with billing:read. Project priority takes precedence. Numbers inherit customer priority; a higher number priority overrides it. Use --project for its funding controls. Includes the revision used for changes.' async run(): Promise { const {flags} = await this.parse(PriorityList) const result = await getPriorities( diff --git a/src/commands/billing/priority/reorder.ts b/src/commands/billing/priority/reorder.ts index 7d9aff3f..919abbb1 100644 --- a/src/commands/billing/priority/reorder.ts +++ b/src/commands/billing/priority/reorder.ts @@ -6,7 +6,7 @@ import {reorderPriorities} from '../../../services/billing-controls.js' export default class PriorityReorder extends BaseCommand { static override summary = 'Save a complete funding order atomically.' static override description = - 'Requires Pay-As-You-Go and an organization key with billing:manage. Pass every active project, customer or real number for its scope. Resource scope mixes all customers and numbers in --project, highest first. Incomplete or stale orders fail without partial changes.' + 'Requires Pay-As-You-Go and an team key with billing:manage. Pass every active project, customer or real number for its scope. Resource scope mixes all customers and numbers in --project, highest first. Incomplete or stale orders fail without partial changes.' static override flags = { scope: Flags.string({ options: ['project', 'customer', 'number', 'resource'], diff --git a/src/commands/billing/priority/set.ts b/src/commands/billing/priority/set.ts index 4058883e..af291d1e 100644 --- a/src/commands/billing/priority/set.ts +++ b/src/commands/billing/priority/set.ts @@ -4,7 +4,7 @@ import {setPriority} from '../../../services/billing-controls.js' export default class PrioritySet extends BaseCommand { static override summary = 'Set a project or number funding priority.' static override description = - 'Requires Pay-As-You-Go and an organization key with billing:manage. Changes future renewal protection and recovery; paid windows keep their expiry.' + 'Requires Pay-As-You-Go and an team key with billing:manage. Changes future renewal protection and recovery; paid windows keep their expiry.' static override args = { id: Args.string({required: true, description: 'Project or number UUID.'}), } diff --git a/src/commands/calls/export.ts b/src/commands/calls/export.ts index 10a74e73..bd09cba2 100644 --- a/src/commands/calls/export.ts +++ b/src/commands/calls/export.ts @@ -26,7 +26,7 @@ Each record has callId, projectId, sessionId, direction, upstream, outcome, stat The command requests every page until no records remain and writes one CSV header. Records go to standard output, or to --output. Progress and the next cursor go to standard error, as JSON lines with --json. --max-pages stops early; resume with --cursor and the same filters. When a page fails, the command reports the cursor that resumes from that page and exits non-zero. -Pass --project to read one project. Without it, an organization API key reads every project in the organization. A project token reads only its own project and requires --project. Credentials need the sessions:read scope.` +Pass --project to read one project. Without it, an team key reads every project in the organization. A project token reads only its own project and requires --project. Credentials need the sessions:read scope.` static override examples = [ '<%= config.bin %> calls export --since 2026-09-01T00:00:00Z --output calls.csv', diff --git a/src/commands/calls/opt-outs/add.ts b/src/commands/calls/opt-outs/add.ts index 41456123..e798d988 100644 --- a/src/commands/calls/opt-outs/add.ts +++ b/src/commands/calls/opt-outs/add.ts @@ -8,7 +8,7 @@ import { export default class CallsOptOutsAdd extends BaseCommand { static override description = - 'Add a person to the team’s do-not-call list by phone number or BSUID. From then on, calls to them are refused with call_recipient_opted_out, including calls your PBX places through a SIP trunk. Adding a person already on the list changes nothing and returns their entry (metadata.status 200 instead of 201). The list holds up to 100,000 entries; a full list fails with call_opt_out_limit (exit code 4). Requires an organization API key.' + 'Add a person to the team’s do-not-call list by phone number or BSUID. From then on, calls to them are refused with call_recipient_opted_out, including calls your PBX places through a SIP trunk. Adding a person already on the list changes nothing and returns their entry (metadata.status 200 instead of 201). The list holds up to 100,000 entries; a full list fails with call_opt_out_limit (exit code 4). Requires an team key.' static override examples = [ '<%= config.bin %> calls opt-outs add --phone-number +14155550123 --note "Asked by phone on 2026-09-19"', diff --git a/src/commands/calls/opt-outs/import.ts b/src/commands/calls/opt-outs/import.ts index d12abed1..47471773 100644 --- a/src/commands/calls/opt-outs/import.ts +++ b/src/commands/calls/opt-outs/import.ts @@ -20,7 +20,7 @@ export default class CallsOptOutsImport extends BaseCommand { }), } - static override description = `Add many people to the team’s do-not-call list from a file of up to 10 MiB. CSV needs a header row naming phoneNumber, bsuid and optionally note; each row names a phone number (E.164) or a BSUID. JSON is an array of {"phoneNumber" | "bsuid", "note"} objects, or an object with that array as "entries". The CLI sends ${IMPORT_BATCH_SIZE.toLocaleString('en-US')} entries per request and reports the entries added, already listed and rejected, with each rejected entry's zero-based position (and CSV line). Running an import again is safe: entries already on the list are counted as already listed. Exit codes: 0 when every entry is listed; 12 when some entries were rejected, or when a later request failed after earlier batches were stored (run the import again); the request's own exit code when nothing was stored. Requires an organization API key.` + static override description = `Add many people to the team’s do-not-call list from a file of up to 10 MiB. CSV needs a header row naming phoneNumber, bsuid and optionally note; each row names a phone number (E.164) or a BSUID. JSON is an array of {"phoneNumber" | "bsuid", "note"} objects, or an object with that array as "entries". The CLI sends ${IMPORT_BATCH_SIZE.toLocaleString('en-US')} entries per request and reports the entries added, already listed and rejected, with each rejected entry's zero-based position (and CSV line). Running an import again is safe: entries already on the list are counted as already listed. Exit codes: 0 when every entry is listed; 12 when some entries were rejected, or when a later request failed after earlier batches were stored (run the import again); the request's own exit code when nothing was stored. Requires an team key.` static override examples = [ '<%= config.bin %> calls opt-outs import do-not-call.csv', diff --git a/src/commands/calls/opt-outs/list.ts b/src/commands/calls/opt-outs/list.ts index cfefcfaa..17d39cc4 100644 --- a/src/commands/calls/opt-outs/list.ts +++ b/src/commands/calls/opt-outs/list.ts @@ -9,7 +9,7 @@ import { export default class CallsOptOutsList extends BaseCommand { static override description = - 'List the team’s do-not-call list, newest first. One page is returned unless --paginate follows page.nextCursor up to --max-items entries; human output ends with the --cursor for the next page. --phone-number or --bsuid returns only that person’s entry, if any. Requires an organization API key.' + 'List the team’s do-not-call list, newest first. One page is returned unless --paginate follows page.nextCursor up to --max-items entries; human output ends with the --cursor for the next page. --phone-number or --bsuid returns only that person’s entry, if any. Requires an team key.' static override examples = [ '<%= config.bin %> calls opt-outs list', diff --git a/src/commands/calls/opt-outs/remove.ts b/src/commands/calls/opt-outs/remove.ts index c2611b51..f5f94772 100644 --- a/src/commands/calls/opt-outs/remove.ts +++ b/src/commands/calls/opt-outs/remove.ts @@ -15,7 +15,7 @@ export default class CallsOptOutsRemove extends BaseCommand { } static override description = - 'Remove one entry from the team’s do-not-call list. The person can be called again at once. Requires --yes and an organization API key; an unknown ID fails with exit code 4.' + 'Remove one entry from the team’s do-not-call list. The person can be called again at once. Requires --yes and an team key; an unknown ID fails with exit code 4.' static override examples = [ '<%= config.bin %> calls opt-outs remove 0b9f6a52-8f0e-4a44-9d51-3c1f9d6f2a10 --yes', diff --git a/src/commands/calls/policy/get.ts b/src/commands/calls/policy/get.ts index 8019c4ce..38e1bec3 100644 --- a/src/commands/calls/policy/get.ts +++ b/src/commands/calls/policy/get.ts @@ -6,7 +6,7 @@ import { export default class CallsPolicyGet extends BaseCommand { static override description = - 'Read the team call policy: the blocked country codes, the number of entries on the do-not-call list, and the revision to pass to `calls policy set --expected-revision`. The revision is 0 before the codes are first saved. Requires an organization API key: a project token used as the API key is refused by the API (exit code 3), and a client token is not accepted for this command (exit code 2).' + 'Read the team call policy: the blocked country codes, the number of entries on the do-not-call list, and the revision to pass to `calls policy set --expected-revision`. The revision is 0 before the codes are first saved. Requires an team key: a project token used as the API key is refused by the API (exit code 3), and a client token is not accepted for this command (exit code 2).' static override examples = ['<%= config.bin %> calls policy get'] diff --git a/src/commands/calls/policy/set.ts b/src/commands/calls/policy/set.ts index 28942fda..f5ced314 100644 --- a/src/commands/calls/policy/set.ts +++ b/src/commands/calls/policy/set.ts @@ -9,7 +9,7 @@ import { export default class CallsPolicySet extends BaseCommand { static override description = - 'Replace the team’s blocked country codes. Calls to phone numbers that start with a blocked code are refused with call_destination_blocked, and while any code is blocked, calls to a person whose phone number is not known are refused too. The list you pass replaces the stored list; --clear allows every country. Codes are country calling codes or longer dialing prefixes of 1 to 4 digits (44, 1876), at most 300. New calls use the new list at once; calls in progress continue. --expected-revision applies the change only if the policy still has that revision (see `calls policy get`); otherwise the command fails with state_conflict (exit code 4) and changes nothing. Requires an organization API key.' + 'Replace the team’s blocked country codes. Calls to phone numbers that start with a blocked code are refused with call_destination_blocked, and while any code is blocked, calls to a person whose phone number is not known are refused too. The list you pass replaces the stored list; --clear allows every country. Codes are country calling codes or longer dialing prefixes of 1 to 4 digits (44, 1876), at most 300. New calls use the new list at once; calls in progress continue. --expected-revision applies the change only if the policy still has that revision (see `calls policy get`); otherwise the command fails with state_conflict (exit code 4) and changes nothing. Requires an team key.' static override examples = [ '<%= config.bin %> calls policy set --blocked-country-codes 44,1876', diff --git a/src/commands/calls/stats.ts b/src/commands/calls/stats.ts index f40d1eef..0cfb0885 100644 --- a/src/commands/calls/stats.ts +++ b/src/commands/calls/stats.ts @@ -17,7 +17,7 @@ export default class CallsStats extends BaseCommand { The range defaults to the last 7 days. --group-by day covers at most 366 days and --group-by hour at most 31. Day and hour buckets, and the heatmap, use --timezone (UTC by default). -Pass --project to read one project. Without it, an organization API key reads every project in the organization. A project token reads only its own project and requires --project. Credentials need the sessions:read scope.` +Pass --project to read one project. Without it, an team key reads every project in the organization. A project token reads only its own project and requires --project. Credentials need the sessions:read scope.` static override examples = [ '<%= config.bin %> calls stats', diff --git a/src/commands/key/revoke.ts b/src/commands/key/revoke.ts index 40b37847..29d9b5cb 100644 --- a/src/commands/key/revoke.ts +++ b/src/commands/key/revoke.ts @@ -8,7 +8,7 @@ export default class KeyRevoke extends BaseCommand { key: Args.string({description: 'API key ID.', required: true}), } - static override description = 'Revoke an organization API key.' + static override description = 'Revoke an team key.' static override flags = { 'idempotency-key': Flags.string({ diff --git a/src/commands/listen.ts b/src/commands/listen.ts index 274cf045..6e9adaca 100644 --- a/src/commands/listen.ts +++ b/src/commands/listen.ts @@ -74,7 +74,7 @@ export default class Listen extends BaseCommand { }), project: Flags.string({ description: - 'Use a project UUID; organization API keys may use a unique slug. Defaults to the linked or profile project.', + 'Use a project UUID; team keys may use a unique slug. Defaults to the linked or profile project.', }), } diff --git a/src/commands/session/list.ts b/src/commands/session/list.ts index 0eb5559c..0137bdfa 100644 --- a/src/commands/session/list.ts +++ b/src/commands/session/list.ts @@ -6,7 +6,7 @@ import { export default class SessionList extends BaseCommand { static override description = - 'List session metadata for the team or one project. Requires an organization API key with sessions:read.' + 'List session metadata for the team or one project. Requires an team key with sessions:read.' static override examples = [ '<%= config.bin %> session list', '<%= config.bin %> session list --project PROJECT_ID --json', diff --git a/src/commands/session/status.ts b/src/commands/session/status.ts index 97e308db..d009713a 100644 --- a/src/commands/session/status.ts +++ b/src/commands/session/status.ts @@ -6,7 +6,7 @@ import {viewManagedSession} from '../../services/platform-management.js' export default class SessionStatus extends BaseCommand { static override description = - 'Read the stored session status and reason. Requires an organization API key with sessions:read.' + 'Read the stored session status and reason. Requires an team key with sessions:read.' static override args = { session: Args.string({description: 'Session ID.', required: true}), } diff --git a/src/commands/session/view.ts b/src/commands/session/view.ts index 2d9e6b51..5651aadf 100644 --- a/src/commands/session/view.ts +++ b/src/commands/session/view.ts @@ -9,7 +9,7 @@ import { export default class SessionView extends BaseCommand { static override description = - 'Read one session by its ID. Requires an organization API key with sessions:read.' + 'Read one session by its ID. Requires an team key with sessions:read.' static override args = { session: Args.string({description: 'Session ID.', required: true}), } diff --git a/src/context/environment.ts b/src/context/environment.ts index 0f64c622..55f5ed83 100644 --- a/src/context/environment.ts +++ b/src/context/environment.ts @@ -75,7 +75,7 @@ export interface ResolveDeveloperEnvironmentInput { /** * Choose the owner when neither --owner nor the profile names one: a * project context (--project, or a project token) reads one project, and - * an organization key otherwise reads the whole organization. For commands + * an team key otherwise reads the whole organization. For commands * whose results span projects rather than belong to one owner. */ readonly inferOwner?: boolean | undefined @@ -464,8 +464,8 @@ export async function resolveCommandEnvironment( ) { throw new PolymorfaConfigurationError( input.surface === 'platform' - ? `Profile ${selected} contains a project token; this Platform command requires an organization API key.` - : `Profile ${selected} contains a project token; this Messaging command requires an organization API key or a Messaging client token.`, + ? `Profile ${selected} contains a project token; this Platform command requires an team key.` + : `Profile ${selected} contains a project token; this Messaging command requires an team key or a Messaging client token.`, 'credential', ) } diff --git a/src/mcp/server.ts b/src/mcp/server.ts index c1ebd143..8a2e4a60 100644 --- a/src/mcp/server.ts +++ b/src/mcp/server.ts @@ -92,7 +92,7 @@ export function createMcpServer( readOnlyHint: true, }, description: - 'List stored session summaries in one project. Requires a CLI organization API key with sessions:read.', + 'List stored session summaries in one project. Requires a CLI team key with sessions:read.', inputSchema: z.object({ projectId: z.string().regex(LISTENER_PROJECT_ID_PATTERN), }), @@ -105,7 +105,7 @@ export function createMcpServer( return { content: [ { - text: 'Could not list project sessions with the selected CLI organization API key.', + text: 'Could not list project sessions with the selected CLI team key.', type: 'text' as const, }, ], diff --git a/src/mcp/session-discovery.ts b/src/mcp/session-discovery.ts index b6feee06..59ac2620 100644 --- a/src/mcp/session-discovery.ts +++ b/src/mcp/session-discovery.ts @@ -34,7 +34,7 @@ export async function listMcpSessions( process.env.POLYMORFA_CLIENT_TOKEN?.trim()) ) { throw new PolymorfaConfigurationError( - 'Select only one organization API key for MCP session discovery.', + 'Select only one team key for MCP session discovery.', 'credential', ) } diff --git a/src/sdk/client.ts b/src/sdk/client.ts index e727749f..2494fe82 100644 --- a/src/sdk/client.ts +++ b/src/sdk/client.ts @@ -33,6 +33,8 @@ export function createMessagingClient( ) } + assertMachineCredential(environment) + return new MessagingClient({ credential: environment.credential, ...sharedOptions(environment), @@ -55,6 +57,8 @@ export function createCallSettingsClient( ) } + assertMachineCredential(environment) + return new MessagingClient({ credential: {type: 'apiKey', value: environment.credential.value}, ...sharedOptions(environment), @@ -81,6 +85,15 @@ export interface CallRetentionClient { export function createCallRetentionClient( environment: TeamSettingEnvironment, ): CallRetentionClient { + if ( + environment.principalType !== 'organization_key' && + environment.principalType !== 'project_token' + ) { + throw new PolymorfaConfigurationError( + 'Machine clients require an explicit machine credential kind.', + 'credential', + ) + } const callRetention = createTeamCallRetentionClient({ baseUrl: environment.baseUrl, credential: { @@ -114,6 +127,8 @@ export function createPlatformClient( ) } + assertMachineCredential(environment) + return new Client({ credential: { type: 'organizationApiKey', @@ -123,6 +138,19 @@ export function createPlatformClient( }) } +function assertMachineCredential(environment: ResolvedEnvironment): void { + if ( + environment.credential?.type !== 'apiKey' && + environment.credential?.type !== 'projectToken' && + environment.credential?.type !== 'clientToken' + ) { + throw new PolymorfaConfigurationError( + 'Machine clients require an explicit machine credential kind.', + 'credential', + ) + } +} + function sharedOptions(environment: ResolvedEnvironment): SharedClientOptions { return { ...(environment.baseUrl === undefined diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index 58ec7bad..ea1f777c 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -144,7 +144,9 @@ export async function listCampaignRecipients( client: MessagingClient, projectSlug: string, campaignId: string, - params: ListCampaignRecipientsParams & {readonly reason?: string}, + params: Omit & { + readonly reason?: string + }, ): Promise> { const response = await client.raw.request<{ data: readonly CampaignRecipient[] diff --git a/src/services/projects.ts b/src/services/projects.ts index 8f81e84f..32c2a7ba 100644 --- a/src/services/projects.ts +++ b/src/services/projects.ts @@ -280,7 +280,7 @@ export class ProjectContextService { if (projectToken !== undefined) { // The token does not name its project, so an explicit UUID is the // only way to identify it locally. A slug cannot be resolved without - // an organization credential. + // an team key. const declared = nonEmpty(options.project)?.trim() if (declared !== undefined && PROJECT_UUID.test(declared)) { return { diff --git a/test/account-device/commands.test.ts b/test/account-device/commands.test.ts new file mode 100644 index 00000000..2a5bf478 --- /dev/null +++ b/test/account-device/commands.test.ts @@ -0,0 +1,222 @@ +import {captureOutput} from '@oclif/test' +import {beforeEach, describe, expect, it, vi} from 'vitest' + +import type {AccountDeviceAuthService} from '../../src/account-device/service.js' +import AccountLogin from '../../src/commands/account/login.js' +import AccountList from '../../src/commands/account/list.js' +import AccountUse from '../../src/commands/account/use.js' +import AccountStatus from '../../src/commands/account/status.js' +import AccountLogout from '../../src/commands/account/logout.js' +import AccountTeams from '../../src/commands/account/teams.js' +import AccountProjects from '../../src/commands/account/projects.js' +import {CONFIG, serviceFixture} from './service-fixture.js' + +const context = vi.hoisted(() => ({ + service: undefined as AccountDeviceAuthService | undefined, + tick: (milliseconds: number) => { + void milliseconds + }, +})) +vi.mock('../../src/account-device/factory.js', () => ({ + createAccountDeviceAuthService: () => { + if (!context.service) throw new Error('fixture not installed') + return context.service + }, +})) +vi.mock('../../src/account-device/command.js', async (original) => ({ + ...(await original()), + waitForAccountDevicePoll: (milliseconds: number, signal: AbortSignal) => { + signal.throwIfAborted() + context.tick(milliseconds) + return Promise.resolve() + }, +})) + +const ROOT = new URL('../../', import.meta.url).pathname +function signIn() { + return captureOutput(() => + AccountLogin.run( + [ + '--profile', + 'own', + '--api-origin', + CONFIG.issuer, + '--console-origin', + CONFIG.consoleOrigin, + '--json', + ], + ROOT, + ), + ) +} + +describe('mounted account-device commands', () => { + let fixture: Awaited> + beforeEach(async () => { + fixture = await serviceFixture() + context.service = fixture.service + context.tick = fixture.tick + }) + + it('mounts login through the actual signed exchange without printing private credentials', async () => { + const result = await signIn() + expect(result.error, result.stderr).toBeUndefined() + expect(JSON.parse(result.stdout)).toMatchObject({ + profile: 'own', + kind: 'signedIn', + remoteIdentityValidated: true, + }) + expect(JSON.parse(result.stderr)).toEqual({ + verificationUri: `${CONFIG.consoleOrigin}/device`, + userCode: 'CODE-1', + }) + for (const token of fixture.credentials.values.values()) { + expect(result.stdout).not.toContain(token) + expect(result.stderr).not.toContain(token) + } + expect(result.stdout + result.stderr).not.toContain('synthetic-device-') + const call = fixture.calls.find( + (entry) => entry.path === '/api/auth/device/code', + )! + expect(call.body).toEqual({client_id: 'polymorfa-cli'}) + expect(call.headers.has('authorization')).toBe(false) + }) + + it('lists/selects only account profiles and checks actual remote session identity', async () => { + expect((await signIn()).error).toBeUndefined() + const before = fixture.calls.length + const list = await captureOutput(() => AccountList.run(['--json'], ROOT)) + expect(JSON.parse(list.stdout)).toEqual([ + expect.objectContaining({ + name: 'own', + kind: 'accountDevice', + active: true, + }), + ]) + const use = await captureOutput(() => + AccountUse.run(['own', '--json'], ROOT), + ) + expect(JSON.parse(use.stdout)).toEqual({ + profile: 'own', + active: true, + kind: 'accountDevice', + }) + expect(fixture.calls).toHaveLength(before) + const status = await captureOutput(() => + AccountStatus.run(['--profile', 'own', '--json'], ROOT), + ) + expect(status.error, status.stderr).toBeUndefined() + expect(JSON.parse(status.stdout)).toMatchObject({ + profile: 'own', + credentialPresent: true, + remoteIdentityValidated: true, + }) + expect(fixture.calls.at(-1)?.path).toBe('/api/auth/get-session') + }) + + it('mounts only the two account resource reads with typed pagination', async () => { + expect((await signIn()).error).toBeUndefined() + const teams = await captureOutput(() => + AccountTeams.run(['--limit', '2', '--json'], ROOT), + ) + expect(teams.error, teams.stderr).toBeUndefined() + expect(JSON.parse(teams.stdout)).toEqual({teams: [], nextAfter: null}) + const projects = await captureOutput(() => + AccountProjects.run( + [ + '--team', + '11111111-1111-4111-8111-111111111111', + '--limit', + '1', + '--json', + ], + ROOT, + ), + ) + expect(projects.error, projects.stderr).toBeUndefined() + expect(JSON.parse(projects.stdout)).toEqual({projects: [], nextAfter: null}) + expect( + fixture.calls + .filter((entry) => entry.path.startsWith('/api/account/device/')) + .map((entry) => entry.path), + ).toEqual(['/api/account/device/teams', '/api/account/device/projects']) + }) + + it('revokes only its own session and reports local cleanup separately', async () => { + expect((await signIn()).error).toBeUndefined() + const result = await captureOutput(() => + AccountLogout.run(['--profile', 'own', '--json'], ROOT), + ) + expect(result.error, result.stderr).toBeUndefined() + expect(JSON.parse(result.stdout)).toEqual({ + profile: 'own', + remoteRevocation: 'confirmed', + localMetadataRemoved: true, + localCredentialRemoved: true, + }) + expect(fixture.revoked).toHaveLength(1) + expect(fixture.credentials.values.size).toBe(0) + }) + + it('exits nonzero for incomplete cleanup without calling it a remote failure', async () => { + expect((await signIn()).error).toBeUndefined() + fixture.credentials.failDelete = true + const result = await captureOutput(() => + AccountLogout.run(['--json'], ROOT), + ) + expect(result.error).toBeDefined() + expect(JSON.parse(result.stdout)).toMatchObject({ + remoteRevocation: 'confirmed', + localMetadataRemoved: true, + localCredentialRemoved: false, + }) + }) + + it.each([ + ['raw-route override', ['--path', '/api/keys']], + ['machine owner override', ['--owner', 'organization']], + [ + 'issuer override after login', + ['--api-origin', 'https://other.example.invalid'], + ], + ['oversized page', ['--limit', '101']], + ])( + 'refuses %s before any credential or network call', + async (_name, flags) => { + const result = await captureOutput(() => + AccountTeams.run([...flags, '--json'], ROOT), + ) + expect(result.error).toBeDefined() + expect(fixture.calls).toHaveLength(0) + expect(fixture.credentials.values.size).toBe(0) + }, + ) + + it('requires a project team selector before any credential or network call', async () => { + const result = await captureOutput(() => + AccountProjects.run(['--json'], ROOT), + ) + expect(result.error).toBeDefined() + expect(fixture.calls).toHaveLength(0) + }) + + it('does not echo an arbitrary storage failure or fall back to machine credentials', async () => { + expect((await signIn()).error).toBeUndefined() + vi.spyOn(fixture.credentials, 'get').mockRejectedValueOnce( + new Error('synthetic-private-token-must-not-print'), + ) + const result = await captureOutput(() => + AccountStatus.run(['--json'], ROOT), + ) + expect(result.error).toBeDefined() + expect(JSON.parse(result.stderr)).toEqual({ + error: { + code: 'account_device_failed', + message: 'The account-device operation could not be completed.', + }, + }) + expect(result.stderr + result.stdout).not.toContain( + 'synthetic-private-token-must-not-print', + ) + }) +}) diff --git a/test/account-device/machine-refusal.test.ts b/test/account-device/machine-refusal.test.ts new file mode 100644 index 00000000..a066f149 --- /dev/null +++ b/test/account-device/machine-refusal.test.ts @@ -0,0 +1,69 @@ +import {describe, expect, it} from 'vitest' + +import type {AccountDeviceEnvironment} from '../../src/account-device/transport.js' +import type { + ResolvedEnvironment, + TeamSettingEnvironment, +} from '../../src/context/environment.js' +import { + createCallRetentionClient, + createCallSettingsClient, + createMessagingClient, + createPlatformClient, + createRawClient, +} from '../../src/sdk/client.js' + +// Deliberately exercise hostile JS/JSON inputs outside the disjoint TypeScript +// types. No account session may be reinterpreted as a machine credential. +describe('account-device machine factory refusal', () => { + const account: AccountDeviceEnvironment = { + surface: 'accountDevice', + issuer: 'https://api.account.example.invalid', + consoleOrigin: 'https://console.account.example.invalid', + userId: 'own-user', + sessionId: 'own-session', + expiresAtMs: 2_000_000_000_000, + credentialAccount: 'account-device:own-profile:own-reference', + } + + it.each([ + createRawClient, + createMessagingClient, + createPlatformClient, + createCallSettingsClient, + ])('refuses a separate account-device environment: %s', (factory) => { + expect(() => factory(account as unknown as ResolvedEnvironment)).toThrow() + }) + + it.each([createRawClient, createPlatformClient, createCallSettingsClient])( + 'refuses a caller-labelled Platform credential before value coercion: %s', + (factory) => { + const hostile = { + surface: 'platform', + credential: {type: 'accountDevice', value: 'synthetic-account-session'}, + } + expect(() => factory(hostile as unknown as ResolvedEnvironment)).toThrow( + 'Machine clients require an explicit machine credential kind.', + ) + }, + ) + + it('refuses a caller-labelled Messaging credential before the SDK constructor', () => { + const hostile = { + surface: 'messaging', + credential: {type: 'accountDevice', value: 'synthetic-account-session'}, + } + expect(() => + createMessagingClient(hostile as unknown as ResolvedEnvironment), + ).toThrow('Machine clients require an explicit machine credential kind.') + }) + + it('refuses an unknown team-setting principal instead of mapping it to an organization key', () => { + expect(() => + createCallRetentionClient({ + principalType: 'accountDevice', + credential: 'synthetic-account-session', + } as unknown as TeamSettingEnvironment), + ).toThrow('Machine clients require an explicit machine credential kind.') + }) +}) diff --git a/test/account-device/service-fixture.ts b/test/account-device/service-fixture.ts new file mode 100644 index 00000000..b72c3b9c --- /dev/null +++ b/test/account-device/service-fixture.ts @@ -0,0 +1,240 @@ +import {randomUUID} from 'node:crypto' + +import {AccountDeviceAuthService} from '../../src/account-device/service.js' +import {AccountDeviceApi} from '../../src/account-device/transport.js' +import { + AccountDeviceStorageError, + type AccountDeviceProfiles, + type AccountDeviceProfilesLike, + type AccountDeviceCredentials, +} from '../../src/account-device/storage.js' + +export const CONFIG = { + issuer: 'https://api.account.example.invalid', + consoleOrigin: 'https://console.account.example.invalid', +} +export const NOW = Date.UTC(2026, 10, 5, 12) +export function deferred() { + let resolve!: (value: T) => void + const promise = new Promise((yes) => { + resolve = yes + }) + return {promise, resolve} +} +function json(value: unknown, status = 200) { + return new Response(JSON.stringify(value), { + status, + headers: {'content-type': 'application/json'}, + }) +} +function encode(value: unknown) { + return Buffer.from(JSON.stringify(value)).toString('base64url') +} + +// Flow fakes never touch the OS keyring or disk. The transport still uses real +// Ed25519 signing/verification and the exact API-owned route/response shapes. +export class MemoryProfiles implements AccountDeviceProfilesLike { + current: AccountDeviceProfiles = {version: 1, revision: null, profiles: {}} + failSave: 'before' | 'after' | 'unreadable' | undefined + failNextRead = false + load(): Promise { + if (this.failNextRead) { + this.failNextRead = false + return Promise.reject(new Error('synthetic metadata read failure')) + } + return Promise.resolve(structuredClone(this.current)) + } + async saveIfRevision( + expected: string | null, + next: Omit, + guard?: () => void, + ) { + if (this.current.revision !== expected) + throw new AccountDeviceStorageError('changed') + if (this.failSave === 'before') + throw new Error('synthetic before publication') + guard?.() + this.current = structuredClone({...next, revision: randomUUID()}) + if (this.failSave === 'unreadable') { + this.failNextRead = true + throw new Error('synthetic unknown publication') + } + if (this.failSave === 'after') + throw new Error('synthetic cleanup after publication') + return this.load() + } +} +export class MemoryCredentials implements AccountDeviceCredentials { + readonly values = new Map() + failDelete = false + get(account: string) { + return Promise.resolve(this.values.get(account)) + } + set(account: string, token: string) { + this.values.set(account, token) + return Promise.resolve() + } + delete(account: string) { + if (this.failDelete) + return Promise.reject(new Error('synthetic keyring unavailable')) + this.values.delete(account) + return Promise.resolve() + } +} + +export async function serviceFixture() { + const pair = await crypto.subtle.generateKey({name: 'Ed25519'}, true, [ + 'sign', + 'verify', + ]) + if (!('privateKey' in pair)) throw new Error('expected key pair') + const jwk = { + ...(await crypto.subtle.exportKey('jwk', pair.publicKey)), + kid: 'own-key', + alg: 'EdDSA', + use: 'sig', + } + const sessions = new Map(), + jwts = new Map() + const calls: {path: string; headers: Headers; body: unknown}[] = [] + const revoked: string[] = [] + const polling: ('pending' | 'slowDown')[] = [] + let count = 0, + clock = 0 + let pollBarrier: ReturnType> | undefined + let refreshBarrier: ReturnType> | undefined + let pollStarted = deferred(), + refreshStarted = deferred() + const fetcher: typeof fetch = async (input, init) => { + const url = + input instanceof Request + ? new URL(input.url) + : input instanceof URL + ? input + : new URL(input) + const headers = new Headers(init?.headers) + const body: unknown = + typeof init?.body === 'string' ? JSON.parse(init.body) : null + calls.push({path: url.pathname, headers, body}) + const token = headers.get('authorization')?.replace(/^Bearer /, '') ?? '' + if (url.pathname === '/api/auth/device/code') + return json({ + device_code: `synthetic-device-${++count}`, + user_code: `CODE-${count}`, + verification_uri: `${CONFIG.consoleOrigin}/device`, + interval: 1, + expires_in: 300, + }) + if (url.pathname === '/api/auth/device/token') { + const waiting = polling.shift() + if (waiting) + return json( + { + error: + waiting === 'pending' ? 'authorization_pending' : 'slow_down', + }, + 400, + ) + const opaque = `synthetic-session-token-${++count}` + sessions.set(opaque, {id: `session-${count}`, userId: 'own-user'}) + pollStarted.resolve() + if (pollBarrier) await pollBarrier.promise + return json({ + access_token: opaque, + token_type: 'bearer', + expires_in: 3600, + }) + } + if (url.pathname === '/api/auth/get-session') { + const session = sessions.get(token) + return session + ? json({ + session: { + ...session, + token, + expiresAt: new Date(NOW + 3_600_000).toISOString(), + }, + user: {id: session.userId}, + }) + : json(null) + } + if (url.pathname === '/api/auth/token') { + const session = sessions.get(token) + if (!session) return json({error: 'unauthenticated'}, 401) + const header = encode({alg: 'EdDSA', kid: 'own-key', typ: 'JWT'}) + const payload = encode({ + iss: CONFIG.issuer, + aud: 'polymorfa-dashboard-api', + sid: session.id, + sub: session.userId, + iat: NOW / 1000, + exp: NOW / 1000 + 900, + }) + const signature = Buffer.from( + await crypto.subtle.sign( + 'Ed25519', + pair.privateKey, + new TextEncoder().encode(`${header}.${payload}`), + ), + ).toString('base64url') + const jwt = `${header}.${payload}.${signature}` + jwts.set(jwt, session.id) + refreshStarted.resolve() + if (refreshBarrier) await refreshBarrier.promise + return json({token: jwt}) + } + if (url.pathname === '/api/auth/jwks') return json({keys: [jwk]}) + if (url.pathname === '/api/auth/sign-out') { + const session = sessions.get(token) + if (session) revoked.push(session.id) + sessions.delete(token) + return json({success: true}) + } + if (url.pathname.startsWith('/api/account/device/')) { + const sid = jwts.get(token) + if (!sid || ![...sessions.values()].some((session) => session.id === sid)) + return json({error: 'revoked'}, 401) + if (url.pathname.endsWith('/teams')) + return json({teams: [], nextAfter: null}) + return json({projects: [], nextAfter: null}) + } + throw new Error('unexpected route') + } + const profiles = new MemoryProfiles(), + credentials = new MemoryCredentials() + const service = new AccountDeviceAuthService( + profiles, + credentials, + (config) => new AccountDeviceApi(config, fetcher, () => NOW), + () => clock, + () => NOW, + ) + return { + service, + profiles, + credentials, + calls, + sessions, + revoked, + polling, + tick: (milliseconds: number) => { + clock += milliseconds + }, + pausePoll: () => { + pollBarrier = deferred() + pollStarted = deferred() + return { + started: pollStarted.promise, + release: () => pollBarrier!.resolve(), + } + }, + pauseRefresh: () => { + refreshBarrier = deferred() + refreshStarted = deferred() + return { + started: refreshStarted.promise, + release: () => refreshBarrier!.resolve(), + } + }, + } +} diff --git a/test/account-device/service.test.ts b/test/account-device/service.test.ts new file mode 100644 index 00000000..10957e21 --- /dev/null +++ b/test/account-device/service.test.ts @@ -0,0 +1,244 @@ +import {describe, expect, it} from 'vitest' + +import {AccountDeviceLoginError} from '../../src/account-device/service.js' +import {CONFIG, serviceFixture} from './service-fixture.js' + +async function login( + f: Awaited>, + name = 'own', +) { + const handle = await f.service.startLogin(name, CONFIG) + f.tick(f.service.pollDelay(handle)) + const result = await f.service.pollLogin(handle) + expect(result.kind).toBe('signedIn') + return result +} + +describe('account-device login/refresh/logout lifecycle', () => { + it('stores the opaque session only in its separate keyring and reports truthful identity', async () => { + const f = await serviceFixture(), + result = await login(f) + expect(result).toMatchObject({ + profile: 'own', + remoteIdentityValidated: true, + localCleanupConfirmed: true, + }) + expect(f.credentials.values.size).toBe(1) + const token = [...f.credentials.values.values()][0]! + expect(JSON.stringify(f.profiles.current)).not.toContain(token) + expect(JSON.stringify(result)).not.toContain(token) + expect(await f.service.status()).toMatchObject({ + kind: 'accountDevice', + credentialPresent: true, + remoteIdentityValidated: true, + }) + await expect(f.service.startLogin('own', CONFIG)).rejects.toMatchObject({ + code: 'refused', + }) + }) + + it('enforces pending/slow-down spacing and the original deadline without a cloned-handle authority', async () => { + const f = await serviceFixture(), + handle = await f.service.startLogin('own', CONFIG) + const before = f.calls.length + await expect(f.service.pollLogin(handle)).rejects.toMatchObject({ + code: 'refused', + }) + expect(f.calls).toHaveLength(before) + await expect(f.service.pollLogin({...handle})).rejects.toMatchObject({ + code: 'login_required', + }) + f.polling.push('pending', 'slowDown') + f.tick(f.service.pollDelay(handle)) + expect(await f.service.pollLogin(handle)).toEqual({ + kind: 'pending', + waitMilliseconds: 1000, + }) + f.tick(1000) + expect(await f.service.pollLogin(handle)).toEqual({ + kind: 'pending', + waitMilliseconds: 6000, + }) + f.tick(300_000) + await expect(f.service.pollLogin(handle)).rejects.toMatchObject({ + code: 'login_required', + }) + expect(f.credentials.values.size).toBe(0) + }) + + it('refuses a concurrent poll without abandoning or duplicating the original redemption', async () => { + const f = await serviceFixture(), + handle = await f.service.startLogin('own', CONFIG), + barrier = f.pausePoll() + f.tick(f.service.pollDelay(handle)) + const first = f.service.pollLogin(handle) + await barrier.started + await expect(f.service.pollLogin(handle)).rejects.toMatchObject({ + code: 'refused', + }) + barrier.release() + expect(await first).toMatchObject({kind: 'signedIn'}) + expect( + f.calls.filter((call) => call.path === '/api/auth/device/token'), + ).toHaveLength(1) + }) + + it('cleans up a known late redeemed session after cancellation without saving a credential', async () => { + const f = await serviceFixture(), + handle = await f.service.startLogin('own', CONFIG), + barrier = f.pausePoll() + f.tick(f.service.pollDelay(handle)) + const result = f.service.pollLogin(handle) + await barrier.started + f.service.cancelLogin(handle) + barrier.release() + await expect(result).rejects.toMatchObject({ + recovery: { + profileSaved: false, + credentialPreserved: false, + remoteRevocation: 'confirmed', + }, + }) + expect(f.revoked).toHaveLength(1) + expect(f.credentials.values.size).toBe(0) + expect(Object.keys(f.profiles.current.profiles)).toHaveLength(0) + }) + + it('rolls back only the fresh unreferenced credential and revokes only its new session on a refused save', async () => { + const f = await serviceFixture() + f.profiles.failSave = 'before' + await expect(login(f)).rejects.toBeInstanceOf(AccountDeviceLoginError) + expect(f.credentials.values.size).toBe(0) + expect(f.revoked).toHaveLength(1) + expect(Object.keys(f.profiles.current.profiles)).toHaveLength(0) + }) + + it('retains a credential/session when exact readback proves metadata was published before an error', async () => { + const f = await serviceFixture() + f.profiles.failSave = 'after' + expect(await login(f)).toMatchObject({ + kind: 'signedIn', + localCleanupConfirmed: false, + }) + expect(f.credentials.values.size).toBe(1) + expect(f.revoked).toHaveLength(0) + expect(await f.service.status()).toMatchObject({ + remoteIdentityValidated: true, + }) + }) + + it('preserves the sole credential and does not infer rollback when publication cannot be read back', async () => { + const f = await serviceFixture() + f.profiles.failSave = 'unreadable' + await expect(login(f)).rejects.toMatchObject({ + recovery: { + profileSaved: 'unknown', + credentialPreserved: true, + remoteRevocation: 'not_exercised', + }, + }) + expect(f.credentials.values.size).toBe(1) + expect(f.revoked).toHaveLength(0) + expect(await f.service.status()).toMatchObject({ + remoteIdentityValidated: true, + }) + }) + + it('coalesces actual JWT refreshes per profile and never rotates the opaque keyring token', async () => { + const f = await serviceFixture() + await login(f) + const before = [...f.credentials.values.entries()] + // A separate command/service instance starts with an empty in-memory JWT. + await f.service.use('own') + const tokenCalls = f.calls.filter( + (call) => call.path === '/api/auth/token', + ).length + const results = await Promise.all([ + f.service.teams('own'), + f.service.teams('own'), + ]) + expect(results).toEqual([ + {teams: [], nextAfter: null}, + {teams: [], nextAfter: null}, + ]) + expect( + f.calls.filter((call) => call.path === '/api/auth/token'), + ).toHaveLength(tokenCalls + 1) + expect([...f.credentials.values.entries()]).toEqual(before) + }) + + it('refuses a late refresh after logout without restoring a cache or issuing a resource request', async () => { + const f = await serviceFixture() + await login(f) + await f.service.use('own') + const barrier = f.pauseRefresh(), + request = f.service.teams('own') + await barrier.started + const logout = await f.service.logout('own') + expect(logout).toMatchObject({ + remoteRevocation: 'confirmed', + localMetadataRemoved: true, + localCredentialRemoved: true, + }) + barrier.release() + await expect(request).rejects.toMatchObject({code: 'changed'}) + expect( + f.calls.filter((call) => call.path === '/api/account/device/teams'), + ).toHaveLength(0) + await expect(f.service.status('own')).rejects.toMatchObject({ + code: 'login_required', + }) + }) + + it('revokes/removes exactly one profile while preserving another device and its local selection', async () => { + const f = await serviceFixture() + await login(f, 'one') + await login(f, 'two') + const second = f.profiles.current.profiles.two! + const secondToken = f.credentials.values.get(second.credentialAccount) + const result = await f.service.logout('one') + expect(result).toMatchObject({ + remoteRevocation: 'confirmed', + localMetadataRemoved: true, + localCredentialRemoved: true, + }) + expect(f.profiles.current.activeProfile).toBe('two') + expect(f.profiles.current.profiles.two).toEqual(second) + expect(f.credentials.values.get(second.credentialAccount)).toBe(secondToken) + expect( + [...f.sessions.values()].some( + (session) => session.id === second.sessionId, + ), + ).toBe(true) + expect(await f.service.status('two')).toMatchObject({ + remoteIdentityValidated: true, + }) + }) + + it('reports remote revocation separately from a failed local keyring deletion', async () => { + const f = await serviceFixture() + await login(f) + f.credentials.failDelete = true + expect(await f.service.logout()).toMatchObject({ + remoteRevocation: 'confirmed', + localMetadataRemoved: true, + localCredentialRemoved: false, + }) + expect(f.credentials.values.size).toBe(1) + }) + + it('does not save or activate an older login over a newer metadata change', async () => { + const f = await serviceFixture(), + handle = await f.service.startLogin('own', CONFIG) + const empty = await f.profiles.load() + await f.profiles.saveIfRevision(empty.revision, {version: 1, profiles: {}}) + const changed = f.profiles.current.revision + f.tick(f.service.pollDelay(handle)) + await expect(f.service.pollLogin(handle)).rejects.toBeInstanceOf( + AccountDeviceLoginError, + ) + expect(f.profiles.current.revision).toBe(changed) + expect(f.credentials.values.size).toBe(0) + expect(f.revoked).toHaveLength(1) + }) +}) diff --git a/test/account-device/storage.test.ts b/test/account-device/storage.test.ts new file mode 100644 index 00000000..93e6cd4d --- /dev/null +++ b/test/account-device/storage.test.ts @@ -0,0 +1,257 @@ +import { + mkdir, + mkdtemp, + readFile, + rm, + stat, + unlink, + writeFile, +} from 'node:fs/promises' +import {homedir} from 'node:os' +import {join} from 'node:path' +import {afterEach, describe, expect, it, vi} from 'vitest' + +import { + AccountDeviceProfileRepository, + AccountDeviceStorageError, + SecureAccountDeviceCredentials, + accountDeviceCredentialAccount, + type AccountDeviceProfile, +} from '../../src/account-device/storage.js' + +const roots: string[] = [] +const TOKEN = 'synthetic-opaque-account-session' + +async function fixture() { + const base = join( + homedir(), + '.polymorfa-agent-work', + 'region-owner-rebase-20261028', + 'cli86-account-device-storage-tests-20261105', + ) + await mkdir(base, {recursive: true, mode: 0o700}) + const directory = await mkdtemp(join(base, 'case-')) + roots.push(directory) + return { + directory, + repo: new AccountDeviceProfileRepository( + join(directory, 'account-device.json'), + ), + } +} +function profile(name = 'own'): AccountDeviceProfile { + return { + kind: 'accountDevice', + issuer: 'https://api.account.example.invalid', + consoleOrigin: 'https://console.account.example.invalid', + userId: 'own-user', + sessionId: 'own-session', + expiresAtMs: 2_000_000_000_000, + credentialAccount: accountDeviceCredentialAccount(name), + } +} +afterEach(async () => { + vi.restoreAllMocks() + for (const root of roots.splice(0)) + await rm(root, {recursive: true, force: true}) +}) + +describe('separate account-device metadata and keyring', () => { + it('stores only metadata under a distinct file with private permissions', async () => { + const {repo, directory} = await fixture() + const machine = join(directory, 'config.json') + const old = + '{"version":2,"profiles":{"machine":{"credential":{"kind":"apiKey","account":"profile:machine:apiKey"}}}}\n' + await writeFile(machine, old) + expect(await repo.load()).toEqual({ + version: 1, + revision: null, + profiles: {}, + }) + const entry = profile() + const saved = await repo.saveIfRevision(null, { + version: 1, + activeProfile: 'own', + profiles: {own: entry}, + }) + expect(saved.profiles.own).toEqual(entry) + expect(saved.revision).not.toBeNull() + expect(Object.isFrozen(saved)).toBe(true) + expect(Object.isFrozen(saved.profiles.own)).toBe(true) + expect((await stat(repo.path)).mode & 0o777).toBe(0o600) + expect(await readFile(machine, 'utf8')).toBe(old) + expect(await readFile(repo.path, 'utf8')).not.toContain(TOKEN) + await expect(stat(`${repo.path}.lock`)).rejects.toMatchObject({ + code: 'ENOENT', + }) + }) + + it('refuses stale mutations without removing a newer login or another profile', async () => { + const {repo} = await fixture(), + own = profile(), + other = profile('other') + const first = await repo.saveIfRevision(null, { + version: 1, + activeProfile: 'own', + profiles: {own}, + }) + const second = await repo.saveIfRevision(first.revision, { + version: 1, + activeProfile: 'other', + profiles: {own, other}, + }) + await expect( + repo.saveIfRevision(first.revision, {version: 1, profiles: {}}), + ).rejects.toMatchObject({code: 'changed'}) + expect(await repo.load()).toEqual(second) + }) + + it('permits exactly one concurrent writer with the same snapshot', async () => { + const {repo} = await fixture() + const attempts = await Promise.allSettled( + ['left', 'right'].map((name) => + repo.saveIfRevision(null, { + version: 1, + activeProfile: name, + profiles: {[name]: profile(name)}, + }), + ), + ) + expect( + attempts.filter((result) => result.status === 'fulfilled'), + ).toHaveLength(1) + expect( + attempts.filter((result) => result.status === 'rejected'), + ).toHaveLength(1) + expect(Object.keys((await repo.load()).profiles)).toHaveLength(1) + }) + + it('refuses an existing product metadata lock without touching it', async () => { + const {repo} = await fixture() + const path = `${repo.path}.lock` + await writeFile(path, 'synthetic-existing-product-writer', {flag: 'wx'}) + await expect( + repo.saveIfRevision(null, {version: 1, profiles: {}}), + ).rejects.toMatchObject({code: 'busy'}) + expect(await readFile(path, 'utf8')).toBe( + 'synthetic-existing-product-writer', + ) + expect(await repo.load()).toEqual({ + version: 1, + revision: null, + profiles: {}, + }) + }) + + it.each([ + {version: 2, profiles: {}}, + {version: 1, revision: 'bad', profiles: {}}, + { + version: 1, + revision: '11111111-1111-4111-8111-111111111111', + profiles: {}, + token: TOKEN, + }, + ])( + 'refuses machine/unknown/raw-secret config shapes without fallback: %j', + async (value) => { + const {repo} = await fixture() + await writeFile(repo.path, JSON.stringify(value)) + await expect(repo.load()).rejects.toBeInstanceOf( + AccountDeviceStorageError, + ) + }, + ) + + it('refuses raw token fields, cross-profile references, repointing and orphan project selectors', async () => { + const {repo} = await fixture(), + own = profile() + for (const bad of [ + {...own, token: TOKEN}, + {...own, credentialAccount: 'profile:own:apiKey'}, + {...own, credentialAccount: accountDeviceCredentialAccount('other')}, + {...own, issuer: 'https://api.account.example.invalid/override'}, + {...own, projectId: '22222222-2222-4222-8222-222222222222'}, + ]) { + await expect( + repo.saveIfRevision(null, {version: 1, profiles: {own: bad}}), + ).rejects.toMatchObject({code: 'invalid'}) + } + expect((await repo.load()).revision).toBeNull() + }) + + it('keeps a replaced product lock and exposes post-rename uncertainty rather than assuming rollback', async () => { + const {repo} = await fixture(), + entry = profile() + vi.spyOn(repo, 'load').mockImplementationOnce(async () => { + // Only this disposable product fixture is altered; no agent-runner or + // foreign lock/process is inspected, reclaimed or modified. + await unlink(`${repo.path}.lock`) + await writeFile(`${repo.path}.lock`, 'synthetic-replacement', { + flag: 'wx', + }) + return {version: 1, revision: null, profiles: {}} + }) + await expect( + repo.saveIfRevision(null, {version: 1, profiles: {own: entry}}), + ).rejects.toMatchObject({code: 'busy'}) + expect(await readFile(`${repo.path}.lock`, 'utf8')).toBe( + 'synthetic-replacement', + ) + // A service must re-read exact publication before deleting/revoking a + // fresh keyring reference after any uncertain save error. + expect((await repo.load()).profiles.own).toEqual(entry) + }) + + it('uses a fresh separate keyring namespace and cannot query machine references', async () => { + const values = new Map(), + calls: {service: string; account: string}[] = [] + const credentials = new SecureAccountDeviceCredentials( + (service, account) => { + calls.push({service, account}) + return { + getPassword: () => values.get(account), + setPassword: (value) => { + values.set(account, value) + }, + deletePassword: () => values.delete(account), + } + }, + ) + const first = accountDeviceCredentialAccount('own'), + second = accountDeviceCredentialAccount('own') + expect(first).not.toBe(second) + await credentials.set(first, TOKEN) + expect(await credentials.get(first)).toBe(TOKEN) + await credentials.delete(first) + expect(await credentials.get(first)).toBeUndefined() + expect( + calls.every( + (call) => + call.service === 'com.polymorfa.cli.account-device-sessions' && + call.account === first, + ), + ).toBe(true) + const count = calls.length + await expect(credentials.get('profile:own:apiKey')).rejects.toMatchObject({ + code: 'invalid', + }) + expect(calls).toHaveLength(count) + }) + + it('sanitizes keyring errors without recommending a machine-credential fallback', async () => { + const credentials = new SecureAccountDeviceCredentials(() => { + throw new Error(`synthetic failure ${TOKEN}`) + }) + const account = accountDeviceCredentialAccount('own') + for (const operation of [ + () => credentials.get(account), + () => credentials.set(account, TOKEN), + () => credentials.delete(account), + ]) { + await expect(operation()).rejects.toMatchObject({code: 'keyring'}) + await expect(operation()).rejects.not.toThrow(TOKEN) + await expect(operation()).rejects.not.toThrow('POLYMORFA_API_KEY') + } + }) +}) diff --git a/test/account-device/transport.test.ts b/test/account-device/transport.test.ts new file mode 100644 index 00000000..e7507495 --- /dev/null +++ b/test/account-device/transport.test.ts @@ -0,0 +1,484 @@ +import {describe, expect, it, vi} from 'vitest' + +import { + AccountDeviceApi, + AccountDeviceError, + type AccountDeviceJwt, +} from '../../src/account-device/transport.js' + +const ISSUER = 'https://api.account.example.invalid' +const CONSOLE = 'https://console.account.example.invalid' +const OPAQUE = 'synthetic-opaque-account-session' +const NOW = Date.UTC(2026, 10, 5, 12) +const TEAM = '11111111-1111-4111-8111-111111111111' +const PROJECT = '22222222-2222-4222-8222-222222222222' +const configuration = {issuer: ISSUER, consoleOrigin: CONSOLE} +const identity = { + sessionId: 'own-session', + userId: 'own-user', + expiresAtMs: NOW + 3_600_000, +} + +function json(value: unknown, status = 200) { + return new Response(JSON.stringify(value), { + status, + headers: {'content-type': 'application/json'}, + }) +} +function liveIdentity() { + return { + session: { + id: identity.sessionId, + userId: identity.userId, + expiresAt: new Date(identity.expiresAtMs).toISOString(), + token: OPAQUE, + }, + user: {id: identity.userId, email: 'not-returned@example.invalid'}, + } +} +function body(init: RequestInit): string { + if (typeof init.body !== 'string') + throw new Error('expected actual JSON string body') + return init.body +} + +function encoded(value: unknown): string { + return Buffer.from(JSON.stringify(value)).toString('base64url') +} + +async function signed( + changes: Record = {}, + headerChanges: Record = {}, +) { + const pair = await crypto.subtle.generateKey({name: 'Ed25519'}, true, [ + 'sign', + 'verify', + ]) + if (!('privateKey' in pair)) throw new Error('expected Ed25519 key pair') + const key = { + ...(await crypto.subtle.exportKey('jwk', pair.publicKey)), + kid: 'own-key', + alg: 'EdDSA', + use: 'sig', + } + const header = encoded({ + alg: 'EdDSA', + typ: 'JWT', + kid: 'own-key', + ...headerChanges, + }) + const payload = encoded({ + iss: ISSUER, + aud: 'polymorfa-dashboard-api', + sub: identity.userId, + sid: identity.sessionId, + iat: NOW / 1000, + exp: NOW / 1000 + 900, + ...changes, + }) + const signature = Buffer.from( + await crypto.subtle.sign( + 'Ed25519', + pair.privateKey, + new TextEncoder().encode(`${header}.${payload}`), + ), + ).toString('base64url') + return {token: `${header}.${payload}.${signature}`, jwks: {keys: [key]}} +} + +async function server( + changes: Record = {}, + headerChanges: Record = {}, +) { + const signature = await signed(changes, headerChanges) + const calls: {url: URL; init: RequestInit | undefined}[] = [] + const responses = new Map Response>([ + ['/api/auth/get-session', () => json(liveIdentity())], + ['/api/auth/token', () => json({token: signature.token})], + ['/api/auth/jwks', () => json(signature.jwks)], + ]) + const request = vi.fn((input, init) => { + const url = + input instanceof Request + ? new URL(input.url) + : input instanceof URL + ? input + : new URL(input) + calls.push({url, init}) + const response = responses.get(url.pathname) + if (!response) throw new Error('unexpected route') + return Promise.resolve(response()) + }) + let now = NOW + const api = new AccountDeviceApi(configuration, request, () => now) + return { + api, + request, + calls, + responses, + signature, + setNow: (value: number) => { + now = value + }, + } +} + +describe('separate account-device issuer-bound transport', () => { + it.each([ + 'http://api.example.invalid', + `${ISSUER}/api`, + `${ISSUER}?x=1`, + `${ISSUER}#x`, + 'https://user:secret@example.invalid', + 'invalid', + ])('refuses an untrusted origin shape without fetch: %s', (origin) => { + const request = vi.fn() + expect( + () => new AccountDeviceApi({...configuration, issuer: origin}, request), + ).toThrow(AccountDeviceError) + expect(request).not.toHaveBeenCalled() + }) + + it('pins immutable configuration and has no public raw request method', () => { + const api = new AccountDeviceApi(configuration) + expect( + Reflect.set(api, 'configuration', { + issuer: 'https://foreign.example.invalid', + }), + ).toBe(false) + expect(Object.isFrozen(api.configuration)).toBe(true) + expect(Reflect.has(api, 'send')).toBe(false) + }) + + it('starts/polls with the registered client and no existing profile credential or cookie', async () => { + const responses = [ + json({ + device_code: 'synthetic-code', + user_code: 'USER-CODE', + verification_uri: `${CONSOLE}/device`, + interval: 5, + expires_in: 300, + }), + json({error: 'authorization_pending'}, 400), + json({error: 'slow_down'}, 400), + json({access_token: OPAQUE, token_type: 'bearer', expires_in: 86_400}), + ] + const calls: RequestInit[] = [] + const request = vi.fn((_url, init) => { + if (init) calls.push(init) + return Promise.resolve(responses.shift()!) + }) + const api = new AccountDeviceApi(configuration, request) + expect(await api.start()).toEqual({ + deviceCode: 'synthetic-code', + userCode: 'USER-CODE', + verificationUri: `${CONSOLE}/device`, + intervalSeconds: 5, + expiresInSeconds: 300, + }) + expect(await api.poll('synthetic-code')).toEqual({kind: 'pending'}) + expect(await api.poll('synthetic-code')).toEqual({kind: 'slowDown'}) + expect(await api.poll('synthetic-code')).toEqual({ + kind: 'session', + sessionToken: OPAQUE, + }) + for (const call of calls) { + expect(call).toMatchObject({ + credentials: 'omit', + redirect: 'error', + cache: 'no-store', + referrerPolicy: 'no-referrer', + method: 'POST', + }) + expect(new Headers(call.headers).has('authorization')).toBe(false) + expect(new Headers(call.headers).has('cookie')).toBe(false) + } + expect(JSON.parse(body(calls[0]!))).toEqual({ + client_id: 'polymorfa-cli', + }) + expect(JSON.parse(body(calls[1]!))).toEqual({ + client_id: 'polymorfa-cli', + device_code: 'synthetic-code', + grant_type: 'urn:ietf:params:oauth:grant-type:device_code', + }) + }) + + it.each([ + `${CONSOLE}/device?next=foreign`, + 'https://foreign.example.invalid/device', + `${CONSOLE}/device/`, + ])('refuses a foreign/aliased verification page: %s', (verification_uri) => { + const api = new AccountDeviceApi(configuration, () => + Promise.resolve( + json({ + device_code: 'synthetic-code', + user_code: 'CODE', + interval: 5, + expires_in: 300, + verification_uri, + }), + ), + ) + return expect(api.start()).rejects.toMatchObject({code: 'invalid_response'}) + }) + + it('returns only bound identity metadata, never the raw session or token', async () => { + const s = await server() + expect(await s.api.identity(OPAQUE)).toEqual(identity) + expect(JSON.stringify(await s.api.identity(OPAQUE))).not.toContain(OPAQUE) + s.responses.set('/api/auth/get-session', () => json(null)) + await expect(s.api.identity(OPAQUE)).rejects.toMatchObject({ + code: 'login_required', + }) + }) + + it('cryptographically verifies the bound JWT and sends it only to the two exact discovery routes', async () => { + const s = await server() + const jwt = await s.api.jwt(OPAQUE, identity) + s.responses.set('/api/account/device/teams', () => + json({ + teams: [ + { + teamId: TEAM, + teamName: 'Team', + teamSlug: 'team', + config: 'not-returned', + }, + ], + nextAfter: TEAM, + }), + ) + s.responses.set('/api/account/device/projects', () => + json({ + projects: [ + { + projectId: PROJECT, + name: 'Project', + slug: 'project', + role: 'member', + }, + ], + nextAfter: null, + }), + ) + expect(await s.api.teams(jwt, {limit: 1})).toEqual({ + teams: [{teamId: TEAM, teamName: 'Team', teamSlug: 'team'}], + nextAfter: TEAM, + }) + expect(await s.api.projects(jwt, TEAM, {limit: 2, after: PROJECT})).toEqual( + { + projects: [ + { + projectId: PROJECT, + name: 'Project', + slug: 'project', + role: 'member', + }, + ], + nextAfter: null, + }, + ) + expect(JSON.stringify(jwt)).not.toContain(s.signature.token) + for (const call of s.calls) { + expect(call.url.origin).toBe(ISSUER) + expect(call.init?.credentials).toBe('omit') + expect(call.init?.redirect).toBe('error') + const auth = new Headers(call.init?.headers).get('authorization') + if (call.url.pathname === '/api/auth/jwks') expect(auth).toBeNull() + else + expect(auth).toBe( + `Bearer ${call.url.pathname.startsWith('/api/account/device/') ? s.signature.token : OPAQUE}`, + ) + } + expect(s.calls.at(-1)?.url.searchParams.get('teamId')).toBe(TEAM) + expect(s.calls.at(-1)?.url.searchParams.get('after')).toBe(PROJECT) + }) + + it.each([ + {iss: 'https://foreign.example.invalid'}, + {aud: 'other-api'}, + {sub: 'another-user'}, + {sid: 'another-session'}, + {iat: NOW / 1000 + 1}, + {exp: NOW / 1000}, + {exp: NOW / 1000 + 901}, + ])('refuses a signed JWT with wrong binding/lifetime %j', async (changes) => { + const s = await server(changes) + await expect(s.api.jwt(OPAQUE, identity)).rejects.toMatchObject({ + code: 'login_required', + }) + expect( + s.calls.some((call) => + call.url.pathname.startsWith('/api/account/device/'), + ), + ).toBe(false) + }) + + it.each([ + {alg: 'HS256'}, + {jku: 'https://foreign.example.invalid/key'}, + {crit: ['unknown']}, + ])('refuses token-controlled algorithm/key discovery %j', async (headers) => { + const s = await server({}, headers) + await expect(s.api.jwt(OPAQUE, identity)).rejects.toMatchObject({ + code: 'invalid_response', + }) + expect(s.calls.every((call) => call.url.origin === ISSUER)).toBe(true) + }) + + it('rejects an invalid signature and duplicate/private verification keys', async () => { + for (const fault of ['signature', 'duplicate', 'private']) { + const s = await server() + if (fault === 'signature') { + const parts = s.signature.token.split('.') + parts[2] = Buffer.alloc(64).toString('base64url') + s.responses.set('/api/auth/token', () => json({token: parts.join('.')})) + } else if (fault === 'duplicate') + s.signature.jwks.keys.push(s.signature.jwks.keys[0]!) + else + s.responses.set('/api/auth/jwks', () => + json({keys: [{...s.signature.jwks.keys[0], d: 'private-material'}]}), + ) + await expect(s.api.jwt(OPAQUE, identity)).rejects.toMatchObject({ + code: 'invalid_response', + }) + } + }) + + it('refuses copied/unverified/foreign-issuer/expired JWT handles before fetch', async () => { + const s = await server() + const jwt = await s.api.jwt(OPAQUE, identity) + const count = s.calls.length + await expect(s.api.teams({...jwt})).rejects.toMatchObject({ + code: 'login_required', + }) + await expect(s.api.teams({} as AccountDeviceJwt)).rejects.toMatchObject({ + code: 'login_required', + }) + const other = new AccountDeviceApi( + {...configuration, issuer: 'https://foreign.example.invalid'}, + s.request, + ) + await expect(other.teams(jwt)).rejects.toMatchObject({ + code: 'login_required', + }) + s.setNow(jwt.expiresAtMs) + await expect(s.api.teams(jwt)).rejects.toMatchObject({ + code: 'login_required', + }) + expect(s.calls).toHaveLength(count) + }) + + it('requires the exact live session and never extends its expiry from the JWT', async () => { + const s = await server() + await expect( + s.api.jwt(OPAQUE, {...identity, sessionId: 'other'}), + ).rejects.toMatchObject({code: 'login_required'}) + s.responses.set('/api/auth/get-session', () => + json({ + ...liveIdentity(), + session: { + ...liveIdentity().session, + expiresAt: new Date(NOW + 10_000).toISOString(), + }, + }), + ) + expect((await s.api.jwt(OPAQUE, identity)).expiresAtMs).toBe(NOW + 10_000) + }) + + it('refuses malformed selectors without issuing a resource request', async () => { + const s = await server(), + jwt = await s.api.jwt(OPAQUE, identity) + const count = s.calls.length + await expect(s.api.projects(jwt, '../other')).rejects.toMatchObject({ + code: 'refused', + }) + await expect(s.api.teams(jwt, {limit: 101})).rejects.toMatchObject({ + code: 'refused', + }) + await expect(s.api.teams(jwt, {after: 'not-a-uuid'})).rejects.toMatchObject( + {code: 'refused'}, + ) + expect(s.calls).toHaveLength(count) + }) + + it('refuses malformed DTO roles/cursors and preserves unavailable/refused outcomes', async () => { + const s = await server(), + jwt = await s.api.jwt(OPAQUE, identity) + s.responses.set('/api/account/device/projects', () => + json({ + projects: [ + { + projectId: PROJECT, + name: 'Project', + slug: 'project', + role: 'superadmin', + }, + ], + nextAfter: null, + }), + ) + await expect(s.api.projects(jwt, TEAM)).rejects.toMatchObject({ + code: 'invalid_response', + }) + s.responses.set('/api/account/device/teams', () => + json({teams: [], nextAfter: TEAM}), + ) + await expect(s.api.teams(jwt)).rejects.toMatchObject({ + code: 'invalid_response', + }) + s.responses.set('/api/account/device/teams', () => + json({error: 'no access'}, 403), + ) + await expect(s.api.teams(jwt)).rejects.toMatchObject({ + code: 'login_required', + }) + s.responses.set('/api/account/device/teams', () => + json({code: 'account_authority_unavailable'}, 503), + ) + await expect(s.api.teams(jwt)).rejects.toMatchObject({ + code: 'authority_unavailable', + }) + }) + + it('signs out only the supplied opaque session and requires actual success', async () => { + const s = await server() + s.responses.set('/api/auth/sign-out', () => json({success: true})) + await s.api.signOut(OPAQUE) + const call = s.calls.at(-1)! + expect(call.init?.method).toBe('POST') + expect(call.init?.body).toBeUndefined() + expect(new Headers(call.init?.headers).get('authorization')).toBe( + `Bearer ${OPAQUE}`, + ) + s.responses.set('/api/auth/sign-out', () => json({success: false})) + await expect(s.api.signOut(OPAQUE)).rejects.toMatchObject({ + code: 'invalid_response', + }) + }) + + it('never exposes upstream HTML/errors or an oversized raw session body', async () => { + const secretError = 'upstream-secret-material' + const bad = new AccountDeviceApi(configuration, () => + Promise.resolve( + new Response(secretError, { + status: 502, + headers: {'content-type': 'text/html'}, + }), + ), + ) + await expect(bad.identity(OPAQUE)).rejects.not.toThrow(secretError) + const unavailable = new AccountDeviceApi(configuration, () => + Promise.reject(new Error(secretError)), + ) + await expect(unavailable.identity(OPAQUE)).rejects.toMatchObject({ + code: 'authority_unavailable', + }) + const large = new AccountDeviceApi(configuration, () => + Promise.resolve(json({padding: 'x'.repeat(1024 * 1024)})), + ) + await expect(large.identity(OPAQUE)).rejects.toMatchObject({ + code: 'invalid_response', + }) + }) +}) diff --git a/test/commands/analytics.test.ts b/test/commands/analytics.test.ts index 616f73a3..3bf084e9 100644 --- a/test/commands/analytics.test.ts +++ b/test/commands/analytics.test.ts @@ -34,8 +34,7 @@ test('runs the JSON analytics command against the project-confined public route' { POLYMORFA_API_KEY: undefined, POLYMORFA_CLIENT_TOKEN: undefined, - POLYMORFA_PROJECT_TOKEN: - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, }, () => @@ -68,8 +67,7 @@ test('rejects an invalid range before making a request', async () => { servers.push(server) const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_PROJECT_TOKEN: undefined, POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_BASE_URL: server.baseUrl, @@ -120,8 +118,7 @@ test('shows call vitals when messaging measurements are unavailable', async () = servers.push(server) const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_PROJECT_TOKEN: undefined, POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_BASE_URL: server.baseUrl, @@ -136,8 +133,7 @@ test('shows call vitals when messaging measurements are unavailable', async () = const json = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_PROJECT_TOKEN: undefined, POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_BASE_URL: server.baseUrl, @@ -165,8 +161,7 @@ test('exports raw collector text through the project-confined SDK transport', as { POLYMORFA_API_KEY: undefined, POLYMORFA_CLIENT_TOKEN: undefined, - POLYMORFA_PROJECT_TOKEN: - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, }, () => @@ -199,8 +194,7 @@ test('rejects an HTML response instead of printing it as collector data', async servers.push(server) const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_PROJECT_TOKEN: undefined, POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_BASE_URL: server.baseUrl, diff --git a/test/commands/api-gh.test.ts b/test/commands/api-gh.test.ts index 361800de..3bcf599a 100644 --- a/test/commands/api-gh.test.ts +++ b/test/commands/api-gh.test.ts @@ -8,8 +8,7 @@ import { type TestHttpServer, } from '../helpers/http-server.js' -const API_KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' describe('api gh-style options', () => { let server: TestHttpServer | undefined diff --git a/test/commands/api.test.ts b/test/commands/api.test.ts index a0237750..daa54b38 100644 --- a/test/commands/api.test.ts +++ b/test/commands/api.test.ts @@ -17,7 +17,7 @@ describe('api', () => { test('sends raw Messaging requests with a project token', async () => { server = await startHttpServer({body: {success: true, data: {ready: true}}}) - const projectToken = `pmfa_pt_${'A'.repeat(94)}` + const projectToken = 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const result = await withEnvironment( { POLYMORFA_API_KEY: undefined, @@ -38,9 +38,9 @@ describe('api', () => { server = await startHttpServer({body: {success: true}}) const result = await withEnvironment( { - POLYMORFA_API_KEY: `pmfa_${'A'.repeat(72)}`, + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_CLIENT_TOKEN: undefined, - POLYMORFA_PROJECT_TOKEN: `pmfa_pt_${'A'.repeat(94)}`, + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, }, () => runCommand('api /messaging/health --surface messaging --json'), @@ -57,7 +57,7 @@ describe('api', () => { const previousDirectory = process.cwd() const keyring = vi .spyOn(AsyncEntry.prototype, 'getPassword') - .mockResolvedValue(`pmfa_pt_${'A'.repeat(94)}`) + .mockResolvedValue('pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k') tempDirectories.push(directory) try { await mkdir(configDirectory) @@ -125,7 +125,7 @@ describe('api', () => { process.chdir(directory) const result = await withEnvironment( { - POLYMORFA_API_KEY: `pmfa_${'A'.repeat(72)}`, + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_PROJECT_TOKEN: undefined, POLYMORFA_BASE_URL: server.baseUrl, @@ -145,8 +145,7 @@ describe('api', () => { }) const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -177,8 +176,7 @@ describe('api', () => { }) const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -218,8 +216,7 @@ describe('api', () => { const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -239,7 +236,7 @@ describe('api', () => { url: '/v1/projects', }) expect(server.requests[0]?.headers.authorization).toBe( - 'Bearer pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'Bearer pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', ) }) @@ -251,8 +248,7 @@ describe('api', () => { const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -281,8 +277,7 @@ describe('api', () => { const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -309,8 +304,7 @@ describe('api', () => { const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -331,8 +325,7 @@ describe('api', () => { test('redacts credentials from API errors and preserves the request ID', async () => { server = await startHttpServer({ body: { - error: - 'Invalid API key pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA.', + error: 'Invalid API key pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k.', }, headers: {'request-id': 'req_auth'}, status: 401, @@ -340,8 +333,7 @@ describe('api', () => { const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -360,9 +352,7 @@ describe('api', () => { }, schemaVersion: 1, }) - expect(result.stderr).not.toContain( - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - ) + expect(result.stderr).not.toContain('pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k') expect(result.stdout).toBe('') }) @@ -371,8 +361,7 @@ describe('api', () => { const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -395,8 +384,7 @@ describe('api', () => { const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -423,8 +411,7 @@ describe('api', () => { const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -457,8 +444,7 @@ describe('api', () => { const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -475,8 +461,7 @@ describe('api', () => { const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -495,8 +480,7 @@ describe('api', () => { const result = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, diff --git a/test/commands/audiences-optouts.test.ts b/test/commands/audiences-optouts.test.ts index 78763a96..fd1db3e9 100644 --- a/test/commands/audiences-optouts.test.ts +++ b/test/commands/audiences-optouts.test.ts @@ -172,7 +172,7 @@ describe('Audience and opt-out commands', () => { expect(result.stdout).not.toContain('Removed') }) - test('reads the organization keyword settings', async () => { + test('reads the team keyword settings', async () => { server = await startHttpServer({body: {data: settings()}}) const result = await apiKeyEnvironment(() => @@ -224,8 +224,7 @@ describe('Audience and opt-out commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, diff --git a/test/commands/auth.test.ts b/test/commands/auth.test.ts index e06e9277..0e02ae9b 100644 --- a/test/commands/auth.test.ts +++ b/test/commands/auth.test.ts @@ -55,7 +55,7 @@ describe('auth commands', () => { test('logs in from stdin, lists profiles, and switches active profile', async () => { await withConfig(async () => { const login = await withStdin( - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n', + 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k\n', async () => runCommand('auth login --with-api-key --profile work --json'), ) @@ -78,10 +78,8 @@ describe('auth commands', () => { test('reports keyring availability and logs out only the selected profile', async () => { await withConfig(async () => { - await withStdin( - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n', - async () => - runCommand('auth login --with-api-key --profile work --json'), + await withStdin('pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k\n', async () => + runCommand('auth login --with-api-key --profile work --json'), ) const status = await runCommand('auth status --profile work --json') @@ -104,8 +102,7 @@ describe('auth commands', () => { test('reports environment credential validation separately from remote exercise', async () => { await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + POLYMORFA_API_KEY: 'pmfa_YmJiYmJiYmJiYmJiYmJiYmJiYmKK0GQs', POLYMORFA_CLIENT_TOKEN: undefined, }, async () => { @@ -124,7 +121,7 @@ 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 token = `pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k\n` const login = await withStdin(token, async () => runCommand( 'auth login --with-project-token --profile project --project project_1 --json', @@ -156,7 +153,7 @@ describe('auth commands', () => { }) test('reports an environment project token without exposing it', async () => { - const token = `pmfa_pt_${'A'.repeat(94)}` + const token = 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' await withEnvironment( { POLYMORFA_API_KEY: undefined, diff --git a/test/commands/bansafe.test.ts b/test/commands/bansafe.test.ts index 5dcdd036..b8ef1dd1 100644 --- a/test/commands/bansafe.test.ts +++ b/test/commands/bansafe.test.ts @@ -490,8 +490,7 @@ describe('BanSafe commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, diff --git a/test/commands/billing-controls.test.ts b/test/commands/billing-controls.test.ts index 1841264b..5adc32c4 100644 --- a/test/commands/billing-controls.test.ts +++ b/test/commands/billing-controls.test.ts @@ -8,8 +8,7 @@ import { withEnvironment, type TestHttpServer, } from '../helpers/http-server.js' -const key = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const key = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const project = '22222222-2222-4222-8222-222222222222', number = '33333333-3333-4333-8333-333333333333' const priorities = { diff --git a/test/commands/business.test.ts b/test/commands/business.test.ts index 9bf7cd06..b6e903e6 100644 --- a/test/commands/business.test.ts +++ b/test/commands/business.test.ts @@ -544,8 +544,7 @@ describe('business commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -559,7 +558,8 @@ describe('business commands', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_BASE_URL: server.baseUrl, - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', }, run, ) diff --git a/test/commands/call-analytics.test.ts b/test/commands/call-analytics.test.ts index 7df50f74..26920c21 100644 --- a/test/commands/call-analytics.test.ts +++ b/test/commands/call-analytics.test.ts @@ -14,10 +14,8 @@ import { } from '../helpers/http-server.js' import {temporaryDirectory} from '../support/fs.js' -const API_KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' -const PROJECT_TOKEN = - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' +const PROJECT_TOKEN = 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const PROJECT = '11111111-1111-4111-8111-111111111111' const HEADER = diff --git a/test/commands/call-consent.test.ts b/test/commands/call-consent.test.ts index d7120986..7cee67d4 100644 --- a/test/commands/call-consent.test.ts +++ b/test/commands/call-consent.test.ts @@ -11,9 +11,9 @@ import { type TestHttpServer, } from '../helpers/http-server.js' -const API_KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' -const CLIENT_TOKEN = 'pmfa_ct_browser_token' +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' +const CLIENT_TOKEN = + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO' const OPT_OUT_ID = '0b9f6a52-8f0e-4a44-9d51-3c1f9d6f2a10' const NEXT_CURSOR = '1c9f6a52-8f0e-4a44-9d51-3c1f9d6f2a11' @@ -135,7 +135,7 @@ describe('call consent commands', () => { body: { error: { code: 'permission_denied', - message: "The team's call policy needs an organization credential.", + message: "The team's call policy needs an team key.", }, success: false, }, @@ -155,7 +155,7 @@ describe('call consent commands', () => { error: {category: 'conflict', code: 'state_conflict'}, }) expect(exitCode(refused.error)).toBe(3) - expect(refused.stderr).toContain('organization credential') + expect(refused.stderr).toContain('team key') }, 30_000) test('reports malformed SDK collection envelopes as server failures', async () => { diff --git a/test/commands/call-links.test.ts b/test/commands/call-links.test.ts index 457d7771..8a9d3e00 100644 --- a/test/commands/call-links.test.ts +++ b/test/commands/call-links.test.ts @@ -10,9 +10,9 @@ import { type TestHttpServer, } from '../helpers/http-server.js' -const API_KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' -const CLIENT_TOKEN = 'pmfa_ct_browser_token' +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' +const CLIENT_TOKEN = + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO' const TOKEN = 'AbCdEf0123456789_link-token' describe('call link commands', () => { diff --git a/test/commands/call-records.test.ts b/test/commands/call-records.test.ts index fcc74723..88851fe7 100644 --- a/test/commands/call-records.test.ts +++ b/test/commands/call-records.test.ts @@ -7,8 +7,7 @@ import { type TestHttpServer, } from '../helpers/http-server.js' -const API_KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const PROJECT = '11111111-1111-4111-8111-111111111111' const record = { callId: 'call_1', diff --git a/test/commands/calls-and-identities.test.ts b/test/commands/calls-and-identities.test.ts index ed0c8bee..6d8f4e07 100644 --- a/test/commands/calls-and-identities.test.ts +++ b/test/commands/calls-and-identities.test.ts @@ -163,8 +163,7 @@ describe('call and identity commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -178,7 +177,8 @@ describe('call and identity commands', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_BASE_URL: server.baseUrl, - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', }, run, ) diff --git a/test/commands/calls.test.ts b/test/commands/calls.test.ts index 0a942890..3d13d4fa 100644 --- a/test/commands/calls.test.ts +++ b/test/commands/calls.test.ts @@ -13,11 +13,10 @@ import { type CallSettings, } from '../../src/services/calls.js' -const API_KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' -const CLIENT_TOKEN = 'pmfa_ct_browser_token' -const PROJECT_TOKEN = - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' +const CLIENT_TOKEN = + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO' +const PROJECT_TOKEN = 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const TRUNK_ID = '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10' const DEFAULT_RETENTION = { appliesTo: ['call_records', 'call_events', 'client_reports'], diff --git a/test/commands/campaign-conversions.test.ts b/test/commands/campaign-conversions.test.ts index 1628e216..1df8d3a1 100644 --- a/test/commands/campaign-conversions.test.ts +++ b/test/commands/campaign-conversions.test.ts @@ -118,8 +118,7 @@ describe('Campaign conversion commands (beta)', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, diff --git a/test/commands/campaign-follow-ups.test.ts b/test/commands/campaign-follow-ups.test.ts index 5fbb5c67..7bdd4652 100644 --- a/test/commands/campaign-follow-ups.test.ts +++ b/test/commands/campaign-follow-ups.test.ts @@ -178,8 +178,7 @@ describe('campaign follow-up output (beta)', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, diff --git a/test/commands/campaign-recipients.test.ts b/test/commands/campaign-recipients.test.ts index f8bcfe7a..0b1959b7 100644 --- a/test/commands/campaign-recipients.test.ts +++ b/test/commands/campaign-recipients.test.ts @@ -414,8 +414,7 @@ describe('Campaign recipient commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, diff --git a/test/commands/campaigns.test.ts b/test/commands/campaigns.test.ts index c42c87f8..42ac432c 100644 --- a/test/commands/campaigns.test.ts +++ b/test/commands/campaigns.test.ts @@ -317,8 +317,7 @@ describe('Messaging campaign commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -332,7 +331,8 @@ describe('Messaging campaign commands', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_BASE_URL: server.baseUrl, - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', }, run, ) diff --git a/test/commands/channels.test.ts b/test/commands/channels.test.ts index 3c1f5035..ce72ac8d 100644 --- a/test/commands/channels.test.ts +++ b/test/commands/channels.test.ts @@ -344,8 +344,7 @@ describe('channel commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -359,7 +358,8 @@ describe('channel commands', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_BASE_URL: server.baseUrl, - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', }, run, ) diff --git a/test/commands/cloud-group.test.ts b/test/commands/cloud-group.test.ts index d20b8e44..9d4dd4fa 100644 --- a/test/commands/cloud-group.test.ts +++ b/test/commands/cloud-group.test.ts @@ -18,10 +18,12 @@ describe('Official group commands (beta)', () => { return withEnvironment( { POLYMORFA_API_KEY: - kind === 'api' ? `pmfa_${'A'.repeat(72)}` : undefined, + kind === 'api' ? 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' : undefined, POLYMORFA_PROJECT_TOKEN: undefined, POLYMORFA_CLIENT_TOKEN: - kind === 'client' ? `pmfa_ct_${'A'.repeat(94)}` : undefined, + kind === 'client' + ? `pmfa_ct_AAECAwQFBgcICQoLDA0ODxAREhM73f-k` + : undefined, POLYMORFA_BASE_URL: server.baseUrl, }, () => runCommand(command), diff --git a/test/commands/cloud.test.ts b/test/commands/cloud.test.ts index bf7477fd..54138ebd 100644 --- a/test/commands/cloud.test.ts +++ b/test/commands/cloud.test.ts @@ -113,7 +113,7 @@ describe('Official API commands', () => { ) const keyring = vi .spyOn(AsyncEntry.prototype, 'getPassword') - .mockResolvedValue(`pmfa_pt_${'A'.repeat(94)}`) + .mockResolvedValue('pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k') server = await startHttpServer({ status: 202, body: {success: true, data: {accepted: true}}, @@ -466,11 +466,15 @@ describe('Official API commands', () => { return withEnvironment( { POLYMORFA_API_KEY: - kind === 'api' ? `pmfa_${'A'.repeat(72)}` : undefined, + kind === 'api' ? 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' : undefined, POLYMORFA_PROJECT_TOKEN: - kind === 'project' ? `pmfa_pt_${'A'.repeat(94)}` : undefined, + kind === 'project' + ? 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' + : undefined, POLYMORFA_CLIENT_TOKEN: - kind === 'client' ? `pmfa_ct_${'A'.repeat(94)}` : undefined, + kind === 'client' + ? `pmfa_ct_AAECAwQFBgcICQoLDA0ODxAREhM73f-k` + : undefined, POLYMORFA_BASE_URL: server.baseUrl, }, run, diff --git a/test/commands/contacts.test.ts b/test/commands/contacts.test.ts index d92e5a1e..bc86bca7 100644 --- a/test/commands/contacts.test.ts +++ b/test/commands/contacts.test.ts @@ -218,8 +218,7 @@ describe('contact commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -233,7 +232,8 @@ describe('contact commands', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_BASE_URL: server.baseUrl, - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', }, run, ) diff --git a/test/commands/customers.test.ts b/test/commands/customers.test.ts index 682a6272..843e590b 100644 --- a/test/commands/customers.test.ts +++ b/test/commands/customers.test.ts @@ -302,8 +302,7 @@ describe('Customer commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, diff --git a/test/commands/developer-webhooks.test.ts b/test/commands/developer-webhooks.test.ts index b79c1e4c..9da67c76 100644 --- a/test/commands/developer-webhooks.test.ts +++ b/test/commands/developer-webhooks.test.ts @@ -881,8 +881,7 @@ function findCall(method: string) { function developerEnvironment(run: () => Promise): Promise { return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: 'https://api.example.test', POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_PROJECT_TOKEN: undefined, diff --git a/test/commands/doctor.test.ts b/test/commands/doctor.test.ts index 5a005d20..70d5a08f 100644 --- a/test/commands/doctor.test.ts +++ b/test/commands/doctor.test.ts @@ -18,8 +18,7 @@ import { type TestHttpServer, } from '../helpers/http-server.js' -const API_KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' describe('doctor command exit status', () => { let server: TestHttpServer diff --git a/test/commands/event-stream.test.ts b/test/commands/event-stream.test.ts index 26f215fc..376c18d6 100644 --- a/test/commands/event-stream.test.ts +++ b/test/commands/event-stream.test.ts @@ -251,8 +251,7 @@ function item(id: string, cursor: string, withBody = true) { function environment(run: () => Promise): Promise { return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: 'https://api.example.test', POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_PROJECT_TOKEN: undefined, diff --git a/test/commands/events.test.ts b/test/commands/events.test.ts index 7fcd2097..c01533f5 100644 --- a/test/commands/events.test.ts +++ b/test/commands/events.test.ts @@ -295,8 +295,7 @@ function replayReceipt() { function environment(run: () => Promise): Promise { return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: 'https://api.example.test', POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_PROJECT_TOKEN: undefined, diff --git a/test/commands/flow-endpoints.test.ts b/test/commands/flow-endpoints.test.ts index 3362c8f6..af79a57c 100644 --- a/test/commands/flow-endpoints.test.ts +++ b/test/commands/flow-endpoints.test.ts @@ -18,7 +18,7 @@ describe('dynamic Flow endpoint commands', () => { return withEnvironment( { POLYMORFA_API_KEY: undefined, - POLYMORFA_PROJECT_TOKEN: `pmfa_pt_${'A'.repeat(94)}`, + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_BASE_URL: server!.baseUrl, }, diff --git a/test/commands/flows.test.ts b/test/commands/flows.test.ts index 48bcaf3c..918be090 100644 --- a/test/commands/flows.test.ts +++ b/test/commands/flows.test.ts @@ -25,9 +25,13 @@ describe('project Flow commands', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_PROJECT_TOKEN: - kind === 'project' ? `pmfa_pt_${'A'.repeat(94)}` : undefined, + kind === 'project' + ? 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' + : undefined, POLYMORFA_CLIENT_TOKEN: - kind === 'client' ? `pmfa_ct_${'A'.repeat(94)}` : undefined, + kind === 'client' + ? `pmfa_ct_AAECAwQFBgcICQoLDA0ODxAREhM73f-k` + : undefined, POLYMORFA_BASE_URL: server!.baseUrl, }, () => runCommand(command), diff --git a/test/commands/functions.test.ts b/test/commands/functions.test.ts index 69bc63da..30fffa86 100644 --- a/test/commands/functions.test.ts +++ b/test/commands/functions.test.ts @@ -9,8 +9,7 @@ import { withEnvironment, type TestHttpServer, } from '../helpers/http-server.js' -const KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const PROJECT = '018f0000-0000-7000-8000-000000000001', FN = '018f0000-0000-7000-8000-000000000002', DEPLOY = '018f0000-0000-7000-8000-000000000003', @@ -391,11 +390,11 @@ describe('Function commands', () => { expect(result.stderr).toContain('reconcile') expect(server.requests).toHaveLength(1) }) - test('uses project tokens without silently treating them as organization credentials', async () => { + test('uses project tokens without silently treating them as team keys', async () => { server = await startHttpServer({ body: {data: {items: [], nextCursor: null}}, }) - const token = 'pmfa_pt_' + 'A'.repeat(94) + const token = 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const result = await withEnvironment( { POLYMORFA_API_KEY: undefined, diff --git a/test/commands/group-mutations.test.ts b/test/commands/group-mutations.test.ts index 840e247b..2d998f90 100644 --- a/test/commands/group-mutations.test.ts +++ b/test/commands/group-mutations.test.ts @@ -8,8 +8,7 @@ import { type TestHttpServer, } from '../helpers/http-server.js' -const API_KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' describe('group mutation commands', () => { let server: TestHttpServer | undefined diff --git a/test/commands/groups.test.ts b/test/commands/groups.test.ts index ee3946ff..5f775f7f 100644 --- a/test/commands/groups.test.ts +++ b/test/commands/groups.test.ts @@ -96,7 +96,8 @@ describe('group commands', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_BASE_URL: server.baseUrl, - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', }, () => runCommand('group list --session session_1 --json'), ) @@ -176,8 +177,7 @@ describe('group commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, diff --git a/test/commands/history.test.ts b/test/commands/history.test.ts index ac4ee258..f1f7b9cb 100644 --- a/test/commands/history.test.ts +++ b/test/commands/history.test.ts @@ -86,7 +86,7 @@ describe('stored chat history commands', () => { const result = await withEnvironment( { POLYMORFA_API_KEY: undefined, - POLYMORFA_PROJECT_TOKEN: `pmfa_pt_${'A'.repeat(94)}`, + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_BASE_URL: server.baseUrl, }, @@ -140,7 +140,8 @@ describe('stored chat history commands', () => { const result = await withEnvironment( { POLYMORFA_API_KEY: undefined, - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', POLYMORFA_BASE_URL: server.baseUrl, }, () => runCommand('chat list --session number_1 --json'), @@ -209,8 +210,7 @@ describe('stored chat history commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_BASE_URL: server.baseUrl, }, diff --git a/test/commands/hybrid-credentials.test.ts b/test/commands/hybrid-credentials.test.ts index 1fa6818f..c0bdaf06 100644 --- a/test/commands/hybrid-credentials.test.ts +++ b/test/commands/hybrid-credentials.test.ts @@ -10,8 +10,8 @@ import { } from '../helpers/http-server.js' const project = '11111111-1111-4111-8111-111111111111' -const apiKey = `pmfa_${'A'.repeat(72)}` -const projectToken = `pmfa_pt_${'A'.repeat(94)}` +const apiKey = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' +const projectToken = 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const cases = [ { command: 'hybrid-link state --session existing', @@ -301,7 +301,8 @@ test.each(cases)( POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_API_KEY: undefined, POLYMORFA_PROJECT_TOKEN: undefined, - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', }, async () => { const result = await runCommand(`${command} --json`) @@ -309,7 +310,9 @@ test.each(cases)( expect(JSON.parse(result.stderr)).toMatchObject({ error: {category: 'configuration'}, }) - expect(result.stderr).not.toContain('pmfa_ct_browser') + expect(result.stderr).not.toContain( + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', + ) }, ) expect(server.requests).toEqual([]) @@ -362,7 +365,7 @@ test('invalid project-token format is rejected before transport', async () => { 'hybrid-link state --session existing --json', ) expect(result.error).toBeDefined() - expect(result.stderr).toContain('canonical pmfa_pt_ v1 format') + expect(result.stderr).toContain('canonical pmfa_pt_ opaque format') expect(result.stderr).not.toContain('pmfa_pt_invalid') }, ) diff --git a/test/commands/hybrid-link.test.ts b/test/commands/hybrid-link.test.ts index 37fc4a14..e2db599c 100644 --- a/test/commands/hybrid-link.test.ts +++ b/test/commands/hybrid-link.test.ts @@ -9,7 +9,7 @@ import { } from '../helpers/http-server.js' const project = '11111111-1111-4111-8111-111111111111' const env = (url: string) => ({ - POLYMORFA_API_KEY: `pmfa_${'A'.repeat(72)}`, + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: url, POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_PROJECT_TOKEN: undefined, diff --git a/test/commands/labels.test.ts b/test/commands/labels.test.ts index 6b8ebf43..dee0a28e 100644 --- a/test/commands/labels.test.ts +++ b/test/commands/labels.test.ts @@ -63,7 +63,8 @@ describe('label commands', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_BASE_URL: server.baseUrl, - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', }, () => runCommand('label list --session session_1 --json'), ) @@ -220,8 +221,7 @@ describe('label commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, diff --git a/test/commands/listen.test.ts b/test/commands/listen.test.ts index cc3e9284..b3cc3f83 100644 --- a/test/commands/listen.test.ts +++ b/test/commands/listen.test.ts @@ -42,8 +42,7 @@ describe('listen', () => { POLYMORFA_API_KEY: undefined, POLYMORFA_BASE_URL: baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, - POLYMORFA_PROJECT_TOKEN: - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, () => runCommand(`listen --project ${PROJECT} --event message.* --json`), ) @@ -122,8 +121,7 @@ describe('listen', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_CLIENT_TOKEN: undefined, - POLYMORFA_PROJECT_TOKEN: - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, () => runCommand( @@ -158,8 +156,7 @@ describe('listen', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_CLIENT_TOKEN: undefined, - POLYMORFA_PROJECT_TOKEN: - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, () => runCommand( @@ -176,8 +173,7 @@ describe('listen', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_CLIENT_TOKEN: undefined, - POLYMORFA_PROJECT_TOKEN: - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, () => runCommand( @@ -193,8 +189,7 @@ describe('listen', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_CLIENT_TOKEN: undefined, - POLYMORFA_PROJECT_TOKEN: - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, () => runCommand( diff --git a/test/commands/media.test.ts b/test/commands/media.test.ts index 43b3b2b0..6090bb99 100644 --- a/test/commands/media.test.ts +++ b/test/commands/media.test.ts @@ -52,7 +52,8 @@ describe('media commands', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_BASE_URL: server.baseUrl, - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', }, () => runCommand('media view media_1 --json'), ) @@ -449,8 +450,7 @@ describe('media commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, diff --git a/test/commands/message-sends.test.ts b/test/commands/message-sends.test.ts index e474ea9b..2be5a02c 100644 --- a/test/commands/message-sends.test.ts +++ b/test/commands/message-sends.test.ts @@ -11,8 +11,7 @@ import { type TestHttpServer, } from '../helpers/http-server.js' -const API_KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' describe('typed message send commands', () => { let server: TestHttpServer | undefined diff --git a/test/commands/messages.test.ts b/test/commands/messages.test.ts index ff13d345..aeea43f3 100644 --- a/test/commands/messages.test.ts +++ b/test/commands/messages.test.ts @@ -134,7 +134,8 @@ describe('message commands', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_BASE_URL: server.baseUrl, - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', }, () => runCommand( @@ -256,8 +257,7 @@ describe('message commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, diff --git a/test/commands/number-tiers.test.ts b/test/commands/number-tiers.test.ts index d16b61a8..a01346c6 100644 --- a/test/commands/number-tiers.test.ts +++ b/test/commands/number-tiers.test.ts @@ -451,8 +451,7 @@ describe('session tier commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_PROJECT_TOKEN: undefined, diff --git a/test/commands/operations.test.ts b/test/commands/operations.test.ts index bccea36e..c732811c 100644 --- a/test/commands/operations.test.ts +++ b/test/commands/operations.test.ts @@ -329,8 +329,7 @@ function operation(status: string, sequence: number) { function environment(run: () => Promise): Promise { return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: api.url, POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_PROJECT_TOKEN: undefined, diff --git a/test/commands/platform-management.test.ts b/test/commands/platform-management.test.ts index 2eafd47a..8501275b 100644 --- a/test/commands/platform-management.test.ts +++ b/test/commands/platform-management.test.ts @@ -137,7 +137,7 @@ describe('Platform management commands', () => { const result = await withEnvironment( { POLYMORFA_API_KEY: undefined, - POLYMORFA_PROJECT_TOKEN: `pmfa_pt_${'A'.repeat(94)}`, + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, }, () => runCommand('session list --json'), @@ -738,8 +738,7 @@ describe('Platform management commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, diff --git a/test/commands/presence.test.ts b/test/commands/presence.test.ts index d005f98d..f803c3d5 100644 --- a/test/commands/presence.test.ts +++ b/test/commands/presence.test.ts @@ -183,8 +183,7 @@ describe('presence commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, @@ -198,7 +197,8 @@ describe('presence commands', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_BASE_URL: server.baseUrl, - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', }, run, ) diff --git a/test/commands/privacy.test.ts b/test/commands/privacy.test.ts index 12f44985..49faf70c 100644 --- a/test/commands/privacy.test.ts +++ b/test/commands/privacy.test.ts @@ -57,7 +57,8 @@ describe('privacy commands', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_BASE_URL: server.baseUrl, - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', }, () => runCommand('privacy view --session session_1 --json'), ) @@ -164,8 +165,7 @@ describe('privacy commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, diff --git a/test/commands/profile.test.ts b/test/commands/profile.test.ts index 1db44ec7..23edf7d0 100644 --- a/test/commands/profile.test.ts +++ b/test/commands/profile.test.ts @@ -83,7 +83,8 @@ describe('profile commands', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_BASE_URL: server.baseUrl, - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', }, () => runCommand('profile view --session session_1 --json'), ) @@ -273,8 +274,7 @@ describe('profile commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, diff --git a/test/commands/project-context.test.ts b/test/commands/project-context.test.ts index 9092e7a4..3e734776 100644 --- a/test/commands/project-context.test.ts +++ b/test/commands/project-context.test.ts @@ -11,8 +11,7 @@ import { type TestHttpServer, } from '../helpers/http-server.js' -const API_KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const APP = '11111111-1111-4111-8111-111111111111' describe('link and project commands', () => { diff --git a/test/commands/quick-replies.test.ts b/test/commands/quick-replies.test.ts index 13e0ca80..5d16fd48 100644 --- a/test/commands/quick-replies.test.ts +++ b/test/commands/quick-replies.test.ts @@ -54,7 +54,8 @@ describe('quick-reply commands', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_BASE_URL: server.baseUrl, - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', }, () => runCommand('quick-reply list --session session_1 --json'), ) @@ -186,8 +187,7 @@ describe('quick-reply commands', () => { if (server === undefined) throw new Error('Test server was not started.') return withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, }, diff --git a/test/commands/quicklinks.test.ts b/test/commands/quicklinks.test.ts index c05894a5..36fa9417 100644 --- a/test/commands/quicklinks.test.ts +++ b/test/commands/quicklinks.test.ts @@ -35,7 +35,7 @@ test('creates a QuickLink and uploads separate test history with exact project s try { await withEnvironment( { - POLYMORFA_API_KEY: `pmfa_${'A'.repeat(72)}`, + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: server.baseUrl, POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_PROJECT_TOKEN: undefined, @@ -69,8 +69,8 @@ test('creates a QuickLink and uploads separate test history with exact project s }) const projectId = '11111111-1111-4111-8111-111111111111' -const apiKey = `pmfa_${'A'.repeat(72)}` -const projectToken = `pmfa_pt_${'A'.repeat(94)}` +const apiKey = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' +const projectToken = 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' test('retrieves QuickLink status with an exact-project token and encoded ID', async () => { const server = await startHttpSequenceServer([ diff --git a/test/commands/request-logs.test.ts b/test/commands/request-logs.test.ts index 1043d7d7..22247ae1 100644 --- a/test/commands/request-logs.test.ts +++ b/test/commands/request-logs.test.ts @@ -7,8 +7,7 @@ import { type TestHttpServer, } from '../helpers/http-server.js' -const key = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const key = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const project = '22222222-2222-4222-8222-222222222222' let server: TestHttpServer diff --git a/test/commands/sip-trunks.test.ts b/test/commands/sip-trunks.test.ts index 95c75fb1..eedcaafa 100644 --- a/test/commands/sip-trunks.test.ts +++ b/test/commands/sip-trunks.test.ts @@ -15,8 +15,7 @@ import { type TestHttpServer, } from '../helpers/http-server.js' -const API_KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const PROJECT = '018f0000-0000-7000-8000-000000000002' const TRUNK_ID = '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10' diff --git a/test/commands/trigger.test.ts b/test/commands/trigger.test.ts index 67909f7e..912d43de 100644 --- a/test/commands/trigger.test.ts +++ b/test/commands/trigger.test.ts @@ -7,7 +7,7 @@ import { } from '../helpers/http-server.js' const projectId = '11111111-1111-4111-8111-111111111111' -const apiKey = `pmfa_${'A'.repeat(72)}` +const apiKey = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const eventId = '0199f1c2-7a4e-7c55-9d1e-3f0b8a2c6d10' async function run( diff --git a/test/commands/usage.test.ts b/test/commands/usage.test.ts index 1aa474c1..5b49e184 100644 --- a/test/commands/usage.test.ts +++ b/test/commands/usage.test.ts @@ -7,10 +7,8 @@ import { type TestHttpServer, } from '../helpers/http-server.js' -const API_KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' -const PROJECT_TOKEN = - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' +const PROJECT_TOKEN = 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const PROJECT_A = '22222222-2222-4222-8222-222222222222' const PROJECT_B = '33333333-3333-4333-8333-333333333333' diff --git a/test/commands/voice-wait-timeout.test.ts b/test/commands/voice-wait-timeout.test.ts index 032d06d5..2fe357fa 100644 --- a/test/commands/voice-wait-timeout.test.ts +++ b/test/commands/voice-wait-timeout.test.ts @@ -7,8 +7,7 @@ import {expect, test} from 'vitest' import {temporaryDirectory} from '../support/fs.js' -const API_KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const ASSET_ID = '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10' const PROJECT = '018f0000-0000-7000-8000-000000000002' diff --git a/test/commands/voice.test.ts b/test/commands/voice.test.ts index 7f3109da..a6f6a1ae 100644 --- a/test/commands/voice.test.ts +++ b/test/commands/voice.test.ts @@ -11,8 +11,7 @@ import { type TestHttpServer, } from '../helpers/http-server.js' -const API_KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const PROVIDER_KEY = 'sk-provider-secret-0123456789' const PROJECT = '018f0000-0000-7000-8000-000000000002' const ASSET_ID = '6f1c2f0e-1d5b-4d8e-9a55-0d4b8a6c1f10' diff --git a/test/config/credential-vectors.test.ts b/test/config/credential-vectors.test.ts new file mode 100644 index 00000000..faffd295 --- /dev/null +++ b/test/config/credential-vectors.test.ts @@ -0,0 +1,28 @@ +import {readFileSync} from 'node:fs' +import {validateOrganizationApiKey} from '@polymorfa/sdk' +import {describe, expect, test} from 'vitest' + +import {validateProjectToken} from '../../src/config/credentials.js' + +// Byte-for-byte copy of #647's public golden vectors. Provenance is recorded in +// docs/opaque-credentials-cutover.md. These are test secrets, never credentials. +const vectors = JSON.parse( + readFileSync( + new URL('../fixtures/machine-credentials-v1.json', import.meta.url), + 'utf8', + ), +) as {opaque: {kind: 'key' | 'project'; token: string}[]} + +describe('canonical API credential vectors through the published SDK', () => { + for (const fixture of vectors.opaque) { + test(`accepts the ${fixture.kind} vector without rewriting its opaque bytes`, () => { + const validate = + fixture.kind === 'key' + ? validateOrganizationApiKey + : validateProjectToken + expect(validate(fixture.token)).toBe(fixture.token) + const changed = `${fixture.token.slice(0, -1)}A` + expect(() => validate(changed)).toThrow() + }) + } +}) diff --git a/test/context/environment.test.ts b/test/context/environment.test.ts index 0d6a8518..aa91a6ad 100644 --- a/test/context/environment.test.ts +++ b/test/context/environment.test.ts @@ -16,8 +16,7 @@ describe('resolveEnvironment', () => { resolveListenerEnvironment({ env: { POLYMORFA_BASE_URL: 'http://127.0.0.1:4567', - POLYMORFA_PROJECT_TOKEN: - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, apiVersion: '2026-09-01', project: PROJECT, @@ -26,8 +25,7 @@ describe('resolveEnvironment', () => { ).resolves.toMatchObject({ apiVersion: '2026-09-01', baseUrl: 'http://127.0.0.1:4567', - credential: - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + credential: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', principalType: 'project_token', projectId: PROJECT, timeoutMs: 4321, @@ -67,9 +65,7 @@ describe('resolveEnvironment', () => { resolveDeveloperEnvironment( {env: {}, profile: 'project'}, {load: () => Promise.resolve(profile), save: () => Promise.resolve()}, - fakeCredentials( - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - ), + fakeCredentials('pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k'), ), ).resolves.toMatchObject({ context: { @@ -78,8 +74,7 @@ describe('resolveEnvironment', () => { profile: 'project', projectId: PROJECT, }, - credential: - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + credential: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }) }) @@ -130,13 +125,10 @@ describe('resolveEnvironment', () => { profile: 'project', }, {load: () => Promise.resolve(profile), save: () => Promise.resolve()}, - fakeCredentials( - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - ), + fakeCredentials('pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k'), ), ).resolves.toMatchObject({ - credential: - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + credential: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', profile: 'project', projectId: '11111111-1111-4111-8111-111111111111', }) @@ -151,8 +143,7 @@ describe('resolveEnvironment', () => { resolveListenerEnvironment( { env: { - POLYMORFA_API_KEY: - 'pmfa_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + POLYMORFA_API_KEY: 'pmfa_YmJiYmJiYmJiYmJiYmJiYmJiYmKK0GQs', }, project: '11111111-1111-4111-8111-111111111111', }, @@ -160,8 +151,7 @@ describe('resolveEnvironment', () => { credentials, ), ).resolves.toMatchObject({ - credential: - 'pmfa_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + credential: 'pmfa_YmJiYmJiYmJiYmJiYmJiYmJiYmKK0GQs', principalType: 'organization_key', }) expect(credentials.get).not.toHaveBeenCalled() @@ -257,8 +247,7 @@ describe('resolveEnvironment', () => { await expect( resolveDeveloperEnvironment({ env: { - POLYMORFA_PROJECT_TOKEN: - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, project: PROJECT, }), @@ -269,8 +258,7 @@ describe('resolveEnvironment', () => { expect( resolveEnvironment({ env: { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_BASE_URL: 'http://127.0.0.1:4567', UNRELATED_SECRET: 'do-not-copy', }, @@ -281,8 +269,7 @@ describe('resolveEnvironment', () => { surface: 'platform', credential: { type: 'apiKey', - value: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + value: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, baseUrl: 'http://127.0.0.1:4567', timeoutMs: 5000, @@ -292,7 +279,10 @@ describe('resolveEnvironment', () => { test('rejects a client token for the Platform surface', () => { expect(() => resolveEnvironment({ - env: {POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser'}, + env: { + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', + }, surface: 'platform', }), ).toThrow('Platform API requests require POLYMORFA_API_KEY.') @@ -301,12 +291,19 @@ describe('resolveEnvironment', () => { test('resolves a client token for the Messaging surface', () => { expect( resolveEnvironment({ - env: {POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser'}, + env: { + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', + }, surface: 'messaging', }), ).toEqual({ surface: 'messaging', - credential: {type: 'clientToken', value: 'pmfa_ct_browser'}, + credential: { + type: 'clientToken', + value: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', + }, }) }) @@ -314,8 +311,7 @@ describe('resolveEnvironment', () => { expect( resolveEnvironment({ env: { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, surface: 'messaging', }), @@ -323,8 +319,7 @@ describe('resolveEnvironment', () => { surface: 'messaging', credential: { type: 'apiKey', - value: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + value: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, }) }) @@ -333,9 +328,9 @@ describe('resolveEnvironment', () => { expect(() => resolveEnvironment({ env: { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - POLYMORFA_CLIENT_TOKEN: 'pmfa_ct_browser', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', + POLYMORFA_CLIENT_TOKEN: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', }, surface: 'messaging', }), @@ -451,7 +446,7 @@ describe('resolveEnvironment', () => { projectProfiles(), credentials, ), - ).rejects.toThrow('organization API key') + ).rejects.toThrow('team key') expect(credentials.get).not.toHaveBeenCalled() }) @@ -474,8 +469,7 @@ describe('resolveEnvironment', () => { resolveCommandEnvironment( { env: { - POLYMORFA_API_KEY: - 'pmfa_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + POLYMORFA_API_KEY: 'pmfa_YmJiYmJiYmJiYmJiYmJiYmJiYmKK0GQs', }, surface: 'platform', }, @@ -485,8 +479,7 @@ describe('resolveEnvironment', () => { ).resolves.toMatchObject({ credential: { type: 'apiKey', - value: - 'pmfa_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + value: 'pmfa_YmJiYmJiYmJiYmJiYmJiYmJiYmKK0GQs', }, source: 'environment', }) @@ -518,8 +511,7 @@ describe('resolveEnvironment', () => { resolveCommandEnvironment( { env: { - POLYMORFA_API_KEY: - 'pmfa_CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC', + POLYMORFA_API_KEY: 'pmfa_YmJiYmJiYmJiYmJiYmJiYmJiYmKK0GQs', }, profile: 'work', surface: 'messaging', @@ -581,7 +573,7 @@ describe('resolveDeveloperEnvironment owner inference', () => { }) }) - test('reads the organization with an organization key and no project', async () => { + test('reads the organization with an team key and no project', async () => { await expect( resolveDeveloperEnvironment( {env: {POLYMORFA_API_KEY: key}, inferOwner: true}, @@ -610,8 +602,7 @@ describe('resolveDeveloperEnvironment owner inference', () => { resolveDeveloperEnvironment( { env: { - POLYMORFA_PROJECT_TOKEN: - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, inferOwner: true, }, @@ -678,7 +669,7 @@ function fakeCredentials(value: string): CredentialStore { } } -const PROJECT_TOKEN = `pmfa_pt_${'A'.repeat(94)}` +const PROJECT_TOKEN = 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' function projectProfiles() { const config: ProfileConfig = { version: 2, diff --git a/test/developer/client.test.ts b/test/developer/client.test.ts index 6abdfa6c..3c32e6c2 100644 --- a/test/developer/client.test.ts +++ b/test/developer/client.test.ts @@ -11,8 +11,7 @@ describe('createDeveloperSession', () => { ) const session = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_PROJECT_TOKEN: undefined, }, @@ -28,11 +27,10 @@ describe('createDeveloperSession', () => { expect(loadLinkedProject).not.toHaveBeenCalled() }) - test('creates an immutable project view from an organization key', async () => { + test('creates an immutable project view from an team key', async () => { const session = await withEnvironment( { - POLYMORFA_API_KEY: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_API_KEY: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', POLYMORFA_CLIENT_TOKEN: undefined, POLYMORFA_PROJECT_TOKEN: undefined, }, @@ -58,8 +56,7 @@ describe('createDeveloperSession', () => { { POLYMORFA_API_KEY: undefined, POLYMORFA_CLIENT_TOKEN: undefined, - POLYMORFA_PROJECT_TOKEN: - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_PROJECT_TOKEN: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, () => createDeveloperSession({ diff --git a/test/fixtures/machine-credentials-v1.json b/test/fixtures/machine-credentials-v1.json new file mode 100644 index 00000000..a40eb086 --- /dev/null +++ b/test/fixtures/machine-credentials-v1.json @@ -0,0 +1,516 @@ +{ + "testOnlySeedHex": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "now": 1792195200000, + "key": { + "kid": "mc1", + "purpose": "machine-credentials", + "algorithm": "Ed25519", + "activatesAt": 0, + "retiresAt": null, + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=\n-----END PUBLIC KEY-----\n" + }, + "context": { + "environment": "test", + "issuer": "https://platform.example.test", + "audience": "polymorfa-api" + }, + "formatRevision": 1, + "crcAlgorithm": "CRC-32/ISO-HDLC", + "kidRegistry": [ + { + "kid": "mc1", + "environment": "test" + } + ], + "opaque": [ + { + "name": "key", + "kind": "key", + "environment": "test", + "secretHex": "000102030405060708090a0b0c0d0e0f10111213", + "checksumHex": "3bddffa4", + "body": "AAECAwQFBgcICQoLDA0ODxAREhM73f-k", + "token": "pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k", + "tokenLength": 37, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e6f70617175652e7368613235360074657374006b657900000102030405060708090a0b0c0d0e0f10111213", + "tokenHashHex": "6e10862b045a45183f5173969a2f5aa7cb93841ddbda52f32e4c55a9b3a9dded" + }, + { + "name": "project", + "kind": "project", + "environment": "test", + "secretHex": "000102030405060708090a0b0c0d0e0f10111213", + "checksumHex": "3bddffa4", + "body": "AAECAwQFBgcICQoLDA0ODxAREhM73f-k", + "token": "pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k", + "tokenLength": 40, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e6f70617175652e73686132353600746573740070726f6a65637400000102030405060708090a0b0c0d0e0f10111213", + "tokenHashHex": "a69d6a7ca7dc5052993dc6bdb0f7a3483543bc2ca9c4e5cbd265535b6722211a" + } + ], + "client": { + "testOnlySeedHex": "9d61b19deffd5a60ba844af492ec2cc44449c5697b326919703bac031cae7f60", + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=\n-----END PUBLIC KEY-----\n", + "keyMetadata": { + "kid": "mc1", + "purpose": "machine-credentials", + "algorithm": "Ed25519", + "activatesAt": 0, + "retiresAt": null, + "publicKeyPem": "-----BEGIN PUBLIC KEY-----\nMCowBQYDK2VwAyEA11qYAYKxCrfVS/7TyWQHOg7hcvPapiMlrwIaaPcHURo=\n-----END PUBLIC KEY-----\n" + }, + "cases": [ + { + "name": "project-issued", + "claims": { + "id": "33333333-3333-4333-8333-333333333333", + "kind": "client", + "issuedAt": 1792195200000, + "ttlSecs": 3600, + "orgId": "44444444-4444-4444-8444-444444444444", + "sessionName": "test-number", + "ephemeralId": "browser", + "issuerKind": "project", + "issuerId": "22222222-2222-4222-8222-222222222222", + "permissionCeiling": "18446744073709551615", + "testNumbersOnly": false + }, + "payloadHex": "01036d633133333333333343338333333333333333000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720122222222222242228222222222222222ffffffffffffffff00", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633133333333333343338333333333333333000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720122222222222242228222222222222222ffffffffffffffff00", + "signatureHex": "535f855c526ae4e6e2a36398271f4d0b72b58ac3d7967b5f0f544809b3567fe3d94b61db836ac404a17814d10c55e66b72e6be50e4cf481bab58bc2bdb50810e", + "token": "pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO", + "tokenLength": 220, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633133333333333343338333333333333333000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720122222222222242228222222222222222ffffffffffffffff00535f855c526ae4e6e2a36398271f4d0b72b58ac3d7967b5f0f544809b3567fe3d94b61db836ac404a17814d10c55e66b72e6be50e4cf481bab58bc2bdb50810e", + "tokenHashHex": "db38b609c61c987c5e4b18d2ccdc990ace4cb9818db503ace80633321b96336d" + }, + { + "name": "project-issued-test-only", + "claims": { + "id": "33333333-3333-4333-8333-333333333333", + "kind": "client", + "issuedAt": 1792195200000, + "ttlSecs": 3600, + "orgId": "44444444-4444-4444-8444-444444444444", + "sessionName": "test-number", + "ephemeralId": "browser", + "issuerKind": "project", + "issuerId": "22222222-2222-4222-8222-222222222222", + "permissionCeiling": "18446744073709551615", + "testNumbersOnly": true + }, + "payloadHex": "01036d633133333333333343338333333333333333000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720122222222222242228222222222222222ffffffffffffffff02", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633133333333333343338333333333333333000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720122222222222242228222222222222222ffffffffffffffff02", + "signatureHex": "5ce274c4ee5bb2d9b184e60e02b175988cb38708c7bf80ff667de89d0a31192fb1597cc1cc21be7d907c07295fffe9ed53e02f463b5e0a617333ee77650a1307", + "token": "pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wJc4nTE7luy2bGE5g4CsXWYjLOHCMe_gP9mfeidCjEZL7FZfMHMIb59kHwHKV__6e1T4C9GO14KYXMz7ndlChMH", + "tokenLength": 220, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633133333333333343338333333333333333000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720122222222222242228222222222222222ffffffffffffffff025ce274c4ee5bb2d9b184e60e02b175988cb38708c7bf80ff667de89d0a31192fb1597cc1cc21be7d907c07295fffe9ed53e02f463b5e0a617333ee77650a1307", + "tokenHashHex": "55520a4f87639e29dee3eb478d12b351edefde44639189a151168ddcdfa156a1" + }, + { + "name": "customer", + "claims": { + "id": "55555555-5555-4555-8555-555555555555", + "kind": "client", + "issuedAt": 1792195200000, + "ttlSecs": 3600, + "orgId": "44444444-4444-4444-8444-444444444444", + "sessionName": "", + "ephemeralId": "browser", + "issuerKind": "project", + "issuerId": "22222222-2222-4222-8222-222222222222", + "permissionCeiling": "18446744073709551615", + "customer": { + "customerId": "66666666-6666-4666-8666-666666666666", + "sessionIds": [ + "77777777-7777-4777-8777-777777777777" + ], + "allowMask": 7 + }, + "testNumbersOnly": false + }, + "payloadHex": "01036d633155555555555545558555555555555555000001a14728840000000e1044444444444444448444444444444444000762726f777365720122222222222242228222222222222222ffffffffffffffff016666666666664666866666666666666601777777777777477787777777777777770007", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633155555555555545558555555555555555000001a14728840000000e1044444444444444448444444444444444000762726f777365720122222222222242228222222222222222ffffffffffffffff016666666666664666866666666666666601777777777777477787777777777777770007", + "signatureHex": "bd2e4c8303f04927cca6040c238913faeb09a6fe824c18179f5d4f6c724e5a51e76c1e16783b099a85214706b9144ef98928a45bd3ad55b054460f138890a50b", + "token": "pmfa_ct_AQNtYzFVVVVVVVVFVYVVVVVVVVVVAAABoUcohAAAAA4QRERERERERESERERERERERAAHYnJvd3NlcgEiIiIiIiJCIoIiIiIiIiIi__________8BZmZmZmZmRmaGZmZmZmZmZgF3d3d3d3dHd4d3d3d3d3d3AAe9LkyDA_BJJ8ymBAwjiRP66wmm_oJMGBefXU9sck5aUedsHhZ4OwmahSFHBrkUTvmJKKRb061VsFRGDxOIkKUL", + "tokenLength": 252, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633155555555555545558555555555555555000001a14728840000000e1044444444444444448444444444444444000762726f777365720122222222222242228222222222222222ffffffffffffffff016666666666664666866666666666666601777777777777477787777777777777770007bd2e4c8303f04927cca6040c238913faeb09a6fe824c18179f5d4f6c724e5a51e76c1e16783b099a85214706b9144ef98928a45bd3ad55b054460f138890a50b", + "tokenHashHex": "0f5aeca52b39e6321f1d98cf8729c10f9f5339ed31342dc525e4a58a198c72dd" + }, + { + "name": "customer-test-only", + "claims": { + "id": "55555555-5555-4555-8555-555555555555", + "kind": "client", + "issuedAt": 1792195200000, + "ttlSecs": 3600, + "orgId": "44444444-4444-4444-8444-444444444444", + "sessionName": "", + "ephemeralId": "browser", + "issuerKind": "project", + "issuerId": "22222222-2222-4222-8222-222222222222", + "permissionCeiling": "18446744073709551615", + "customer": { + "customerId": "66666666-6666-4666-8666-666666666666", + "sessionIds": [ + "77777777-7777-4777-8777-777777777777" + ], + "allowMask": 7 + }, + "testNumbersOnly": true + }, + "payloadHex": "01036d633155555555555545558555555555555555000001a14728840000000e1044444444444444448444444444444444000762726f777365720122222222222242228222222222222222ffffffffffffffff036666666666664666866666666666666601777777777777477787777777777777770007", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633155555555555545558555555555555555000001a14728840000000e1044444444444444448444444444444444000762726f777365720122222222222242228222222222222222ffffffffffffffff036666666666664666866666666666666601777777777777477787777777777777770007", + "signatureHex": "00bc981be2745c7b8339e2c7421ec6d5efcb3044257a5c626755f6ade8c936f92a500c3ebf6ec09cf0d889b66819c399b0a9ab78dfa082899618d88c0ad6170a", + "token": "pmfa_ct_AQNtYzFVVVVVVVVFVYVVVVVVVVVVAAABoUcohAAAAA4QRERERERERESERERERERERAAHYnJvd3NlcgEiIiIiIiJCIoIiIiIiIiIi__________8DZmZmZmZmRmaGZmZmZmZmZgF3d3d3d3dHd4d3d3d3d3d3AAcAvJgb4nRce4M54sdCHsbV78swRCV6XGJnVfat6Mk2-SpQDD6_bsCc8NiJtmgZw5mwqat436CCiZYY2IwK1hcK", + "tokenLength": 252, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633155555555555545558555555555555555000001a14728840000000e1044444444444444448444444444444444000762726f777365720122222222222242228222222222222222ffffffffffffffff03666666666666466686666666666666660177777777777747778777777777777777000700bc981be2745c7b8339e2c7421ec6d5efcb3044257a5c626755f6ade8c936f92a500c3ebf6ec09cf0d889b66819c399b0a9ab78dfa082899618d88c0ad6170a", + "tokenHashHex": "bbb27eea340d8119e42bf6ccc27d527bd001898abac562fdc309f38a64acb586" + }, + { + "name": "human", + "claims": { + "id": "88888888-8888-4888-8888-888888888888", + "kind": "client", + "issuedAt": 1792195200000, + "ttlSecs": 3600, + "orgId": "44444444-4444-4444-8444-444444444444", + "sessionName": "test-number", + "ephemeralId": "browser", + "issuerKind": "human", + "issuerId": "user-test", + "permissionCeiling": "18446744073709551615", + "userId": "user-test", + "sessionId": "session-test", + "region": "br", + "testNumbersOnly": false + }, + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff000c73657373696f6e2d74657374026272", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff000c73657373696f6e2d74657374026272", + "signatureHex": "b0867675c35f9931c4ee48935f880eecc70966bbdc942c02d98588d565bbc09076a397209a8e3377062699d010bb1dab6ccb31021258afd4abedba19887c5f02", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyAgl1c2VyLXRlc3T__________wAMc2Vzc2lvbi10ZXN0AmJysIZ2dcNfmTHE7kiTX4gO7McJZrvclCwC2YWI1WW7wJB2o5cgmo4zdwYmmdAQux2rbMsxAhJYr9Sr7boZiHxfAg", + "tokenLength": 234, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff000c73657373696f6e2d74657374026272b0867675c35f9931c4ee48935f880eecc70966bbdc942c02d98588d565bbc09076a397209a8e3377062699d010bb1dab6ccb31021258afd4abedba19887c5f02", + "tokenHashHex": "04bc4b1681f7464cdb9cc73c39bf1f85fe0699f03e480872b540078e5719e624" + }, + { + "name": "human-test-only", + "claims": { + "id": "88888888-8888-4888-8888-888888888888", + "kind": "client", + "issuedAt": 1792195200000, + "ttlSecs": 3600, + "orgId": "44444444-4444-4444-8444-444444444444", + "sessionName": "test-number", + "ephemeralId": "browser", + "issuerKind": "human", + "issuerId": "user-test", + "permissionCeiling": "18446744073709551615", + "userId": "user-test", + "sessionId": "session-test", + "region": "br", + "testNumbersOnly": true + }, + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff020c73657373696f6e2d74657374026272", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff020c73657373696f6e2d74657374026272", + "signatureHex": "cc9840f3dcacc013a31ad88e0fbe30b3d49ac9823e1e8b9e8ea0f4d95426a0c63eb0b50e7a4a65b3a49f1c21231802b0f8d338879232a22f45d44d3220e7810d", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyAgl1c2VyLXRlc3T__________wIMc2Vzc2lvbi10ZXN0AmJyzJhA89yswBOjGtiOD74ws9SayYI-HouejqD02VQmoMY-sLUOekpls6SfHCEjGAKw-NM4h5Iyoi9F1E0yIOeBDQ", + "tokenLength": 234, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff020c73657373696f6e2d74657374026272cc9840f3dcacc013a31ad88e0fbe30b3d49ac9823e1e8b9e8ea0f4d95426a0c63eb0b50e7a4a65b3a49f1c21231802b0f8d338879232a22f45d44d3220e7810d", + "tokenHashHex": "1c80f128a7dbc9fef87b4917eae8f14cef960b8c0d9a18c8f6da28e98f39f573" + }, + { + "name": "console-www", + "claims": { + "id": "88888888-8888-4888-8888-888888888888", + "kind": "client", + "issuedAt": 1792195200000, + "ttlSecs": 3600, + "orgId": "44444444-4444-4444-8444-444444444444", + "sessionName": "test-number", + "ephemeralId": "browser", + "issuerKind": "human", + "issuerId": "user-test", + "permissionCeiling": "18446744073709551615", + "userId": "user-test", + "sessionId": "session-test", + "region": "br", + "testNumbersOnly": false, + "console": { + "projectId": "22222222-2222-4222-8222-222222222222", + "sessionIds": [ + "77777777-7777-4777-8777-777777777777" + ], + "origin": "https://www.polymorfa.com" + } + }, + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d", + "signatureHex": "4261a093bf65fc5e340121a9ecfa87251faaa6d380d734777f87d88a5e2fb2700213943622205b15a85a9593a49f50ca37a410df7f7dd6f6d6a3a1c27c487c0c", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyAgl1c2VyLXRlc3T__________wQMc2Vzc2lvbi10ZXN0AmJyIiIiIiIiQiKCIiIiIiIiIgF3d3d3d3dHd4d3d3d3d3d3GWh0dHBzOi8vd3d3LnBvbHltb3JmYS5jb21CYaCTv2X8XjQBIans-oclH6qm04DXNHd_h9iKXi-ycAITlDYiIFsVqFqVk6SfUMo3pBDff33W9tajocJ8SHwM", + "tokenLength": 312, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d4261a093bf65fc5e340121a9ecfa87251faaa6d380d734777f87d88a5e2fb2700213943622205b15a85a9593a49f50ca37a410df7f7dd6f6d6a3a1c27c487c0c", + "tokenHashHex": "fc0a354da098db2d4459889a3d615956d30adb9c6fc907bebb9ce89791540568" + }, + { + "name": "console-www-test-only", + "claims": { + "id": "88888888-8888-4888-8888-888888888888", + "kind": "client", + "issuedAt": 1792195200000, + "ttlSecs": 3600, + "orgId": "44444444-4444-4444-8444-444444444444", + "sessionName": "test-number", + "ephemeralId": "browser", + "issuerKind": "human", + "issuerId": "user-test", + "permissionCeiling": "18446744073709551615", + "userId": "user-test", + "sessionId": "session-test", + "region": "br", + "testNumbersOnly": true, + "console": { + "projectId": "22222222-2222-4222-8222-222222222222", + "sessionIds": [ + "77777777-7777-4777-8777-777777777777" + ], + "origin": "https://www.polymorfa.com" + } + }, + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff060c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff060c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d", + "signatureHex": "eb726490d835ac4fb9f9757d176b4a87368eff119b788ae23f0ede3ed41ffce6c85d4d49da8f369e3d02940bff3379f367206f1d62d2e1afb38efab3c7daa00a", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyAgl1c2VyLXRlc3T__________wYMc2Vzc2lvbi10ZXN0AmJyIiIiIiIiQiKCIiIiIiIiIgF3d3d3d3dHd4d3d3d3d3d3GWh0dHBzOi8vd3d3LnBvbHltb3JmYS5jb23rcmSQ2DWsT7n5dX0Xa0qHNo7_EZt4iuI_Dt4-1B_85shdTUnajzaePQKUC_8zefNnIG8dYtLhr7OO-rPH2qAK", + "tokenLength": 312, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff060c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6deb726490d835ac4fb9f9757d176b4a87368eff119b788ae23f0ede3ed41ffce6c85d4d49da8f369e3d02940bff3379f367206f1d62d2e1afb38efab3c7daa00a", + "tokenHashHex": "8335b2391c91ef7cbf99eb9cc6762fc9fcd267724c4c46037d162b183c19c0ee" + }, + { + "name": "console-docs-all-blocks", + "claims": { + "id": "88888888-8888-4888-8888-888888888888", + "kind": "client", + "issuedAt": 1792195200000, + "ttlSecs": 3600, + "orgId": "44444444-4444-4444-8444-444444444444", + "sessionName": "", + "ephemeralId": "browser", + "issuerKind": "human", + "issuerId": "user-test", + "permissionCeiling": "18446744073709551615", + "userId": "user-test", + "sessionId": "session-test", + "region": "br", + "testNumbersOnly": false, + "customer": { + "customerId": "66666666-6666-4666-8666-666666666666", + "sessionIds": [ + "77777777-7777-4777-8777-777777777777" + ], + "allowMask": 7 + }, + "console": { + "projectId": "22222222-2222-4222-8222-222222222222", + "sessionIds": [ + "77777777-7777-4777-8777-777777777777" + ], + "origin": "https://docs.polymorfa.com" + } + }, + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e1044444444444444448444444444444444000762726f777365720209757365722d74657374ffffffffffffffff0566666666666646668666666666666666017777777777774777877777777777777700070c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771a68747470733a2f2f646f63732e706f6c796d6f7266612e636f6d", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e1044444444444444448444444444444444000762726f777365720209757365722d74657374ffffffffffffffff0566666666666646668666666666666666017777777777774777877777777777777700070c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771a68747470733a2f2f646f63732e706f6c796d6f7266612e636f6d", + "signatureHex": "4a441052fba43e67ec4b7a8357d4a5ce9ea3d381db745536158f0c71766ae26eba08c7e1d4886e312e60b983ffd8231fdea8a6de5b3689347f87fed1248f6805", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAAHYnJvd3NlcgIJdXNlci10ZXN0__________8FZmZmZmZmRmaGZmZmZmZmZgF3d3d3d3dHd4d3d3d3d3d3AAcMc2Vzc2lvbi10ZXN0AmJyIiIiIiIiQiKCIiIiIiIiIgF3d3d3d3dHd4d3d3d3d3d3Gmh0dHBzOi8vZG9jcy5wb2x5bW9yZmEuY29tSkQQUvukPmfsS3qDV9Slzp6j04HbdFU2FY8McXZq4m66CMfh1IhuMS5guYP_2CMf3qim3ls2iTR_h_7RJI9oBQ", + "tokenLength": 346, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e1044444444444444448444444444444444000762726f777365720209757365722d74657374ffffffffffffffff0566666666666646668666666666666666017777777777774777877777777777777700070c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771a68747470733a2f2f646f63732e706f6c796d6f7266612e636f6d4a441052fba43e67ec4b7a8357d4a5ce9ea3d381db745536158f0c71766ae26eba08c7e1d4886e312e60b983ffd8231fdea8a6de5b3689347f87fed1248f6805", + "tokenHashHex": "21f27fd09e9eed68d910122e1c06f3a54a0c8018c6d7262f4ec89e2be1f94bfd" + }, + { + "name": "console-docs-all-blocks-test-only", + "claims": { + "id": "88888888-8888-4888-8888-888888888888", + "kind": "client", + "issuedAt": 1792195200000, + "ttlSecs": 3600, + "orgId": "44444444-4444-4444-8444-444444444444", + "sessionName": "", + "ephemeralId": "browser", + "issuerKind": "human", + "issuerId": "user-test", + "permissionCeiling": "18446744073709551615", + "userId": "user-test", + "sessionId": "session-test", + "region": "br", + "testNumbersOnly": true, + "customer": { + "customerId": "66666666-6666-4666-8666-666666666666", + "sessionIds": [ + "77777777-7777-4777-8777-777777777777" + ], + "allowMask": 7 + }, + "console": { + "projectId": "22222222-2222-4222-8222-222222222222", + "sessionIds": [ + "77777777-7777-4777-8777-777777777777" + ], + "origin": "https://docs.polymorfa.com" + } + }, + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e1044444444444444448444444444444444000762726f777365720209757365722d74657374ffffffffffffffff0766666666666646668666666666666666017777777777774777877777777777777700070c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771a68747470733a2f2f646f63732e706f6c796d6f7266612e636f6d", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e1044444444444444448444444444444444000762726f777365720209757365722d74657374ffffffffffffffff0766666666666646668666666666666666017777777777774777877777777777777700070c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771a68747470733a2f2f646f63732e706f6c796d6f7266612e636f6d", + "signatureHex": "8d06f148b2405d1b8d4e36d5f48fa1dd04bf1f8800c52f6bfeb3ae9ba78d240178b4a4bf2c40cb0d275173f7eef578854e6de5a26a4905bcd0e8e9996c8e8a0e", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAAHYnJvd3NlcgIJdXNlci10ZXN0__________8HZmZmZmZmRmaGZmZmZmZmZgF3d3d3d3dHd4d3d3d3d3d3AAcMc2Vzc2lvbi10ZXN0AmJyIiIiIiIiQiKCIiIiIiIiIgF3d3d3d3dHd4d3d3d3d3d3Gmh0dHBzOi8vZG9jcy5wb2x5bW9yZmEuY29tjQbxSLJAXRuNTjbV9I-h3QS_H4gAxS9r_rOum6eNJAF4tKS_LEDLDSdRc_fu9XiFTm3lompJBbzQ6OmZbI6KDg", + "tokenLength": 346, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e1044444444444444448444444444444444000762726f777365720209757365722d74657374ffffffffffffffff0766666666666646668666666666666666017777777777774777877777777777777700070c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771a68747470733a2f2f646f63732e706f6c796d6f7266612e636f6d8d06f148b2405d1b8d4e36d5f48fa1dd04bf1f8800c52f6bfeb3ae9ba78d240178b4a4bf2c40cb0d275173f7eef578854e6de5a26a4905bcd0e8e9996c8e8a0e", + "tokenHashHex": "1c028f72d26f22febf7ac15087c6bcdcf508d534e753d55927eab7fd6951ccfc" + } + ], + "rejections": [ + { + "name": "console-count-0", + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222200777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222200777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d", + "signatureHex": "1f79ff8699e78ae6644dd0d4fdbb080064028fb9cca75fdadbbd3f1133e145de46452b154995f6ca94831a6fb46602330965d5af8b46be22159a594e5f9dcb0c", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyAgl1c2VyLXRlc3T__________wQMc2Vzc2lvbi10ZXN0AmJyIiIiIiIiQiKCIiIiIiIiIgB3d3d3d3dHd4d3d3d3d3d3GWh0dHBzOi8vd3d3LnBvbHltb3JmYS5jb20fef-GmeeK5mRN0NT9uwgAZAKPucynX9rbvT8RM-FF3kZFKxVJlfbKlIMab7RmAjMJZdWvi0a-IhWaWU5fncsM", + "tokenLength": 312, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222200777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d1f79ff8699e78ae6644dd0d4fdbb080064028fb9cca75fdadbbd3f1133e145de46452b154995f6ca94831a6fb46602330965d5af8b46be22159a594e5f9dcb0c", + "tokenHashHex": "2774cd35e2a8a89ab704f6d1facdd3370b7ac27f6d8714733d45717a51a42d65", + "expectedError": "invalid credential" + }, + { + "name": "console-count-65", + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222241777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222241777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d", + "signatureHex": "db4d67537cfd9dd4337f788c3b8737324864950c6b35c1db2cffbda3f59b86475a58d1c7b6a8938d106595a67fa5224b16b993df3031976c1db15d0da3ec0a0c", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyAgl1c2VyLXRlc3T__________wQMc2Vzc2lvbi10ZXN0AmJyIiIiIiIiQiKCIiIiIiIiIkF3d3d3d3dHd4d3d3d3d3d3GWh0dHBzOi8vd3d3LnBvbHltb3JmYS5jb23bTWdTfP2d1DN_eIw7hzcySGSVDGs1wdss_72j9ZuGR1pY0ce2qJONEGWVpn-lIksWuZPfMDGXbB2xXQ2j7AoM", + "tokenLength": 312, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222241777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6ddb4d67537cfd9dd4337f788c3b8737324864950c6b35c1db2cffbda3f59b86475a58d1c7b6a8938d106595a67fa5224b16b993df3031976c1db15d0da3ec0a0c", + "tokenHashHex": "7fc5152457aa110d956187f119521a8372a2c5a3eb0a65f00c1ede1e002dfaf4", + "expectedError": "invalid credential" + }, + { + "name": "console-duplicate", + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d74657374026272222222222222422282222222222222220277777777777747778777777777777777777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d74657374026272222222222222422282222222222222220277777777777747778777777777777777777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d", + "signatureHex": "45f4fcf4fb03089132977e9ed3775c7671f6065423b2e490f198690593b9edd3901664ddceb7daa455c150ed272f30ed65f67317b4f4c6c53582aa8b19a1af0b", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyAgl1c2VyLXRlc3T__________wQMc2Vzc2lvbi10ZXN0AmJyIiIiIiIiQiKCIiIiIiIiIgJ3d3d3d3dHd4d3d3d3d3d3d3d3d3d3R3eHd3d3d3d3dxlodHRwczovL3d3dy5wb2x5bW9yZmEuY29tRfT89PsDCJEyl36e03dcdnH2BlQjsuSQ8ZhpBZO57dOQFmTdzrfapFXBUO0nLzDtZfZzF7T0xsU1gqqLGaGvCw", + "tokenLength": 334, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d74657374026272222222222222422282222222222222220277777777777747778777777777777777777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d45f4fcf4fb03089132977e9ed3775c7671f6065423b2e490f198690593b9edd3901664ddceb7daa455c150ed272f30ed65f67317b4f4c6c53582aa8b19a1af0b", + "tokenHashHex": "8a342e88bf10e6e0934184ddc44a59af81715e7a89be5a0da275c076097909ad", + "expectedError": "invalid credential" + }, + { + "name": "console-unsorted", + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d74657374026272222222222222422282222222222222220288888888888848888888888888888888777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d74657374026272222222222222422282222222222222220288888888888848888888888888888888777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d", + "signatureHex": "1b9dd31b3175075c6bd62b0465a5c0f5c8ea116875d0961640b9ceed1d2f59db55bfbc86397c8606154e32aa60dd9f58d03a97e8b9cfd0c368b72374a31d2b0d", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyAgl1c2VyLXRlc3T__________wQMc2Vzc2lvbi10ZXN0AmJyIiIiIiIiQiKCIiIiIiIiIgKIiIiIiIhIiIiIiIiIiIiId3d3d3d3R3eHd3d3d3d3dxlodHRwczovL3d3dy5wb2x5bW9yZmEuY29tG53TGzF1B1xr1isEZaXA9cjqEWh10JYWQLnO7R0vWdtVv7yGOXyGBhVOMqpg3Z9Y0DqX6LnP0MNotyN0ox0rDQ", + "tokenLength": 334, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d74657374026272222222222222422282222222222222220288888888888848888888888888888888777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d1b9dd31b3175075c6bd62b0465a5c0f5c8ea116875d0961640b9ceed1d2f59db55bfbc86397c8606154e32aa60dd9f58d03a97e8b9cfd0c368b72374a31d2b0d", + "tokenHashHex": "05325595cd10d34deb5f567fdec1df8e54985605910f2ed80d8397534e9f7e0d", + "expectedError": "invalid credential" + }, + { + "name": "console-origin-https://attacker.example", + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771868747470733a2f2f61747461636b65722e6578616d706c65", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771868747470733a2f2f61747461636b65722e6578616d706c65", + "signatureHex": "d01f216ff2b4b6751a9290ae03af64c15a0403b6cee01fa3013b5e5f2ee8ebaed2ed05daa01f6c2e31c25ea1391a148a93cfa69f046a560842b18ccd1be1f401", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyAgl1c2VyLXRlc3T__________wQMc2Vzc2lvbi10ZXN0AmJyIiIiIiIiQiKCIiIiIiIiIgF3d3d3d3dHd4d3d3d3d3d3GGh0dHBzOi8vYXR0YWNrZXIuZXhhbXBsZdAfIW_ytLZ1GpKQrgOvZMFaBAO2zuAfowE7Xl8u6Ouu0u0F2qAfbC4xwl6hORoUipPPpp8EalYIQrGMzRvh9AE", + "tokenLength": 311, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771868747470733a2f2f61747461636b65722e6578616d706c65d01f216ff2b4b6751a9290ae03af64c15a0403b6cee01fa3013b5e5f2ee8ebaed2ed05daa01f6c2e31c25ea1391a148a93cfa69f046a560842b18ccd1be1f401", + "tokenHashHex": "3595e0a875fc25aa193924b14eaf05365419732bf86f891af7b28a4e27616205", + "expectedError": "invalid credential" + }, + { + "name": "console-origin-https://www.polymorfa.com/", + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771a68747470733a2f2f7777772e706f6c796d6f7266612e636f6d2f", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771a68747470733a2f2f7777772e706f6c796d6f7266612e636f6d2f", + "signatureHex": "caad7224806615ad2b7bbcb8a84411dbbdd9d1d77d6ea1926a0ed43a97b12b12d3b46ca630a0531af131b685c8d9a1ba7d0e001478aed2f8c2bfd93c184b9904", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyAgl1c2VyLXRlc3T__________wQMc2Vzc2lvbi10ZXN0AmJyIiIiIiIiQiKCIiIiIiIiIgF3d3d3d3dHd4d3d3d3d3d3Gmh0dHBzOi8vd3d3LnBvbHltb3JmYS5jb20vyq1yJIBmFa0re7y4qEQR273Z0dd9bqGSag7UOpexKxLTtGymMKBTGvExtoXI2aG6fQ4AFHiu0vjCv9k8GEuZBA", + "tokenLength": 314, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771a68747470733a2f2f7777772e706f6c796d6f7266612e636f6d2fcaad7224806615ad2b7bbcb8a84411dbbdd9d1d77d6ea1926a0ed43a97b12b12d3b46ca630a0531af131b685c8d9a1ba7d0e001478aed2f8c2bfd93c184b9904", + "tokenHashHex": "878a52987e8403584b34f31fa5266188b643bee112a592ae21d0f4610d2c7787", + "expectedError": "invalid credential" + }, + { + "name": "console-origin-https://www.polymorfa.com.attacker.example", + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777772a68747470733a2f2f7777772e706f6c796d6f7266612e636f6d2e61747461636b65722e6578616d706c65", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777772a68747470733a2f2f7777772e706f6c796d6f7266612e636f6d2e61747461636b65722e6578616d706c65", + "signatureHex": "333b1efcb02c44c019ec3447c89ad23b8bc7eb0674d588295daa37da59aad3641affdc76a96f8e225306b845a20763f9276f0944d8a4d81123531f3fd2e51400", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyAgl1c2VyLXRlc3T__________wQMc2Vzc2lvbi10ZXN0AmJyIiIiIiIiQiKCIiIiIiIiIgF3d3d3d3dHd4d3d3d3d3d3Kmh0dHBzOi8vd3d3LnBvbHltb3JmYS5jb20uYXR0YWNrZXIuZXhhbXBsZTM7HvywLETAGew0R8ia0juLx-sGdNWIKV2qN9pZqtNkGv_cdqlvjiJTBrhFogdj-SdvCUTYpNgRI1MfP9LlFAA", + "tokenLength": 335, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777772a68747470733a2f2f7777772e706f6c796d6f7266612e636f6d2e61747461636b65722e6578616d706c65333b1efcb02c44c019ec3447c89ad23b8bc7eb0674d588295daa37da59aad3641affdc76a96f8e225306b845a20763f9276f0944d8a4d81123531f3fd2e51400", + "tokenHashHex": "0c77d7ec6d79df1abebd939565a4032640f1b2153d08ccec9950fc093f5e30cb", + "expectedError": "invalid credential" + }, + { + "name": "console-nil-project", + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262720000000000000000000000000000000001777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262720000000000000000000000000000000001777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d", + "signatureHex": "9e0f685a33f24c0baa9debcf0c26a6bca09d8ad4337e831c761bd3e832ba916a81db22a478acf4a301a79d977a06baf707a95ca3ac476b5b16fabee19a9ad90f", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyAgl1c2VyLXRlc3T__________wQMc2Vzc2lvbi10ZXN0AmJyAAAAAAAAAAAAAAAAAAAAAAF3d3d3d3dHd4d3d3d3d3d3GWh0dHBzOi8vd3d3LnBvbHltb3JmYS5jb22eD2haM_JMC6qd688MJqa8oJ2K1DN-gxx2G9PoMrqRaoHbIqR4rPSjAaedl3oGuvcHqVyjrEdrWxb6vuGamtkP", + "tokenLength": 312, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262720000000000000000000000000000000001777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d9e0f685a33f24c0baa9debcf0c26a6bca09d8ad4337e831c761bd3e832ba916a81db22a478acf4a301a79d977a06baf707a95ca3ac476b5b16fabee19a9ad90f", + "tokenHashHex": "04f462708a86aede99364862e9b88e8d12e21f645db989447e639f893f0873e9", + "expectedError": "invalid credential" + }, + { + "name": "console-truncated", + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f", + "signatureHex": "7b2093c293eb5821490a4014f80e91c1a05ba16a6085345f8a10a07925c59c4a020848865a9da8724220e1570ae317c11f978b1ef8cc21979450931e8f64430e", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyAgl1c2VyLXRlc3T__________wQMc2Vzc2lvbi10ZXN0AmJyIiIiIiIiQiKCIiIiIiIiIgF3d3d3d3dHd4d3d3d3d3d3GWh0dHBzOi8vd3d3LnBvbHltb3JmYS5jb3sgk8KT61ghSQpAFPgOkcGgW6FqYIU0X4oQoHklxZxKAghIhlqdqHJCIOFXCuMXwR-Xix74zCGXlFCTHo9kQw4", + "tokenLength": 311, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f7b2093c293eb5821490a4014f80e91c1a05ba16a6085345f8a10a07925c59c4a020848865a9da8724220e1570ae317c11f978b1ef8cc21979450931e8f64430e", + "tokenHashHex": "89622c7897d7cf30c4a6881278fecc484dba8e546485f36e812cee127022022f", + "expectedError": "invalid credential" + }, + { + "name": "console-trailing", + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d00", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d00", + "signatureHex": "e703ca91e8a33515d7ec3dcff0fe4c851a2d52ac107b928217afcd8279e2e382d0c32a6a2da8017f7e12fbb7e427c8efb344330b245820bd563bd2d56f122609", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyAgl1c2VyLXRlc3T__________wQMc2Vzc2lvbi10ZXN0AmJyIiIiIiIiQiKCIiIiIiIiIgF3d3d3d3dHd4d3d3d3d3d3GWh0dHBzOi8vd3d3LnBvbHltb3JmYS5jb20A5wPKkeijNRXX7D3P8P5MhRotUqwQe5KCF6_Ngnni44LQwypqLagBf34S-7fkJ8jvs0QzCyRYIL1WO9LVbxImCQ", + "tokenLength": 314, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff040c73657373696f6e2d746573740262722222222222224222822222222222222201777777777777477787777777777777771968747470733a2f2f7777772e706f6c796d6f7266612e636f6d00e703ca91e8a33515d7ec3dcff0fe4c851a2d52ac107b928217afcd8279e2e382d0c32a6a2da8017f7e12fbb7e427c8efb344330b245820bd563bd2d56f122609", + "tokenHashHex": "890e6f274f2aac3dc264ab06aec9d47ca29e75c3b1c9f9cc8f3dc492c77cf0b5", + "expectedError": "invalid credential" + }, + { + "name": "reserved-bit3", + "payloadHex": "01036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff080c73657373696f6e2d74657374026272", + "signedInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e456432353531392e636c69656e7400746573740068747470733a2f2f706c6174666f726d2e6578616d706c652e7465737400706f6c796d6f7266612d6170690001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff080c73657373696f6e2d74657374026272", + "signatureHex": "d0bfcd42444d91414a2c554e74e71ec1967218817a6890a7d5b08f3e16a8bb8f17f13392b415350de50995fb078720423fcb8289398c40f060b400354bb7c209", + "token": "pmfa_ct_AQNtYzGIiIiIiIhIiIiIiIiIiIiIAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyAgl1c2VyLXRlc3T__________wgMc2Vzc2lvbi10ZXN0AmJy0L_NQkRNkUFKLFVOdOcewZZyGIF6aJCn1bCPPhaou48X8TOStBU1DeUJlfsHhyBCP8uCiTmMQPBgtAA1S7fCCQ", + "tokenLength": 234, + "fingerprintInputHex": "706f6c796d6f7266612e6d616368696e652d63726564656e7469616c732e76312e686173682e636c69656e740001036d633188888888888848888888888888888888000001a14728840000000e10444444444444444484444444444444440b746573742d6e756d6265720762726f777365720209757365722d74657374ffffffffffffffff080c73657373696f6e2d74657374026272d0bfcd42444d91414a2c554e74e71ec1967218817a6890a7d5b08f3e16a8bb8f17f13392b415350de50995fb078720423fcb8289398c40f060b400354bb7c209", + "tokenHashHex": "88ece95bd41718f55c0a73337dbd82077e48eb7cec9e453cc0f13cf08c941d29", + "expectedError": "invalid credential" + } + ] + }, + "negative": [ + { + "name": "test-client-against-live-number", + "operation": "authorize-number", + "input": { + "clientCase": "project-issued-test-only", + "numberTestMode": false + }, + "expectedError": "forbidden" + }, + { + "name": "test-client-unknown-number-mode", + "operation": "authorize-number", + "input": { + "clientCase": "project-issued-test-only", + "numberTestMode": null + }, + "expectedError": "forbidden" + } + ], + "consoleOrigins": [ + "https://www.polymorfa.com", + "https://docs.polymorfa.com" + ] +} diff --git a/test/input/stdin.test.ts b/test/input/stdin.test.ts index abaa3e46..5e691800 100644 --- a/test/input/stdin.test.ts +++ b/test/input/stdin.test.ts @@ -7,13 +7,9 @@ describe('readSecretFromStdin', () => { test('reads one trimmed secret without returning extra input', async () => { await expect( readSecretFromStdin( - Readable.from([ - ' pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA\n', - ]), + Readable.from([' pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k\n']), ), - ).resolves.toBe( - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', - ) + ).resolves.toBe('pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k') }) test('rejects empty and oversized input', async () => { diff --git a/test/mcp/session-discovery.test.ts b/test/mcp/session-discovery.test.ts index 0c7dcc87..1437f7c2 100644 --- a/test/mcp/session-discovery.test.ts +++ b/test/mcp/session-discovery.test.ts @@ -8,7 +8,7 @@ import { } from '../helpers/http-server.js' const PROJECT_ID = '11111111-1111-4111-8111-111111111111' -const API_KEY = `pmfa_${'A'.repeat(72)}` +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' describe('MCP session discovery', () => { let server: TestHttpServer | undefined @@ -79,7 +79,7 @@ describe('MCP session discovery', () => { }, () => listMcpSessions(PROJECT_ID), ), - ).rejects.toThrow('Select only one organization API key') + ).rejects.toThrow('Select only one team key') expect(server.requests).toHaveLength(0) }) }) diff --git a/test/sdk/client.test.ts b/test/sdk/client.test.ts index ef7a8423..fd34fb35 100644 --- a/test/sdk/client.test.ts +++ b/test/sdk/client.test.ts @@ -20,8 +20,7 @@ describe('createRawClient', () => { surface: 'platform', credential: { type: 'apiKey', - value: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + value: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, }) @@ -31,7 +30,11 @@ describe('createRawClient', () => { test('returns the public Messaging raw client', () => { const client = createRawClient({ surface: 'messaging', - credential: {type: 'clientToken', value: 'pmfa_ct_browser'}, + credential: { + type: 'clientToken', + value: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', + }, }) expect(client).toBeInstanceOf(RawClient) @@ -41,11 +44,13 @@ describe('createRawClient', () => { expect(() => createRawClient({ surface: 'platform', - credential: {type: 'clientToken', value: 'pmfa_ct_browser'}, + credential: { + type: 'clientToken', + value: + 'pmfa_ct_AQNtYzEzMzMzMzNDM4MzMzMzMzMzAAABoUcohAAAAA4QRERERERERESERERERERERAt0ZXN0LW51bWJlcgdicm93c2VyASIiIiIiIkIigiIiIiIiIiL__________wBTX4VcUmrk5uKjY5gnH00LcrWKw9eWe18PVEgJs1Z_49lLYduDasQEoXgU0QxV5mty5r5Q5M9IG6tYvCvbUIEO', + }, }), - ).toThrow( - 'Client and project tokens cannot be used as organization server API keys.', - ) + ).toThrow('Client and project tokens cannot be used as team keys.') }) test('creates a typed Messaging client with session and operation resources', () => { @@ -53,8 +58,7 @@ describe('createRawClient', () => { surface: 'messaging', credential: { type: 'apiKey', - value: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + value: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, timeoutMs: 1234, }) @@ -72,8 +76,7 @@ describe('createRawClient', () => { const client = createMessagingClient({ credential: { type: 'projectToken', - value: - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + value: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, surface: 'messaging', }) @@ -87,8 +90,7 @@ describe('createRawClient', () => { surface: 'platform', credential: { type: 'apiKey', - value: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + value: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, }) @@ -110,8 +112,7 @@ describe('createRawClient', () => { surface: 'messaging', credential: { type: 'apiKey', - value: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + value: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, }), ).toThrow('requires the Platform API surface') @@ -127,8 +128,7 @@ describe('createRawClient', () => { baseUrl: server.baseUrl, credential: { type: 'apiKey', - value: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + value: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, surface: 'platform', }).project('11111111-1111-4111-8111-111111111111') @@ -143,7 +143,7 @@ describe('createRawClient', () => { url: '/platform/projects/11111111-1111-4111-8111-111111111111/events?limit=25', }) expect(server.requests[0]?.headers.authorization).toBe( - 'Bearer pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'Bearer pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', ) } finally { await server.close() diff --git a/test/services/auth.test.ts b/test/services/auth.test.ts index d0501c27..2ac78d4d 100644 --- a/test/services/auth.test.ts +++ b/test/services/auth.test.ts @@ -9,8 +9,7 @@ describe('AuthService', () => { const fixture = createFixture() const result = await fixture.service.login({ - apiKey: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + apiKey: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', profile: 'work', }) @@ -21,7 +20,7 @@ describe('AuthService', () => { }) expect(fixture.credentials.set).toHaveBeenCalledWith( 'profile:work:apiKey', - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', ) expect(fixture.saved()).toEqual({ activeProfile: 'work', @@ -33,17 +32,41 @@ describe('AuthService', () => { version: 2, }) expect(JSON.stringify(fixture.saved())).not.toContain( - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', ) }) + test('delegates checksum checks without fixing credential lengths', async () => { + const fixture = createFixture() + for (const projectToken of [ + 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', + 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhMUFRYXGBkaGxwdHh-RJn6K', + ]) { + await expect( + fixture.service.loginProjectToken({ + profile: 'growth', + project: 'project_1', + projectToken, + }), + ).resolves.toMatchObject({credentialKind: 'projectToken'}) + } + vi.mocked(fixture.credentials.set).mockClear() + await expect( + fixture.service.loginProjectToken({ + profile: 'growth', + project: 'project_1', + projectToken: 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-A', + }), + ).rejects.toThrow('canonical pmfa_pt_ opaque format') + expect(fixture.credentials.set).not.toHaveBeenCalled() + }) + test('rejects an invalid profile name before touching secure storage', async () => { const fixture = createFixture() await expect( fixture.service.login({ - apiKey: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + apiKey: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', profile: '../work', }), ).rejects.toThrow('Profile names may contain') @@ -55,13 +78,13 @@ describe('AuthService', () => { await expect( fixture.service.login({apiKey: 'secret', profile: 'work'}), - ).rejects.toThrow('must use the canonical pmfa_ v1 format') + ).rejects.toThrow('must use the canonical pmfa_ opaque format') 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)}` + const token = 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' await expect( fixture.service.loginProjectToken({ @@ -88,13 +111,12 @@ describe('AuthService', () => { await fixture.service.loginProjectToken({ profile: 'project', project: 'project_1', - projectToken: - ' pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA ', + projectToken: ' pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k ', }) expect(fixture.credentials.set).toHaveBeenCalledWith( 'profile:project:projectToken', - 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', ) await expect( fixture.service.loginProjectToken({ @@ -102,7 +124,7 @@ describe('AuthService', () => { project: 'project_1', projectToken: 'pmfa_pt_', }), - ).rejects.toThrow('canonical pmfa_pt_ v1 format') + ).rejects.toThrow('canonical pmfa_pt_ opaque format') }) test('removes the previous project-token keyring entry after switching a profile to an API key', async () => { @@ -120,12 +142,11 @@ describe('AuthService', () => { version: 2, }) vi.mocked(fixture.credentials.get).mockResolvedValue( - `pmfa_pt_${'A'.repeat(94)}`, + 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', ) await fixture.service.login({ - apiKey: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + apiKey: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', profile: 'work', }) @@ -157,7 +178,7 @@ describe('AuthService', () => { }, version: 2, }) - const oldToken = `pmfa_pt_${'A'.repeat(94)}` + const oldToken = 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' vi.mocked(fixture.credentials.get).mockResolvedValue(oldToken) vi.mocked(fixture.repository.save).mockRejectedValue( new Error('profile save failed'), @@ -165,8 +186,7 @@ describe('AuthService', () => { await expect( fixture.service.login({ - apiKey: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + apiKey: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', profile: 'work', }), ).rejects.toThrow('profile save failed') @@ -216,7 +236,7 @@ describe('AuthService', () => { test('switches the active profile and reports secure credential availability', async () => { const fixture = createFixture(profileConfig()) vi.mocked(fixture.credentials.get).mockResolvedValue( - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', ) await expect(fixture.service.use('work')).resolves.toMatchObject({ @@ -250,7 +270,7 @@ describe('AuthService', () => { version: 2, }) vi.mocked(fixture.credentials.get).mockResolvedValue( - `pmfa_pt_${'A'.repeat(94)}`, + 'pmfa_pt_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', ) await expect(fixture.service.status('project')).resolves.toMatchObject({ diff --git a/test/services/doctor.test.ts b/test/services/doctor.test.ts index 67337e1d..653110b4 100644 --- a/test/services/doctor.test.ts +++ b/test/services/doctor.test.ts @@ -9,8 +9,7 @@ import type {CredentialStore} from '../../src/config/credentials.js' import type {ProfileConfig} from '../../src/config/profiles.js' import {runDoctor, type DoctorDependencies} from '../../src/services/doctor.js' -const KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const config: ProfileConfig = { activeProfile: 'work', profiles: { diff --git a/test/services/groups.test.ts b/test/services/groups.test.ts index 6306df2c..11efc5b8 100644 --- a/test/services/groups.test.ts +++ b/test/services/groups.test.ts @@ -106,8 +106,7 @@ function client(baseUrl: string) { baseUrl, credential: { type: 'apiKey', - value: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + value: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, surface: 'messaging', }) diff --git a/test/services/projects.test.ts b/test/services/projects.test.ts index b27158da..972201af 100644 --- a/test/services/projects.test.ts +++ b/test/services/projects.test.ts @@ -17,8 +17,7 @@ import { } from '../../src/context/environment.js' import {ProjectContextService} from '../../src/services/projects.js' -const API_KEY = - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const API_KEY = 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' const APP = '11111111-1111-4111-8111-111111111111' const WEB = '22222222-2222-4222-8222-222222222222' diff --git a/test/services/voice.test.ts b/test/services/voice.test.ts index c4d8744a..abd30a89 100644 --- a/test/services/voice.test.ts +++ b/test/services/voice.test.ts @@ -145,8 +145,7 @@ describe('voice transfers', () => { baseUrl: 'https://api.example.test', credential: { type: 'apiKey' as const, - value: - 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + value: 'pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k', }, surface: 'platform' as const, ...(timeoutMs === undefined ? {} : {timeoutMs}),