diff --git a/README.md b/README.md index e902231..bc6d77d 100644 --- a/README.md +++ b/README.md @@ -13,7 +13,7 @@ this SDK's initial scope. | Package | Runtime | Responsibility | | --------------------- | ------------------- | --------------------------------------------------------------------------------- | -| `@polymorfa/sdk` | Node.js 20+ | Messaging and Platform server clients, webhooks, and raw requests | +| `@polymorfa/sdk` | Node.js 20+ | Messaging, management, system, and Bridge server clients | | `@polymorfa/browser` | Browser | Client-token transport and framework-neutral product controllers | | `@polymorfa/ui` | Isomorphic | Appearance, locale, direction, motion, and diagnostic contracts | | `@polymorfa/elements` | Browser | Portable custom elements for React-free, Vue, Svelte, and plain HTML applications | @@ -38,7 +38,12 @@ The Git install runs the package build through `prepare`. The published package name and root import are already stable: ```ts -import { MessagingClient, PlatformClient } from "@polymorfa/sdk"; +import { + BridgeClient, + Client, + MessagingClient, + SystemClient, +} from "@polymorfa/sdk"; ``` Node.js 20 or newer is required. The package has no runtime dependencies. @@ -53,7 +58,7 @@ const messaging = new MessagingClient({ type: "apiKey", value: process.env.POLYMORFA_MESSAGING_API_KEY!, }, - apiVersion: "2026-08-19", + apiVersion: "1.0.0", }); const sessions = await messaging.sessions.list(); @@ -121,31 +126,66 @@ The handwritten Messaging resources in this milestone are: LID-backed user ID - `webhooks`: list, create, retrieve, update, and delete -## Platform client +## Management client ```ts -import { PlatformClient } from "@polymorfa/sdk"; +import { Client } from "@polymorfa/sdk"; -const platform = new PlatformClient({ - apiKey: process.env.POLYMORFA_PLATFORM_API_KEY!, +const client = new Client({ + credential: { + type: "organizationApiKey", + value: process.env.POLYMORFA_PLATFORM_API_KEY!, + }, }); -const projects = await platform.projects.list(); -const sessions = await platform.sessions.list({ +const projects = await client.projects.list(); +const sessions = await client.sessions.list({ projectId: projects.data.data[0]?._id, }); -const campaign = await platform.campaigns.create( - { projectId: projects.data.data[0]?._id, name: "August launch" }, - { idempotencyKey: crypto.randomUUID() }, -); -console.log(campaign.data.data, campaign.metadata.requestId); +const project = client.project("project_123"); +const events = await project.events.list({ limit: 25 }); +console.log(events.items, events.response.metadata.requestId); ``` -The Platform client accepts only `pmfa_` server API keys. It rejects client -tokens and project tokens before making a request. +`Client` binds its ownership context when you construct it. An organization +API key without `projectId` creates an organization client. Call +`client.project(projectId)` to create an immutable project view, or construct a +project view directly with an organization key or a `pmfa_pt_` project token: -The handwritten Platform resources in this milestone are: +```ts +const project = new Client({ + credential: { + type: "projectToken", + value: process.env.POLYMORFA_PROJECT_TOKEN!, + }, + projectId: "project_123", +}); +``` + +Project tokens require an explicit project ID. The server verifies the initial +token-to-project binding. A later attempt to bind that client to another +project fails before transport. `Client` also rejects browser client tokens and +the CLI-only `pmfa_ls_` listener credential before transport. + +Both organization and project views expose owner-bound resources: + +- `events`: list, retrieve, and replay durable events +- `webhooks`: list, create, retrieve, update, delete, test, and rotate secrets +- `webhookDeliveries`: list and retrieve deliveries, list and retrieve their + physical attempts, and retry a delivery +- `operations`: list, retrieve, list transitions, cancel, and wait for a + terminal state +- `quickLinkSettings`: retrieve and update the saved QuickLink configuration + +List methods return `CursorPage`. Mutations return typed receipts with the +resource, operation, and idempotency identifiers supplied by the API. The +SDK-only `operations.wait()` helper polls `retrieve`; it does not create a +second remote operation or cancel the remote operation when local waiting is +aborted. A larger server `Retry-After` raises the next poll delay without +extending the caller's total wait deadline. + +The organization view also exposes these management resources: - `organizations`: retrieve the organization visible to the API key - `apiKeys`: list key metadata and deactivate an organization API key @@ -154,15 +194,12 @@ The handwritten Platform resources in this milestone are: and limit filters - `sessionBans`: list all or active session bans - `securityIncidents`: list and acknowledge leaked-credential incidents -- `operations`: retrieve durable asynchronous operation state - `projectTokens`: list token metadata for an explicit project - `billing`: retrieve balance and currency, inspect usage meters, list transactions and tier pricing, and update low-balance reminders - `projects`: list, create, request production enrollment, approve, and cancel -- `sessions`: list, stop or delete one session, stop or delete a bounded batch, - set tier override, and create a testing session -- `widgetSettings`: retrieve organization or project Connect widget settings - and update the exact saved configuration fields +- `sessions`: list, start, stop, or delete one session; stop or delete a bounded + batch; set tier override; and create a testing session - `campaigns`: list, create, retrieve, update, delete, lifecycle actions, analytics, events, and recipients - `customers`: enable Customers for a project; create, list, retrieve, update, @@ -182,10 +219,47 @@ operation payloads as open objects. These methods therefore use the exported `PlatformPayload` type instead of claiming fields the contract does not define. Platform template and Flow endpoints require a live dashboard bearer and reject -organization server keys. They are intentionally absent from `PlatformClient`; +organization server keys. They are intentionally absent from `Client`; browser template tooling must reach them through an application-owned server adapter that authorizes the signed-in user. +## System and Bridge clients + +`SystemClient` calls the credential-free status, version, readiness, and +liveness routes. It does not accept a credential: + +```ts +import { SystemClient } from "@polymorfa/sdk"; + +const system = new SystemClient(); +const [status, version, health, ping] = await Promise.all([ + system.status(), + system.version(), + system.health(), + system.ping(), +]); +``` + +`BridgeClient` accepts only a project token and exposes one discovery method: + +```ts +import { BridgeClient } from "@polymorfa/sdk"; + +const bridge = new BridgeClient({ + credential: { + type: "projectToken", + value: process.env.POLYMORFA_PROJECT_TOKEN!, + }, +}); + +const route = await bridge.routes.resolve(); +console.log(route.data.wsUrl, route.data.expiresAt); +``` + +Route discovery returns the regional Bridge connection details. The client does +not open the WebSocket, manage reconnects, or participate in the CLI listener +protocol. A `pmfa_ls_` listener credential is rejected before transport. + ## Response metadata and errors Every request resolves to an `ApiResponse`: @@ -217,7 +291,7 @@ them on the client or override them for one request: ```ts const controller = new AbortController(); -const response = await platform.projects.create( +const response = await client.projects.create( { name: "Support" }, { signal: controller.signal, @@ -238,19 +312,22 @@ honors `Retry-After`, then uses bounded exponential backoff with jitter. Set `apiVersion` on a client or a single request. The SDK sends it as the `Polymorfa-Version` header. -Every client exposes `raw.request()` for endpoints without a curated method: +Every client exposes `raw.request()` for deliberate API escape hatches: ```ts -const response = await platform.raw.request<{ data: unknown }>({ +const response = await client.raw.request<{ data: unknown }>({ method: "GET", path: "/v1/operations/operation_123", query: { projectId: "project_123" }, }); ``` -Raw paths must start with one slash and cannot be absolute URLs, preventing a -credential from being forwarded to another host. Raw requests retain typed -errors, metadata, cancellation, API versions, retry rules, and idempotency. +Organization raw paths remain relative API paths. Project raw paths are +relative to the bound project and receive the encoded +`/v1/projects/{projectId}` prefix automatically. Project raw requests reject +absolute URLs, traversal, explicit project prefixes, backslashes, and +`Authorization` overrides before transport. Raw requests retain typed errors, +metadata, cancellation, API versions, retry rules, and idempotency. `raw.paginate()` accepts a page decoder and returns `CursorPage`, which supports `items`, `nextCursor`, `hasMore`, `nextPage()`, and async item @@ -263,13 +340,13 @@ CLI. Verify the exact raw request body before parsing: ```ts -import { constructWebhookEvent, isEvent } from "@polymorfa/sdk"; +import { isEvent, webhooks } from "@polymorfa/sdk"; -const event = await constructWebhookEvent( - rawBody, - signatureHeader, - webhookSecret, -); +const event = await webhooks.verify({ + body: rawBody, + signature: signatureHeader, + secret: webhookSecret, +}); if (isEvent(event, "message.received")) { console.log(event.payload); } @@ -282,14 +359,33 @@ events narrow to exported payload types, including messages, sessions, groups, presence, contacts, chats, calls, labels, history sync, command results, and business quick replies. Unknown event names and payloads are preserved for forward compatibility. +`webhooks.verifySignature()` returns a boolean without parsing. +`webhooks.createFixture()` creates exact-byte local fixtures, and +`webhooks.verifyLocal()` verifies payloads re-signed by local CLI forwarding. +The older `constructWebhookEvent` and `verifyWebhookSignature` exports remain +available through the first stable major. A later major can remove them with a +migration release. Messaging server credentials can manage webhook registrations through -`MessagingClient.webhooks`. The Platform contract also defines organization -and project event listing and replay, webhook management, delivery inspection -and retry, and durable operation management. These Platform resources remain -missing from the handwritten SDK, apart from organization operation retrieval -through `PlatformClient.operations.retrieve`. The coverage ledger records each -gap. Dashboard and staff routes retain their separate credential requirements. +`MessagingClient.webhooks`. The management `Client` owns the separate durable +organization and project event, webhook, delivery, attempt, and operation +resources described above. Dashboard and staff routes retain their separate +credential requirements. + +The SDK has no listener, event stream, `AsyncIterable`, or forwarding API. +`polymorfa listen` connects to a separate CLI-only protocol; its `pmfa_ls_` +credential cannot be used by `Client`, `MessagingClient`, or their raw request +helpers. + +## QuickLink settings + +`client.quickLinkSettings.retrieve()` and `update()` map only the management +`GET /v1/quicklink` and `PUT /v1/quicklink` settings contract. The same methods +on `client.project(projectId)` use the immutable project ownership context. + +The SDK does not expose hosted QuickLink creation, inspection, or cancellation +for `/api/quicklinks`. Those ephemeral flows belong to an application adapter +and the browser controller, not the management client. ## Browser controllers and UI @@ -382,7 +478,7 @@ colors derive from the shared appearance variables. `@polymorfa/nextjs` builds Web `Request`/`Response` handlers, so it has no Next.js runtime dependency. Applications provide their own authorization and minting logic; webhook helpers read raw bytes once and delegate verification to -`constructWebhookEvent` from the server SDK. +`webhooks.verify` from the server SDK. Template routes use the same application-owned authorization boundary. Project scope and Cloud API session selection are resolver callbacks that run only on @@ -396,16 +492,12 @@ subpath exports an inert mount function. ## Coverage status -`contracts/coverage.json` records all 402 Messaging and Platform operations in -its pinned contract: 226 covered, 103 missing, 73 excluded, and zero changed -fingerprints. Covered mappings include methods in the server, browser, and -Calls packages. A mapping records an HTTP operation, not package publication -or live-call readiness. - -The five programmatic Calls operations map to `HttpCallsApi.place`, `accept`, -`reject`, `addParticipant`, and `setMode`. QuickLink routes and the new durable -Platform resources retain explicit missing entries. Existing widget methods -still request removed routes and do not count as QuickLink coverage. +`contracts/coverage.json` records every Messaging and Platform operation in its +pinned contracts. Covered mappings include methods in the server, browser, and +Calls packages. A mapping records an HTTP operation, not package publication or +live-call readiness. The durable management resources and QuickLink settings +map to typed `Client` resources; dashboard, staff, and CLI-listener routes keep +explicit credential-boundary exclusions. `npm run check:coverage` requires every contract operation to have a ledger row. It permits explicit missing and excluded entries; passing that check does diff --git a/contracts/README.md b/contracts/README.md index ad9ad2e..5a2043f 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -7,9 +7,9 @@ paths and SHA-256 hashes. `coverage.json` uses the same source revision. | Status | Operations | | ------------------- | ---------: | -| Covered | 226 | -| Missing | 103 | -| Excluded | 73 | +| Covered | 271 | +| Missing | 0 | +| Excluded | 131 | | Partial | 0 | | Changed fingerprint | 0 | | Total | 402 | @@ -28,30 +28,20 @@ nullable terminal-call callers, participant lifecycle events, client-token delegation scopes, and TURN health diagnostics. These changes do not alter the operation coverage totals or the existing missing-operation inventory. -## Previous reconciliation - -The previous snapshot added 22 operations and removed 11 widget operations. -The added operations were five implemented Calls -operations, ten missing QuickLink operations, and seven Console-only -operations excluded by their credential contract. +This snapshot records complete handwritten TypeScript coverage for every +customer-credential-compatible operation in the pinned contracts. Routes that +require console, staff, browser, or ephemeral QuickLink credentials are +excluded with an operation-specific reason. `HttpCallsApi.place`, `accept`, `reject`, `addParticipant`, and `setMode` cover the five Calls operations. Request tests invoke these methods and check the HTTP method, encoded path, body, authentication, and response handling. -The removed widget rows included three covered mappings. Two pointed to -`PlatformClient.widgetSettings` methods that still request `/v1/widget`; one -pointed to `BrowserMessagingClient.widget.handoff`. None implements the new -QuickLink routes. The ledger removes those old rows and keeps QuickLink -missing. - -All 93 existing missing entries remain missing. Of those, 38 had the stale -reason "Operation is absent from the coverage ledger" even though their rows -were already present. Their reasons now describe the missing methods. The -existing durable Platform gaps comprise 37 operations across organization and -project events, webhooks, deliveries, and operations; session start is the -other entry in that group of 38. Organization operation retrieval remains -covered by `PlatformClient.operations.retrieve`. +The unified `Client` owns organization control-plane resources and creates +immutable project views with `client.project(projectId)`. QuickLink management +uses `Client.quickLinkSettings`; obsolete `/v1/widget` mappings are gone. +Credential-free service probes use `SystemClient`, project-token Bridge route +discovery uses `BridgeClient`, and listener transport remains CLI-only. ## Updating the ledger diff --git a/contracts/coverage.json b/contracts/coverage.json index 37fdc15..9305426 100644 --- a/contracts/coverage.json +++ b/contracts/coverage.json @@ -26,9 +26,9 @@ "operationId": "cancelQuickLink", "fingerprint": "763ce4c4e8f4fdec20a081a53a40bcbc10101940bbe9fc41647ca5338215d5f7", "typescript": { - "status": "missing", - "reason": "The QuickLink controller uses an application-provided backend; no handwritten transport implements this QuickLink route.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Ephemeral hosted QuickLink orchestration is outside the public server SDK management-resource contract; browser QuickLink uses an application-provided backend adapter.", + "milestone": "not-server-sdk" } }, { @@ -182,9 +182,8 @@ "operationId": "getStatus", "fingerprint": "d718b1693efe722ebf046a8fe6525eccb179052320870a2ce40dbeb63e612f87", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "SystemClient.status" } }, { @@ -194,9 +193,8 @@ "operationId": "getVersion", "fingerprint": "2ab7906884ffa07c978e9378490a193652ce1e1e960459908e23c4ab76838996", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "SystemClient.version" } }, { @@ -305,9 +303,9 @@ "operationId": "getQuickLink", "fingerprint": "76ece78254ba9f50816a3ebb40365ac699bd7b2ceb4b15134a3e6c2521672b4c", "typescript": { - "status": "missing", - "reason": "The QuickLink controller uses an application-provided backend; no handwritten transport implements this QuickLink route.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Ephemeral hosted QuickLink orchestration is outside the public server SDK management-resource contract; browser QuickLink uses an application-provided backend adapter.", + "milestone": "not-server-sdk" } }, { @@ -791,9 +789,8 @@ "operationId": "getHealth", "fingerprint": "05bab58b71b5692f804870852a686a9b4e140255899a6058b9aa284b5a0ed45c", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "SystemClient.health" } }, { @@ -803,9 +800,8 @@ "operationId": "ping", "fingerprint": "59aa902499566c7e84335b9f53e2ef306e5732bdf1228f03d571c03077c7b329", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "SystemClient.ping" } }, { @@ -815,9 +811,9 @@ "operationId": "getQuickLinkState", "fingerprint": "3d23a1ff64a1b8d149d49f511048526b58c11754de51e8b1b6b3e2a386c26731", "typescript": { - "status": "missing", - "reason": "The QuickLink controller uses an application-provided backend; no handwritten transport implements this QuickLink route.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Credential-free public QuickLink participant route owned by browser UI transport, not the server SDK.", + "milestone": "not-server-sdk" } }, { @@ -827,9 +823,9 @@ "operationId": "getQuickLinkLogo", "fingerprint": "36238ba60ff6e55aa2cf2bc0a0558d9da149de02e6b75c3592c1f8274665fd0d", "typescript": { - "status": "missing", - "reason": "The QuickLink controller uses an application-provided backend; no handwritten transport implements this QuickLink route.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Credential-free public QuickLink participant route owned by browser UI transport, not the server SDK.", + "milestone": "not-server-sdk" } }, { @@ -839,9 +835,8 @@ "operationId": "getBridgeRoute", "fingerprint": "c6e3c36cb20cdcf0c6c6991a495c676891c1e6d5f53ef83e754d4c9d66a2d801", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "BridgeClient.routes.resolve" } }, { @@ -1027,9 +1022,9 @@ "operationId": "createQuickLink", "fingerprint": "0f8aa33283ca0a7b8f5ce21aec512a471ec96fee773a16f1bf96e55c5cea9367", "typescript": { - "status": "missing", - "reason": "The QuickLink controller uses an application-provided backend; no handwritten transport implements this QuickLink route.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Ephemeral hosted QuickLink orchestration is outside the public server SDK management-resource contract; browser QuickLink uses an application-provided backend adapter.", + "milestone": "not-server-sdk" } }, { @@ -1050,9 +1045,9 @@ "operationId": "sessionHandoffAck", "fingerprint": "24eb38203f6a285b118bc82facb5351a42fe70243ef790cef65be01fd6d71139", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Encrypted session handoff remains outside the public SDK until its dedicated security and atomicity contract is complete.", + "milestone": "security-design" } }, { @@ -1062,9 +1057,9 @@ "operationId": "sessionHandoffImport", "fingerprint": "8912116b0231eb1a64c7d45aedb45aa11c9251c8fed13a0db8e7ca936a4ba5bf", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Encrypted session handoff remains outside the public SDK until its dedicated security and atomicity contract is complete.", + "milestone": "security-design" } }, { @@ -1074,9 +1069,9 @@ "operationId": "sessionHandoffKeys", "fingerprint": "a528ac23c017ee7e74576d55862bd80793b4bbecf68bb1e353d38b7a3f647fc7", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Encrypted session handoff remains outside the public SDK until its dedicated security and atomicity contract is complete.", + "milestone": "security-design" } }, { @@ -1130,9 +1125,9 @@ "operationId": "sessionUploadAppStateKeys", "fingerprint": "dcfe439c4855dd14296883928f3fa9a3b0d9b1a2e75394c361880f3dc7dd5af2", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Encrypted session handoff remains outside the public SDK until its dedicated security and atomicity contract is complete.", + "milestone": "security-design" } }, { @@ -1694,9 +1689,9 @@ "operationId": "cancelQuickLinkByToken", "fingerprint": "5447efd63f6d2dae94fad5a3f4373f8952d45d88e67009285a0cab08a037a28d", "typescript": { - "status": "missing", - "reason": "The QuickLink controller uses an application-provided backend; no handwritten transport implements this QuickLink route.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Credential-free public QuickLink participant route owned by browser UI transport, not the server SDK.", + "milestone": "not-server-sdk" } }, { @@ -1706,9 +1701,9 @@ "operationId": "requestQuickLinkCode", "fingerprint": "bab38293fe497b4e710cfeeb2f02d02ef3448106271de993df4a07191f8c4a59", "typescript": { - "status": "missing", - "reason": "The QuickLink controller uses an application-provided backend; no handwritten transport implements this QuickLink route.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Credential-free public QuickLink participant route owned by browser UI transport, not the server SDK.", + "milestone": "not-server-sdk" } }, { @@ -1718,9 +1713,9 @@ "operationId": "confirmQuickLink", "fingerprint": "776fd122dd6f6bf8b69b86c4a4289718e660e989fd6053f02b212b12f06a2e0e", "typescript": { - "status": "missing", - "reason": "The QuickLink controller uses an application-provided backend; no handwritten transport implements this QuickLink route.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Credential-free public QuickLink participant route owned by browser UI transport, not the server SDK.", + "milestone": "not-server-sdk" } }, { @@ -2005,9 +2000,9 @@ "operationId": "adminRevokeApiKeyApiKey", "fingerprint": "a4ad0cb9129d422eb4da110b5c2fe93d0ee437cf7b8878f8afd1b1e1bf0c4736", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2017,9 +2012,9 @@ "operationId": "adminRemoveMember", "fingerprint": "75d3e00264714ff72c0e528d36e599ff734d6ac84f24a90eeedd353b71e38f41", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2029,9 +2024,9 @@ "operationId": "adminRevokeOrganizationInvitation", "fingerprint": "fc969ae102b8fa7b5a9cffce1db7cc909764e6937ab248a690ee51d283b22cbf", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2041,9 +2036,9 @@ "operationId": "adminCascadeDeleteOrganization", "fingerprint": "4e099164b6c7541cec09a27f50ae82c89cfa860f24d4c7ba23c0e0331df3c98e", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2053,9 +2048,9 @@ "operationId": "adminForceDeleteSession", "fingerprint": "85c5c1a65cc2541606d9eaf2d12843af119952e4e508fae77116bd201a8ad8c6", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2065,9 +2060,9 @@ "operationId": "adminResetOnboardingUser", "fingerprint": "87e04025c3c6ce6859b53da46e3493d43ede6a17d776b1b24eba3618cd8e1b37", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2150,7 +2145,7 @@ "fingerprint": "89db8d46b387f9b7ea94363f3205e01abdc11a81250bd4029e78b0d54dea5726", "typescript": { "status": "covered", - "method": "PlatformClient.audiences.delete" + "method": "Client.audiences.delete" } }, { @@ -2161,7 +2156,7 @@ "fingerprint": "6e2e2556a22b8f5110eefed455820a0be2a58ed7ca60392ef3b865eef2b073a7", "typescript": { "status": "covered", - "method": "PlatformClient.campaigns.delete" + "method": "Client.campaigns.delete" } }, { @@ -2172,7 +2167,7 @@ "fingerprint": "0e3a8b41ae00c04078420785edd14df2b073216b6135790a0a6bfbbd27e78a50", "typescript": { "status": "covered", - "method": "PlatformClient.customers.revokePairingLink" + "method": "Client.customers.revokePairingLink" } }, { @@ -2183,7 +2178,7 @@ "fingerprint": "75623976db5350942aef9181408d6d7a3264013ba9c34e37abd1288a671d9f3c", "typescript": { "status": "excluded", - "reason": "This dashboard-only route does not accept the organization server API key used by PlatformClient.", + "reason": "This dashboard-only route does not accept the organization server API key used by Client.", "milestone": "credential-boundary" } }, @@ -2195,7 +2190,7 @@ "fingerprint": "ad299f8afd661838296ea39fce5b41a50c678a6e5aaf8425625815fa1d4885a7", "typescript": { "status": "covered", - "method": "PlatformClient.apiKeys.deactivate" + "method": "Client.apiKeys.deactivate" } }, { @@ -2206,7 +2201,7 @@ "fingerprint": "21733c4f3e2c6fa9c0fdb7dc40e32cdd27e22a282b3fa5708b1b008684a301b1", "typescript": { "status": "covered", - "method": "PlatformClient.media.delete" + "method": "Client.media.delete" } }, { @@ -2217,7 +2212,7 @@ "fingerprint": "88c312dbdc2b8ef1d666704ca7e94d457611c4c6e66ff54d1ea2331baa8e0e55", "typescript": { "status": "excluded", - "reason": "This dashboard-only member mutation accepts ConsoleSession authentication and rejects the organization server API key used by PlatformClient.", + "reason": "This dashboard-only member mutation accepts ConsoleSession authentication and rejects the organization server API key used by Client.", "milestone": "credential-boundary" } }, @@ -2229,7 +2224,7 @@ "fingerprint": "1107ac53c52a4b3bf8f517974d437a03abc8b947ba50b47a90b5e308a8907ec9", "typescript": { "status": "covered", - "method": "PlatformClient.optOuts.delete" + "method": "Client.optOuts.delete" } }, { @@ -2239,9 +2234,8 @@ "operationId": "deleteProjectWebhook", "fingerprint": "2555d7a3c0501769b3560377c8595ee3413e7d38e29d91a21156b8a26cabc47b", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).webhooks.delete" } }, { @@ -2252,7 +2246,7 @@ "fingerprint": "009cdf86b86ea287b943a22b2958b1d4bdbbd641d67881b6193868d847322d44", "typescript": { "status": "covered", - "method": "PlatformClient.sessions.delete" + "method": "Client.sessions.delete" } }, { @@ -2263,7 +2257,7 @@ "fingerprint": "dd72834096e3536cd909d750f8f8fd88e8f94c3d3ce041cacf760eecbd873fcd", "typescript": { "status": "excluded", - "reason": "This dashboard-only route does not accept the organization server API key used by PlatformClient.", + "reason": "This dashboard-only route does not accept the organization server API key used by Client.", "milestone": "credential-boundary" } }, @@ -2274,9 +2268,8 @@ "operationId": "deleteOrganizationWebhook", "fingerprint": "315c17d7e078e1003e719e9794627813f51d9e1ae40281f1b2c374555a41cfc5", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.webhooks.delete" } }, { @@ -2286,9 +2279,9 @@ "operationId": "adminListAuditLogGlobal", "fingerprint": "cf1754fa03d280a52fefd8da466f6791cb168f5d5212537a49ecb21e9451fb87", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2298,9 +2291,9 @@ "operationId": "adminListAuditLogAdminActions", "fingerprint": "9db6b2947639b5d9727b846289d5f785004909020203b50bf58d6f602ecc0606", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2310,9 +2303,9 @@ "operationId": "adminListRecentSessionBans", "fingerprint": "31e808f02991d35bc911897f5e56cf5955be273eef1e2d69969fb3b00284c5da", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2322,9 +2315,9 @@ "operationId": "adminTopOrgsByRevenueBilling", "fingerprint": "bd551d50b3841132a0d7337450cd6ba3516db319c0fc62ce444c18d6076c5db2", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2334,9 +2327,9 @@ "operationId": "adminLowBalanceOrgsBilling", "fingerprint": "b6c693576751caa97b26aca1f99a775912d709d5ad893f1b96223dc857b27c14", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2346,9 +2339,9 @@ "operationId": "adminPlatformRevenueBilling", "fingerprint": "e5efb95d6c75256293676aa700e66d0c6d874720d030ef0dc96ef3c708bd42d7", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2358,9 +2351,9 @@ "operationId": "adminListCampaigns", "fingerprint": "5cf420fbac81f17078fdb6cf52907e73b866a437ebf15a6a094e69bd91f6da56", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2370,9 +2363,9 @@ "operationId": "adminOptOutLeaderboardCampaign", "fingerprint": "9aa141b46fd96c01548f8b69892aa06179fd23d15966b8e7cea1f216ecd2d629", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2382,9 +2375,9 @@ "operationId": "adminListApiKeys", "fingerprint": "1ff6c744e353cd170fb5d3d7e2e8061a7b0f47ae2f408a1117929792503d727f", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2394,9 +2387,9 @@ "operationId": "adminPlatformKpisPlatform", "fingerprint": "3ad6c63b3e7a6caea00363b21c287c1c5bd2dfa08e05fdd41345c7cb8ce1e144", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2406,9 +2399,9 @@ "operationId": "adminListOrganizationInvitations", "fingerprint": "09dacaf2b15866b1ee597569d2395ddff156029e79b0681e6b4f2aea956fdbe9", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2418,9 +2411,9 @@ "operationId": "adminListOrganizations", "fingerprint": "9d41dedfb00afac7a6013df50893fb36be67e179a7ab86dbc06a133bee284a5a", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2430,9 +2423,9 @@ "operationId": "adminListOrganizationFeatureGroups", "fingerprint": "eb809ba63426a43d95fea2c35a9c2c2d5927d181de64027b99362696a997ca15", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2442,9 +2435,9 @@ "operationId": "adminGetOrganization", "fingerprint": "6b9c45cf94ebbc2eb88ba59a6d0c65ffd3adb7d85610306624f9200391f232b2", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2454,9 +2447,9 @@ "operationId": "adminListPricing", "fingerprint": "6fffbc67d71392d03997c21708e1430cc817706464a1ddf6b4c2973325b03af3", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2466,9 +2459,9 @@ "operationId": "adminListSessions", "fingerprint": "8c9f9acacf56ae316a16d182adecb72df4497b2a147a10014e9c9619198701d4", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2478,9 +2471,9 @@ "operationId": "adminRecentSignupsPlatform", "fingerprint": "60138e38218bf8afa1093c6c689f22f7ff78ab1d214f4ac8cc77c7ce40a913fd", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2490,9 +2483,9 @@ "operationId": "adminListTenants", "fingerprint": "1c2524576a92fe2fc94a4151ecd2b9ca15ce8ac71b5fdddc603facda7b149582", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2502,9 +2495,9 @@ "operationId": "adminSearchUser", "fingerprint": "70f33c4a358d4b371c7280011504cbaffb466626663175452fb39caa2ed76043", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2514,9 +2507,9 @@ "operationId": "adminGetUser", "fingerprint": "29577189dc3923646b9578bec4cbabbefcbc01e12032d823550e3f582f9c03c2", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -2527,7 +2520,7 @@ "fingerprint": "747e9241cf93f1f137d5b03441e34d2768e196f2fcab1c1f4d9d1cbab4f396fb", "typescript": { "status": "excluded", - "reason": "This Polymorfa staff route requires a staff dashboard identity and never accepts server credentials.", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", "milestone": "credential-boundary" } }, @@ -2743,7 +2736,7 @@ "fingerprint": "203826fc7769c25645eceac8e1f711d964344d6de8afc5f591f7a5c51bf0a86a", "typescript": { "status": "covered", - "method": "PlatformClient.audiences.list" + "method": "Client.audiences.list" } }, { @@ -2754,7 +2747,7 @@ "fingerprint": "0faa242ed87367ed7f3776955e9923148ff5ba162e1ecf0e652dd8cbbbfd304a", "typescript": { "status": "covered", - "method": "PlatformClient.audiences.retrieve" + "method": "Client.audiences.retrieve" } }, { @@ -2765,7 +2758,7 @@ "fingerprint": "677185d7ed1e85806e95ebaff70014686ce20e118712b5ba4f1adb32568d80ee", "typescript": { "status": "covered", - "method": "PlatformClient.auditLogs.list" + "method": "Client.auditLogs.list" } }, { @@ -2776,7 +2769,7 @@ "fingerprint": "888d886c7dcee97ade64909ad73bc187c5e12696492198579c56e1fb98805ef5", "typescript": { "status": "covered", - "method": "PlatformClient.sessionBans.list" + "method": "Client.sessionBans.list" } }, { @@ -2787,7 +2780,7 @@ "fingerprint": "2245fb243558391a9e6e0b9253144743bf42dfb5c90d5b1cb0d74f3df38dce61", "typescript": { "status": "covered", - "method": "PlatformClient.sessionBans.listActive" + "method": "Client.sessionBans.listActive" } }, { @@ -2798,7 +2791,7 @@ "fingerprint": "b79ffd5c2db93f74b53fbb286d340853e4193609be3b25c3c221a065e80aa5ac", "typescript": { "status": "covered", - "method": "PlatformClient.billing.retrieve" + "method": "Client.billing.retrieve" } }, { @@ -2809,7 +2802,7 @@ "fingerprint": "552594051767287f7d22abb45a38e2bd91fa07242905b92eda6d98d24838e99c", "typescript": { "status": "covered", - "method": "PlatformClient.billing.listPricing" + "method": "Client.billing.listPricing" } }, { @@ -2820,7 +2813,7 @@ "fingerprint": "521773873dbc673426bf4d013f058b10c0b3505f65af83e0de49dc114d052940", "typescript": { "status": "covered", - "method": "PlatformClient.billing.listTransactions" + "method": "Client.billing.listTransactions" } }, { @@ -2831,7 +2824,7 @@ "fingerprint": "49314723af2ef845963a473abbfeba6b3c331fc524b8223f3223bc8569e07a47", "typescript": { "status": "covered", - "method": "PlatformClient.billing.usage" + "method": "Client.billing.usage" } }, { @@ -2842,7 +2835,7 @@ "fingerprint": "13541c77e70ae753f224408e344829aacf76d3a88416f686cdf19b4434b6dc12", "typescript": { "status": "covered", - "method": "PlatformClient.campaigns.list" + "method": "Client.campaigns.list" } }, { @@ -2853,7 +2846,7 @@ "fingerprint": "8a62796d923adf424de9e19e952073a71d11776745221373bbcdd8ab5d2f997b", "typescript": { "status": "covered", - "method": "PlatformClient.campaigns.retrieve" + "method": "Client.campaigns.retrieve" } }, { @@ -2864,7 +2857,7 @@ "fingerprint": "f6af6d45f076a5eafd9ddcb441cd44cc3b0ff6e9b8021dcab8ef9c6708db1daf", "typescript": { "status": "covered", - "method": "PlatformClient.campaigns.analytics" + "method": "Client.campaigns.analytics" } }, { @@ -2875,7 +2868,7 @@ "fingerprint": "66c0c14c9cf8608854050ae3fd7677cd320d7c8b2289aef64d6f7857c9322e93", "typescript": { "status": "covered", - "method": "PlatformClient.campaigns.events" + "method": "Client.campaigns.events" } }, { @@ -2886,7 +2879,7 @@ "fingerprint": "dc30a8e28bc4801b30c9b95230a61fa6a3392cc56dad1dc6466eb1026837d512", "typescript": { "status": "covered", - "method": "PlatformClient.campaigns.recipients" + "method": "Client.campaigns.recipients" } }, { @@ -2897,7 +2890,7 @@ "fingerprint": "a54c14d80d3504882f5902952563038e37d5bb85fd045dcd2e570c2ba0e16be6", "typescript": { "status": "covered", - "method": "PlatformClient.customers.list" + "method": "Client.customers.list" } }, { @@ -2908,7 +2901,7 @@ "fingerprint": "62ae84a5ee3d5c3783070bfc03e5f9535d67a80c0f2cb0d7f6c5c67e07bd718e", "typescript": { "status": "covered", - "method": "PlatformClient.customers.retrieve" + "method": "Client.customers.retrieve" } }, { @@ -2919,7 +2912,7 @@ "fingerprint": "839539469ae5e671420c089392077f47808c77cabc43fce7db54be34ac126f5f", "typescript": { "status": "covered", - "method": "PlatformClient.customers.listEvents" + "method": "Client.customers.listEvents" } }, { @@ -2930,7 +2923,7 @@ "fingerprint": "14ddf26d897bc605a839082e43ac77675f4a82e935cfdca8aa80adc398a2a3a2", "typescript": { "status": "covered", - "method": "PlatformClient.customers.listNumbers" + "method": "Client.customers.listNumbers" } }, { @@ -2941,7 +2934,7 @@ "fingerprint": "6d419c3d4369c5020e8e11588a62e23882dd58f81546d57796235d816f366543", "typescript": { "status": "covered", - "method": "PlatformClient.customers.listPairingLinks" + "method": "Client.customers.listPairingLinks" } }, { @@ -2951,9 +2944,8 @@ "operationId": "listOrganizationEvents", "fingerprint": "5be3e55ec98b3beed960218f6af5a5ace861a5a6811b1c62814085f57e1b238c", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.events.list" } }, { @@ -2963,9 +2955,8 @@ "operationId": "getOrganizationEvent", "fingerprint": "160c5e4c7b91b8b633b004961d2039b0a974fd56f3b8560abb67e5dd22ae5dbf", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.events.retrieve" } }, { @@ -2976,7 +2967,7 @@ "fingerprint": "b77ce1c733584fa605761e207035cef871bc49341c665cfc82643243debfd47f", "typescript": { "status": "excluded", - "reason": "This dashboard-only route does not accept the organization server API key used by PlatformClient.", + "reason": "This dashboard-only route does not accept the organization server API key used by Client.", "milestone": "credential-boundary" } }, @@ -2988,7 +2979,7 @@ "fingerprint": "30eb629b546bed2aeab2450a75fb32ac25831c3f522fa8ae0fff2ffa5cebf301", "typescript": { "status": "excluded", - "reason": "This dashboard-only route does not accept the organization server API key used by PlatformClient.", + "reason": "This dashboard-only route does not accept the organization server API key used by Client.", "milestone": "credential-boundary" } }, @@ -3000,7 +2991,7 @@ "fingerprint": "c2e69c4b877a1914f1a5bfcbd4820c13749b1ccf70ea0c5e878e1a3fd86c153a", "typescript": { "status": "covered", - "method": "PlatformClient.securityIncidents.list" + "method": "Client.securityIncidents.list" } }, { @@ -3011,7 +3002,7 @@ "fingerprint": "603de482c9fbe57d09fbbca97920c80f2b9b7fd06a2e4e29bd07b2abcfbde6fe", "typescript": { "status": "covered", - "method": "PlatformClient.apiKeys.list" + "method": "Client.apiKeys.list" } }, { @@ -3022,7 +3013,7 @@ "fingerprint": "860947f02650cea2d14e5827bdd8a8788c2f1714cdd6d3465bf78eb6424b7b77", "typescript": { "status": "covered", - "method": "PlatformClient.media.retrieve" + "method": "Client.media.retrieve" } }, { @@ -3033,7 +3024,7 @@ "fingerprint": "76ff2fcdeb2b299974b01dd5ff26b0b1d209b802e08d0d523fa7c2ea4d09a232", "typescript": { "status": "covered", - "method": "PlatformClient.members.list" + "method": "Client.members.list" } }, { @@ -3043,9 +3034,8 @@ "operationId": "listOrganizationOperations", "fingerprint": "b82282f9c6348a836b3f21000e080efb517b701d988758b0fa802ff30dae56b3", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.operations.list" } }, { @@ -3056,7 +3046,7 @@ "fingerprint": "93c96fd49f49773bbf8006cba357f42f26370c3da1af9e9d73518389ba54bfca", "typescript": { "status": "covered", - "method": "PlatformClient.operations.retrieve" + "method": "Client.operations.retrieve" } }, { @@ -3066,9 +3056,8 @@ "operationId": "listOrganizationOperationTransitions", "fingerprint": "eb99e217f76bca9939974be5ff17ed8f5f71c4c6a3ff0923981254efdb936486", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.operations.listTransitions" } }, { @@ -3079,7 +3068,7 @@ "fingerprint": "fa236cca96686c6191aa1f2cdb84272e025f89aef0a25504b9cf532028fe1ae6", "typescript": { "status": "covered", - "method": "PlatformClient.optOuts.list" + "method": "Client.optOuts.list" } }, { @@ -3090,7 +3079,7 @@ "fingerprint": "1cc1249633ee2ef286e59fdee2f8d51a78648a4a68f6cf67614d39a6fd8c7ef9", "typescript": { "status": "covered", - "method": "PlatformClient.organizations.retrieve" + "method": "Client.organizations.retrieve" } }, { @@ -3101,7 +3090,7 @@ "fingerprint": "8b4f569d2828572eecd824d2f28b33775e894faa1dba9693de6729e586fce628", "typescript": { "status": "covered", - "method": "PlatformClient.projects.list" + "method": "Client.projects.list" } }, { @@ -3112,7 +3101,7 @@ "fingerprint": "2ea59c9703c76cf8bfd04d74166ce4504d70fa53090049fd6614ba1044d103a2", "typescript": { "status": "covered", - "method": "PlatformClient.customers.status" + "method": "Client.customers.status" } }, { @@ -3122,9 +3111,8 @@ "operationId": "listProjectEvents", "fingerprint": "ab2f389ed49a29b77d55e2431a671a7dfa328a2fd3bd64b8ea3ca07704dae671", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).events.list" } }, { @@ -3134,9 +3122,8 @@ "operationId": "getProjectEvent", "fingerprint": "625009ef487d2c3140be83fd59a68577224e782fef48476132702ac96d2ff15a", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).events.retrieve" } }, { @@ -3146,9 +3133,8 @@ "operationId": "listProjectOperations", "fingerprint": "402c9db5f517ca805d26c82cb2da0a4d417d1482cba1910c168ef0c7402b4498", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).operations.list" } }, { @@ -3158,9 +3144,8 @@ "operationId": "getProjectOperation", "fingerprint": "c2e69bf06e704272dddb0c998ab8a665e4b48df8243ae511c5d64b14febb9b80", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).operations.retrieve" } }, { @@ -3170,9 +3155,8 @@ "operationId": "listProjectOperationTransitions", "fingerprint": "791d29cded696ad923666f84ed184b18af05d03da0a938a4ffa7fa926adb63d6", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).operations.listTransitions" } }, { @@ -3182,9 +3166,8 @@ "operationId": "listProjectWebhookDeliveries", "fingerprint": "05ef9b32c73de411c31d8f6fc859764a13be04b9d5fdb46fe374a10d982ece12", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).webhookDeliveries.list" } }, { @@ -3194,9 +3177,8 @@ "operationId": "getProjectWebhookDelivery", "fingerprint": "abb159dcd6242c16f333dd17b7f32b471d8548d17e447285d346622c602563e7", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).webhookDeliveries.retrieve" } }, { @@ -3206,9 +3188,8 @@ "operationId": "listProjectWebhookDeliveryAttempts", "fingerprint": "faf7d82f3c2c65faf4397cddcbc66566a51dbc46f0e7390509430168f0a23fd5", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).webhookDeliveries.listAttempts" } }, { @@ -3218,9 +3199,8 @@ "operationId": "getProjectWebhookDeliveryAttempt", "fingerprint": "6cb230760cc6dd4c4a5572c634b3350af4ca47762fea75a4b62bfa7014b70aef", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).webhookDeliveries.retrieveAttempt" } }, { @@ -3230,9 +3210,8 @@ "operationId": "listProjectWebhooks", "fingerprint": "b0ef0ff74c50c943198af2ae17c79bba7cf2321fafc7b82199f10bddc5d3c6f1", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).webhooks.list" } }, { @@ -3242,9 +3221,8 @@ "operationId": "getProjectWebhook", "fingerprint": "08ef24fe00067de9e010e41acd4c79f285e257652c61186c62ee8f0ebf6a95fb", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).webhooks.retrieve" } }, { @@ -3254,9 +3232,8 @@ "operationId": "getQuickLinkSettings", "fingerprint": "2da393403c39ff81e2ec7ee4d62bbe50d8ce6feb96eb8a5f34c62af48ae98b80", "typescript": { - "status": "missing", - "reason": "PlatformClient.widgetSettings still requests /v1/widget; it does not implement the /v1/quicklink contract.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.quickLinkSettings.retrieve" } }, { @@ -3267,7 +3244,7 @@ "fingerprint": "7bfd8c212e34f1f5e496f582608769d1667b0eaee3581609873591566b1c0e89", "typescript": { "status": "covered", - "method": "PlatformClient.sessions.list" + "method": "Client.sessions.list" } }, { @@ -3278,7 +3255,7 @@ "fingerprint": "f38d5515565c7d1ef8ddc82968906af4d7e0c0f15b8ffca2cfeea272583b26fd", "typescript": { "status": "excluded", - "reason": "This dashboard-only route does not accept the organization server API key used by PlatformClient.", + "reason": "This dashboard-only route does not accept the organization server API key used by Client.", "milestone": "credential-boundary" } }, @@ -3290,7 +3267,7 @@ "fingerprint": "8cb15afe849c0c8d223fdf4e6c9e9d220715778da80f94f7572b8f5966aaa8de", "typescript": { "status": "excluded", - "reason": "This dashboard-only route does not accept the organization server API key used by PlatformClient.", + "reason": "This dashboard-only route does not accept the organization server API key used by Client.", "milestone": "credential-boundary" } }, @@ -3302,7 +3279,7 @@ "fingerprint": "b8d5d76ce747845f745082489c05037c0cdaf9d20a03a6d7809459d9a5ce35ce", "typescript": { "status": "covered", - "method": "PlatformClient.projectTokens.list" + "method": "Client.projectTokens.list" } }, { @@ -3312,9 +3289,8 @@ "operationId": "listOrganizationWebhookDeliveries", "fingerprint": "b34dcc5f989890f4a244c7b98cd2a1103a1ae7587eaaff5990c6ae35dce6eab3", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.webhookDeliveries.list" } }, { @@ -3324,9 +3300,8 @@ "operationId": "getOrganizationWebhookDelivery", "fingerprint": "d4fa1bc619a17a6e42609717f2094506d670c6faac93bf1673fcca18c71e99d4", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.webhookDeliveries.retrieve" } }, { @@ -3336,9 +3311,8 @@ "operationId": "listOrganizationWebhookDeliveryAttempts", "fingerprint": "9dd8f70e0f94223481cdeebb2308330492b6ee916b1f14cfcc19d92a93b9fbe3", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.webhookDeliveries.listAttempts" } }, { @@ -3348,9 +3322,8 @@ "operationId": "getOrganizationWebhookDeliveryAttempt", "fingerprint": "504298df2cd3af0e90a3395523a507d4f053a718b0e346fa75f1f21651fcc366", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.webhookDeliveries.retrieveAttempt" } }, { @@ -3360,9 +3333,8 @@ "operationId": "listOrganizationWebhooks", "fingerprint": "52475b3163f0fbc7862a8462b030677e326da65807f10659d5df0942ffca49e8", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.webhooks.list" } }, { @@ -3372,9 +3344,8 @@ "operationId": "getOrganizationWebhook", "fingerprint": "573b9da2a147e13f7b6191db6554eea579e3a4d8cc555119ae9d65c00e6dd5e9", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.webhooks.retrieve" } }, { @@ -3384,9 +3355,9 @@ "operationId": "adminSetMemberRole", "fingerprint": "1d40f79d50a6ab2e1187fa1efe66060a3200d0d545a7e67099b16c22a97a10a3", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3396,9 +3367,9 @@ "operationId": "adminRenameOrganization", "fingerprint": "e0e6d6798056ed6f37d726a3f7a60b1e81a47078a14c9bb7c676b49184974261", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3408,9 +3379,9 @@ "operationId": "adminSetOrganizationFeatureGroups", "fingerprint": "db5781f4cfddd0bead2de6e502755eac0831478c2ad28353ac95a5fbedc4aa3c", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3420,9 +3391,9 @@ "operationId": "adminSetPricingActive", "fingerprint": "9eddf6277243ee32343ab00f6a70fb184677b604f9e841b6d6ab9f81a209fcbf", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3432,9 +3403,9 @@ "operationId": "adminSetTenantActive", "fingerprint": "8731e12cb798c00e9b4fcc17912a0164c80d6d2b6656b06e358706a5a23614ed", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3481,7 +3452,7 @@ "fingerprint": "58ee9e21033128d7f90773953f5b9900107e4dc7a2c83a024bf3b4b145f195a8", "typescript": { "status": "covered", - "method": "PlatformClient.billing.updateReminderSettings" + "method": "Client.billing.updateReminderSettings" } }, { @@ -3492,7 +3463,7 @@ "fingerprint": "15a5e192d9251828c843d90675e686f38eb4dabaadc39027e50390bcc8867b20", "typescript": { "status": "covered", - "method": "PlatformClient.campaigns.update" + "method": "Client.campaigns.update" } }, { @@ -3503,7 +3474,7 @@ "fingerprint": "66eb57975826a76c2d96816688450d24afe6eea4195cbcbf5473f1f14295ea33", "typescript": { "status": "covered", - "method": "PlatformClient.customers.update" + "method": "Client.customers.update" } }, { @@ -3514,7 +3485,7 @@ "fingerprint": "5ca2b5ea86279e32bc071141d4e90078fdf01b77afff2bc415846058e8ceb62c", "typescript": { "status": "excluded", - "reason": "This dashboard-only route does not accept the organization server API key used by PlatformClient.", + "reason": "This dashboard-only route does not accept the organization server API key used by Client.", "milestone": "credential-boundary" } }, @@ -3526,7 +3497,7 @@ "fingerprint": "e8a2544274d583bb7c406bc52818bdc172cc605a9c2fee184a876a7e55c2dbc5", "typescript": { "status": "excluded", - "reason": "This dashboard-only member mutation accepts ConsoleSession authentication and rejects the organization server API key used by PlatformClient.", + "reason": "This dashboard-only member mutation accepts ConsoleSession authentication and rejects the organization server API key used by Client.", "milestone": "credential-boundary" } }, @@ -3538,7 +3509,7 @@ "fingerprint": "9f1712477bf16a8a2372cbfbec20197d822cd0f29868b49f2f648540b52e31ff", "typescript": { "status": "excluded", - "reason": "This dashboard-only route does not accept the organization server API key used by PlatformClient.", + "reason": "This dashboard-only route does not accept the organization server API key used by Client.", "milestone": "credential-boundary" } }, @@ -3549,9 +3520,8 @@ "operationId": "updateProjectWebhook", "fingerprint": "ab722f393f3d724aff5bcac6b48e7e62e27221fa7e5216d772be7fa1f9668f4f", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).webhooks.update" } }, { @@ -3562,7 +3532,7 @@ "fingerprint": "b9a55c4616ef53fe433885201e23e4a6638f2c2afd78516ac90f2fabf0ba02bb", "typescript": { "status": "covered", - "method": "PlatformClient.sessions.setTierOverride" + "method": "Client.sessions.setTierOverride" } }, { @@ -3573,7 +3543,7 @@ "fingerprint": "e84a8b125bb33aac5b8bff0443710eba04ba613c7ae117de12f8e178428ff4ec", "typescript": { "status": "excluded", - "reason": "This dashboard-only route does not accept the organization server API key used by PlatformClient.", + "reason": "This dashboard-only route does not accept the organization server API key used by Client.", "milestone": "credential-boundary" } }, @@ -3584,9 +3554,8 @@ "operationId": "updateOrganizationWebhook", "fingerprint": "7a951130d9739ea4f6c3b9af13dd19a91ef5942d06d60f5d353f842a3ace2233", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.webhooks.update" } }, { @@ -3596,9 +3565,9 @@ "operationId": "adminCompCreditsBilling", "fingerprint": "5dfeaa55ddc13056abf53756e6b7e927b5483a5d4aa20c72ad70e90707d84412", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3608,9 +3577,9 @@ "operationId": "adminRefundBilling", "fingerprint": "4a3af41a249fb8bedcde2947e5cd067d0cb4288b56f37458cf143fb85f7d5c17", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3620,9 +3589,9 @@ "operationId": "adminForcePauseCampaign", "fingerprint": "ca6cfea9e0133062915100b3e281ad66ac71495d79025cab5c0f9c8eb9d089e2", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3632,9 +3601,9 @@ "operationId": "adminForceStopCampaign", "fingerprint": "e7b428272304f94eebcb48f487c872ccb6cf990adde488509971dd05315cf410", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3644,9 +3613,9 @@ "operationId": "adminInviteMember", "fingerprint": "48ded54b69d484be6d960b8f04baa62fda2d0d704314e4492660e23f730e9c24", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3656,9 +3625,9 @@ "operationId": "adminCreateOrganizationInvitation", "fingerprint": "d8f635f3ed51be3f91be8da22e575a52752b423b2915f3ecb80deeee2c655828", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3668,9 +3637,9 @@ "operationId": "adminFreezeOrganization", "fingerprint": "29491cbdbba0b1131c42557c2d0da16387028278b6bf6f66e7bae610a9b506d8", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3680,9 +3649,9 @@ "operationId": "adminUnfreezeOrganization", "fingerprint": "471b13a4f50084fd4b7e852f1c430d11c74173f89a0c8de7236a6cb7b6fa4a9d", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3692,9 +3661,9 @@ "operationId": "adminCompSessionDaySession", "fingerprint": "2805d221840da8a5acb4da164b01ff5c6996cb9875db2878057b2a3b39f5f9c8", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3704,9 +3673,9 @@ "operationId": "adminForceStopSession", "fingerprint": "59de376f0586fb9fe59349d74928d981feab8226a4edc760108288d81cf47a45", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3716,9 +3685,9 @@ "operationId": "adminEnsureCurrentUserStaffSyncStaffSync", "fingerprint": "f6dcffee982955559462f37e30d42545847d5531e6b4303c34476dde6cee7552", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3728,9 +3697,9 @@ "operationId": "adminForceSignOutUser", "fingerprint": "71a604cf9f25b568ae4573f771aa5159b6b576ebde45163b1a5dccf34ee43de1", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3740,9 +3709,9 @@ "operationId": "adminSuspendUser", "fingerprint": "d93139af07a146aaab9f6f8c100dbc05d084099f8566708c6348a60c43125c1c", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3752,9 +3721,9 @@ "operationId": "adminUnsuspendUser", "fingerprint": "026f63ffee48400ed53c6a9c8b106063dc3db734a1f32b432460457b294c7d54", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -3765,7 +3734,7 @@ "fingerprint": "d724d71c360d348db65ed19fcba6bb3d490bbb47066f2434ba59a237a2afcf63", "typescript": { "status": "excluded", - "reason": "This Polymorfa staff route requires a staff dashboard identity and never accepts server credentials.", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", "milestone": "credential-boundary" } }, @@ -4029,7 +3998,7 @@ "fingerprint": "b6b0ecd9fc2086a2fd2002f36d9cc7bd7d3d8815cd1646a019058551eece7b79", "typescript": { "status": "covered", - "method": "PlatformClient.audiences.create" + "method": "Client.audiences.create" } }, { @@ -4040,7 +4009,7 @@ "fingerprint": "181687aae5b6bf19a47f468db80b99a60186d2a6a0b607be6f3940e3b31ced0c", "typescript": { "status": "covered", - "method": "PlatformClient.audiences.createUpload" + "method": "Client.audiences.createUpload" } }, { @@ -4051,7 +4020,7 @@ "fingerprint": "e1b2ad3a76c285b136e3930a6f0a757b2c53bbd2e80419bc48ca51e4e15d7d4d", "typescript": { "status": "covered", - "method": "PlatformClient.campaigns.create" + "method": "Client.campaigns.create" } }, { @@ -4062,7 +4031,7 @@ "fingerprint": "7874cacdb5473efcbf5ec60537359dde02f6c8ed107f65464c103c9a0c956c59", "typescript": { "status": "covered", - "method": "PlatformClient.campaigns.archive" + "method": "Client.campaigns.archive" } }, { @@ -4073,7 +4042,7 @@ "fingerprint": "980f5484a2c7d11062cfecc4e089728c96d4e020c419f84e995cd8b927d834ca", "typescript": { "status": "covered", - "method": "PlatformClient.campaigns.duplicate" + "method": "Client.campaigns.duplicate" } }, { @@ -4084,7 +4053,7 @@ "fingerprint": "05e3a309a4b13e603a677acedc813bfeb532f1d2f40724955630e3338bbe76f1", "typescript": { "status": "covered", - "method": "PlatformClient.campaigns.launch" + "method": "Client.campaigns.launch" } }, { @@ -4095,7 +4064,7 @@ "fingerprint": "525a5e4ace1bea4dbd1fa38c5ab4a34f53381556c2351cf2983fc36461d6ff55", "typescript": { "status": "covered", - "method": "PlatformClient.campaigns.pause" + "method": "Client.campaigns.pause" } }, { @@ -4106,7 +4075,7 @@ "fingerprint": "9ac17764dba881792d291a1637803a072833bf9647046eca3a7e13cad2913bd6", "typescript": { "status": "covered", - "method": "PlatformClient.campaigns.requeue" + "method": "Client.campaigns.requeue" } }, { @@ -4117,7 +4086,7 @@ "fingerprint": "d41ef90149f1305467359427caa89809cc5cb08796dbc3095f7336fb3c60dbcd", "typescript": { "status": "covered", - "method": "PlatformClient.campaigns.resume" + "method": "Client.campaigns.resume" } }, { @@ -4128,7 +4097,7 @@ "fingerprint": "e7fafb4cf9730d92363f799dcb536b89c4d5a42a07fc5d5765e4df622258d2c8", "typescript": { "status": "covered", - "method": "PlatformClient.campaigns.stop" + "method": "Client.campaigns.stop" } }, { @@ -4139,7 +4108,7 @@ "fingerprint": "5ef3cee3b57f10bc9854638bb35f47c4014aa5c9b4951e783df5472fda8b2144", "typescript": { "status": "covered", - "method": "PlatformClient.customers.create" + "method": "Client.customers.create" } }, { @@ -4150,7 +4119,7 @@ "fingerprint": "f9d55c6babd4f5abd9e2391f9db9152cc9440c1416585397b0ff65842447dac3", "typescript": { "status": "covered", - "method": "PlatformClient.customers.archive" + "method": "Client.customers.archive" } }, { @@ -4161,7 +4130,7 @@ "fingerprint": "40346a290d32c5795e327291bfead6c7332c97d5150c87263b0887f6b272189a", "typescript": { "status": "covered", - "method": "PlatformClient.customers.transferNumber" + "method": "Client.customers.transferNumber" } }, { @@ -4172,7 +4141,7 @@ "fingerprint": "7e6677d1c9fbb6174106818aa7fb1fe426f4bf274ccd60958c45060eb7057fbf", "typescript": { "status": "covered", - "method": "PlatformClient.customers.createPairingLink" + "method": "Client.customers.createPairingLink" } }, { @@ -4183,7 +4152,7 @@ "fingerprint": "e13571eea373b7bea4ebb134a3d8a7fb158c92c30007b8d9f9493e426500e841", "typescript": { "status": "covered", - "method": "PlatformClient.customers.restore" + "method": "Client.customers.restore" } }, { @@ -4193,9 +4162,8 @@ "operationId": "replayOrganizationEvent", "fingerprint": "eba8005fe5674692bf863d4ae911fe03819398729b8369a4448189b7fe129e0d", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.events.replay" } }, { @@ -4206,7 +4174,7 @@ "fingerprint": "88b3342941cdb519d5725b10edad1faa124b74785571b88fc6df7248a4de805e", "typescript": { "status": "excluded", - "reason": "This dashboard-only route does not accept the organization server API key used by PlatformClient.", + "reason": "This dashboard-only route does not accept the organization server API key used by Client.", "milestone": "credential-boundary" } }, @@ -4218,7 +4186,7 @@ "fingerprint": "e53ce0bd9846707155d28030c572ca4ced5b494c62d242274b18f579f653350a", "typescript": { "status": "covered", - "method": "PlatformClient.securityIncidents.acknowledge" + "method": "Client.securityIncidents.acknowledge" } }, { @@ -4229,7 +4197,7 @@ "fingerprint": "1b72058d61aebe5d619aee99e5547b1de93d3f90c8232805cadfdbe8d944e750", "typescript": { "status": "covered", - "method": "PlatformClient.media.createUpload" + "method": "Client.media.createUpload" } }, { @@ -4240,7 +4208,7 @@ "fingerprint": "2762d14d85c446fd0d0144bfefe588590b4cbf93fd5bcb75a2e6b50a12c9223e", "typescript": { "status": "excluded", - "reason": "This dashboard-only invitation accepts ConsoleSession authentication and rejects the organization server API key used by PlatformClient.", + "reason": "This dashboard-only invitation accepts ConsoleSession authentication and rejects the organization server API key used by Client.", "milestone": "credential-boundary" } }, @@ -4251,9 +4219,8 @@ "operationId": "cancelOrganizationOperation", "fingerprint": "a7a33415d944a39d49dc5d7907b498c9985a2c9967fe71975fe76b6bf5061c23", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.operations.cancel" } }, { @@ -4264,7 +4231,7 @@ "fingerprint": "fc46f65b20f6df48dc7abd557fa6ab8c63d0381f8b85f3810c813387b6f3816b", "typescript": { "status": "covered", - "method": "PlatformClient.optOuts.create" + "method": "Client.optOuts.create" } }, { @@ -4275,7 +4242,7 @@ "fingerprint": "af7de5f6d5c838aa65da25ddfa1376ac38cba86ff9ba05b79b7d695eb72134ee", "typescript": { "status": "covered", - "method": "PlatformClient.optOuts.createBatch" + "method": "Client.optOuts.createBatch" } }, { @@ -4286,7 +4253,7 @@ "fingerprint": "61809f2781d79bffe3ec2c4ef23a9dc04b0ad1df60cc0f9823390f15d24a6e93", "typescript": { "status": "covered", - "method": "PlatformClient.projects.create" + "method": "Client.projects.create" } }, { @@ -4297,7 +4264,7 @@ "fingerprint": "d65936aee2325cf0fa55bc9c22bba407bda8efc1110307c1e0d2d3176698e265", "typescript": { "status": "covered", - "method": "PlatformClient.customers.enable" + "method": "Client.customers.enable" } }, { @@ -4307,9 +4274,8 @@ "operationId": "replayProjectEvent", "fingerprint": "2339cad5119c3d95b56f85e05d252373c32197a75a739ce174f8ce8e7f0b9af4", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).events.replay" } }, { @@ -4319,9 +4285,8 @@ "operationId": "cancelProjectOperation", "fingerprint": "368d5301a8dc02d5348208e3b6d1789adb8cb11867e2cb32511e90efdbc7d3e2", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).operations.cancel" } }, { @@ -4332,7 +4297,7 @@ "fingerprint": "2895433633925a3c7cf2486e569584c47f80c14265202df7c5538274202ab154", "typescript": { "status": "covered", - "method": "PlatformClient.projects.approveProductionEnrollment" + "method": "Client.projects.approveProductionEnrollment" } }, { @@ -4343,7 +4308,7 @@ "fingerprint": "3ed9c7d417bc287059160de229e698c11feea210c2c5300bc6dcf55a1bfaf662", "typescript": { "status": "covered", - "method": "PlatformClient.projects.cancelProductionEnrollment" + "method": "Client.projects.cancelProductionEnrollment" } }, { @@ -4354,7 +4319,7 @@ "fingerprint": "9ef810d6c24aeca305cd3c325af2354cea607798e82247a3e95a0ee0f97425bf", "typescript": { "status": "covered", - "method": "PlatformClient.projects.requestProductionEnrollment" + "method": "Client.projects.requestProductionEnrollment" } }, { @@ -4364,9 +4329,8 @@ "operationId": "retryProjectWebhookDelivery", "fingerprint": "38edb41227ef675157277de18e0d9bf7f6639677818f8c2a8f69e280b45214df", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).webhookDeliveries.retry" } }, { @@ -4376,9 +4340,8 @@ "operationId": "createProjectWebhook", "fingerprint": "1a84d699c6a8fc774bfe85b4eaaddbd31addd61809c403bbaa010a6dd6021a59", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).webhooks.create" } }, { @@ -4388,9 +4351,8 @@ "operationId": "rotateProjectWebhookSecret", "fingerprint": "b6db4b2f05eacb82576c3fb6a72cbecd7bcefbd1cb605308822ff5bd7a5b050a", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).webhooks.rotateSecret" } }, { @@ -4400,9 +4362,8 @@ "operationId": "testProjectWebhook", "fingerprint": "41f3f02723d73d84ee76a503e44a4ac4320f53d40c728577379eaf6016455376", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.project(projectId).webhooks.test" } }, { @@ -4413,7 +4374,7 @@ "fingerprint": "7729d4347e0be6d3cbdfc9b092449fe2b9203126eefef68dff93091fb6acab10", "typescript": { "status": "covered", - "method": "PlatformClient.sessions.deleteMany" + "method": "Client.sessions.deleteMany" } }, { @@ -4424,7 +4385,7 @@ "fingerprint": "30243b9f92c42b9d34e62970ef42f7a275594bf9bc87c313858162bd6b146cb2", "typescript": { "status": "covered", - "method": "PlatformClient.sessions.stopMany" + "method": "Client.sessions.stopMany" } }, { @@ -4435,7 +4396,7 @@ "fingerprint": "64cb2649c764e746de9cc02c2ac90b18dee5c0d1c44db5f5e4a3e487f8d49aef", "typescript": { "status": "covered", - "method": "PlatformClient.sessions.createTesting" + "method": "Client.sessions.createTesting" } }, { @@ -4445,9 +4406,8 @@ "operationId": "startPlatformSession", "fingerprint": "6c00282c3715a7b87ea17c2add03033ad5fa03a8dd6b12d709fa03aecae62157", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.sessions.start" } }, { @@ -4458,7 +4418,7 @@ "fingerprint": "066950a07954551deda3e02880db38a88cb47f8b3466d69a243fff357dc53d8c", "typescript": { "status": "covered", - "method": "PlatformClient.sessions.stop" + "method": "Client.sessions.stop" } }, { @@ -4469,7 +4429,7 @@ "fingerprint": "5a6b8831c17b9a824f1d20f8de747176199e2b88828f841ccbdbe16ee3dd49da", "typescript": { "status": "excluded", - "reason": "This dashboard-only route does not accept the organization server API key used by PlatformClient.", + "reason": "This dashboard-only route does not accept the organization server API key used by Client.", "milestone": "credential-boundary" } }, @@ -4480,9 +4440,8 @@ "operationId": "retryOrganizationWebhookDelivery", "fingerprint": "3c4842a2d3eff7bc2d5d4c96cb93d44edd4466eb98ef9d0919489f66574c2957", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.webhookDeliveries.retry" } }, { @@ -4492,9 +4451,8 @@ "operationId": "createOrganizationWebhook", "fingerprint": "310c07a9659ec0c5f399ecc795e31d9210387a547a8e5db59b33887c1ebf8d4f", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.webhooks.create" } }, { @@ -4504,9 +4462,8 @@ "operationId": "rotateOrganizationWebhookSecret", "fingerprint": "eb920d819c359f828ebef495c874215afc8d0a5b5cf0e5a5f9b02c164cf4fd00", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.webhooks.rotateSecret" } }, { @@ -4516,9 +4473,8 @@ "operationId": "testOrganizationWebhook", "fingerprint": "b65a74eb4c4e71a057a0d0176eeca1aa3fcc67fe1b89cdac4917e3f5e2660abb", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.webhooks.test" } }, { @@ -4528,9 +4484,9 @@ "operationId": "adminUpdatePricing", "fingerprint": "5b0e99a7aea998fd8b86838992f36690b5e21cc047d820fc061973c5e113c18a", "typescript": { - "status": "missing", - "reason": "No handwritten TypeScript resource method exists for this operation yet.", - "milestone": "typescript-parity" + "status": "excluded", + "reason": "Staff administration route authenticated with ConsoleSession; it is outside the public server SDK credential boundary.", + "milestone": "credential-boundary" } }, { @@ -4600,9 +4556,8 @@ "operationId": "updateQuickLinkSettings", "fingerprint": "da2606f6c7967e7642fae7930f54f8482151d1b9d443ada79cc092ee1108f510", "typescript": { - "status": "missing", - "reason": "PlatformClient.widgetSettings still requests /v1/widget; it does not implement the /v1/quicklink contract.", - "milestone": "typescript-parity" + "status": "covered", + "method": "Client.quickLinkSettings.update" } } ] diff --git a/packages/typescript/README.md b/packages/typescript/README.md index a2a7bf8..ea5ee77 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -8,16 +8,18 @@ Install the development branch: npm install github:polymorfa/sdks#dev ``` -Import Messaging and Platform clients, errors, response metadata, request -options, pagination, webhook utilities, and all public request/response types -from the package root: +Import the management and Messaging clients, errors, response metadata, +request options, pagination, webhook utilities, and public request/response +types from the package root: ```ts import { + BridgeClient, + Client, MessagingClient, - PlatformClient, PolymorfaError, - constructWebhookEvent, + SystemClient, + webhooks, type RequestOptions, } from "@polymorfa/sdk"; ``` @@ -26,9 +28,88 @@ See the repository README for the complete development contract and current typed-resource coverage. This package has no runtime dependencies and requires Node.js 20 or newer. +## Management client and project views + +`Client` has one ownership context for its lifetime. Construct an organization +client with an organization API key, then derive immutable project views with +`project(projectId)`: + +```ts +const platform = new Client({ + credential: { + type: "organizationApiKey", + value: process.env.POLYMORFA_PLATFORM_API_KEY!, + }, + apiVersion: "1.0.0", +}); + +const project = platform.project("project_123"); +const events = await project.events.list({ limit: 25 }); +console.log(events.items, events.response.metadata.requestId); +``` + +A project token can construct only a project view and requires `projectId`: + +```ts +const project = new Client({ + credential: { + type: "projectToken", + value: process.env.POLYMORFA_PROJECT_TOKEN!, + }, + projectId: "project_123", +}); +``` + +The server verifies that initial binding. Rebinding the same project-token +client to a different project fails before transport. A project view exposes +only owner-bound management resources; organization resources such as +`projects`, `members`, and `billing` stay on the organization client. +`MessagingClient` remains separate because its session APIs and credentials +have a different authorization boundary. + +The SDK rejects `pmfa_ct_` browser tokens and CLI-only `pmfa_ls_` listener +credentials before a management request. It does not expose a listener, +`AsyncIterable`, event emitter, or forwarding API. Live forwarding belongs to +`polymorfa listen`. + +## System and Bridge clients + +`SystemClient` is credential-free. Its four methods preserve the normal +`ApiResponse` metadata while calling public service probes: + +```ts +const system = new SystemClient(); +const [status, version, health, ping] = await Promise.all([ + system.status(), + system.version(), + system.health(), + system.ping(), +]); +``` + +`BridgeClient` accepts only `{ type: "projectToken", value }`. It exposes +`routes.resolve()` for regional Bridge route discovery: + +```ts +const bridge = new BridgeClient({ + credential: { + type: "projectToken", + value: process.env.POLYMORFA_PROJECT_TOKEN!, + }, +}); + +const route = await bridge.routes.resolve(); +console.log(route.data.wsUrl, route.metadata.requestId); +``` + +`BridgeClient` does not open the returned WebSocket or manage its lifecycle. +It is also unrelated to the CLI-only SSE listener protocol. Project tokens and +listener credentials are not interchangeable; `pmfa_ls_` fails before a +Bridge request. + ## Customers -Use `PlatformClient.customers` to manage project-owned Customers and their +Use `Client.customers` to manage project-owned Customers and their Numbers. The resource covers the complete Customers contract, including enablement, profile lifecycle, pairing links, recent events, and Number transfers. @@ -321,7 +402,7 @@ client-token allowlist. ## Messaging media -`MessagingClient.media` is distinct from `PlatformClient.media`. It exposes all +`MessagingClient.media` is distinct from `Client.media`. It exposes all three operations in the Messaging Media tag for Linked Device sessions: - `download(mediaId)` returns `ApiResponse` and requires @@ -844,7 +925,7 @@ error. Its `{ requeued }` result is the number actually moved. Lists are complete newest-first arrays; the source exposes no cursor, page token, search, event history, replay, or delivery-listener endpoint. -This Messaging family is distinct from `PlatformClient.campaigns`, which maps +This Messaging family is distinct from `Client.campaigns`, which maps the Management API's organization-key campaign model. The Messaging routes accept organization API keys. Their live authorization layer also accepts a project token only when it is bound to the exact path project, but the public @@ -910,12 +991,16 @@ webhook registrations with a server credential carrying `webhooks:manage`. Webhook mutations accept the same `RequestOptions` as every other resource, including idempotency keys, cancellation, timeouts, and API-version overrides. -Use `constructWebhookEvent` with the exact raw request bytes before inspecting -an inbound delivery. `isEvent` narrows recognized event names to their exported -payload types: +Use `webhooks.verify` with the exact raw request bytes before inspecting an +inbound Messaging delivery. `isEvent` narrows known event names to their +exported payload types: ```ts -const event = await constructWebhookEvent(rawBody, signature, webhookSecret); +const event = await webhooks.verify({ + body: rawBody, + signature, + secret: webhookSecret, +}); if (isEvent(event, "history.sync")) { console.log(event.payload.syncType, event.payload.progress); @@ -930,12 +1015,64 @@ the media host disappeared before reporting the caller. The reason is `pod_lost` for those recovered terminal events. Telemetry fields `recvKbps` and `sendKbps` contain cumulative kilobits, not rates. -The Platform contract exposes campaign events through -`PlatformClient.campaigns.events`. It does not expose key-authenticated webhook -delivery inspection, replay, test delivery, or a general event list. Console -webhook settings require a dashboard identity; staff webhook inspection and -disable operations require a staff identity. Those routes are intentionally -absent from the server client, including its raw-request guidance. +`webhooks.verifySignature()` performs the same production signature check and +returns a boolean without parsing. `webhooks.createFixture()` creates an exact +JSON byte sequence and matching production signature for local tests. +`webhooks.verifyLocal()` verifies the timestamped signature used by local CLI +forwarding. These helpers are credential-free. The older +`constructWebhookEvent` and `verifyWebhookSignature` exports remain available +through the first stable major. A later major can remove them with a migration +release. + +The management `Client` owns a separate durable developer API at both +organization and project scope: + +- `events.list`, `retrieve`, and `replay` +- `webhooks.list`, `create`, `retrieve`, `update`, `delete`, `test`, and + `rotateSecret` +- `webhookDeliveries.list`, `retrieve`, `listAttempts`, `retrieveAttempt`, and + `retry` +- `operations.list`, `retrieve`, `listTransitions`, `cancel`, and `wait` + +```ts +const deliveries = await project.webhookDeliveries.list({ + webhookId: "wh_123", + limit: 25, +}); + +const delivery = deliveries.items[0]; +if (delivery) { + const attempts = await project.webhookDeliveries.listAttempts(delivery.id); + if (attempts.items[0]) { + await project.webhookDeliveries.retrieveAttempt( + delivery.id, + attempts.items[0].id, + ); + } +} + +const replay = await project.events.replay( + "evt_123", + { webhookId: "wh_123" }, + { idempotencyKey: crypto.randomUUID() }, +); + +await project.operations.wait(replay.data.operationId, { + maxWaitMs: 30_000, + pollIntervalMs: 1_000, +}); +``` + +List methods return `CursorPage`. Mutations return owner-specific typed +receipts and preserve response metadata, request IDs, and idempotency receipts. +`operations.wait` is a local polling helper. Aborting or timing out the wait +does not cancel the remote operation. A larger server `Retry-After` raises the +next poll delay without extending `maxWaitMs`. The SDK has no operation watch +or event listener transport. + +Console and staff routes remain absent from the server client and its raw +guidance. The CLI listener protocol is separate from the durable events API; +the SDK exposes no connection, cursor, reconnect, gap, or forwarding methods. ## Platform automation @@ -958,13 +1095,13 @@ console.log(campaign.data.data, campaign.metadata.requestId); ``` The pinned contract defines these operation payloads as open objects, exposed -as `PlatformPayload`. Templates and Flows are not methods on `PlatformClient`: +as `PlatformPayload`. Templates and Flows are not methods on `Client`: their endpoints require a dashboard bearer and reject the organization API key used by the server client. ## Billing and usage -`PlatformClient.billing` exposes the complete organization-key billing family. +`Client.billing` exposes the complete organization-key billing family. Reads require `sessions:read`; updating reminder settings requires `sessions:manage`. @@ -993,26 +1130,24 @@ console.log({ }); ``` -## Organization access, security, and operations +## Organization access and security -The organization-key Platform surface exposes ten exact operations through -seven resources: +The organization view exposes key metadata, members, audit logs, session bans, +security incidents, and project-token metadata: ```ts -const [keys, members, audit, bans, incidents, operation, tokens] = - await Promise.all([ - platform.apiKeys.list(), - platform.members.list(), - platform.auditLogs.list({ - action: "session.stop", - resource: "session", - limit: 100, - }), - platform.sessionBans.listActive(), - platform.securityIncidents.list(), - platform.operations.retrieve("018f0000-0000-7000-8000-000000000001"), - platform.projectTokens.list("018f0000-0000-7000-8000-000000000002"), - ]); +const [keys, members, audit, bans, incidents, tokens] = await Promise.all([ + platform.apiKeys.list(), + platform.members.list(), + platform.auditLogs.list({ + action: "session.stop", + resource: "session", + limit: 100, + }), + platform.sessionBans.listActive(), + platform.securityIncidents.list(), + platform.projectTokens.list("018f0000-0000-7000-8000-000000000002"), +]); await platform.securityIncidents.acknowledge(incidents.data.data[0]!.id, { idempotencyKey: "acknowledge-incident-1", @@ -1022,15 +1157,13 @@ await platform.apiKeys.deactivate(keys.data.data[0]!.keyId, { }); ``` -The read operations require `sessions:read`. API-key deactivation and incident +These read operations require `sessions:read`. API-key deactivation and incident acknowledgement require `sessions:manage`. The two mutations are direct organization-scoped writes rather than asynchronous operations. The SDK retries them only when an idempotency key is supplied, but the pinned handlers do not persist that header. Incident acknowledgement is repeatable; an API-key deactivation retry after an unseen successful response can return `404` because -the key is already inactive. `operations.retrieve` polls the durable state of -asynchronous work started elsewhere and does not open a stream or wait for -completion. +the key is already inactive. These list responses are complete arrays. The source exposes no cursor or page token. The live audit handler accepts exact `action` and `resource` @@ -1049,22 +1182,49 @@ string rather than a dashboard user ID. Organization updates, member role changes, member deletion, invitations, billing top-ups, and console usage insights require a dashboard session and are not exposed by the server SDK. Browser client tokens are rejected by the -Management API, and `PlatformClient` deliberately rejects project and browser -client tokens before transport. +Management API. Project tokens are accepted only by a project-scoped `Client`; +organization-only resources are absent from that view's public type. + +## QuickLink settings + +`Client.quickLinkSettings.retrieve` and `update` map the management +`GET /v1/quicklink` and `PUT /v1/quicklink` operations. Use them on the root +organization client or an immutable project view: + +```ts +const organizationSettings = await platform.quickLinkSettings.retrieve(); +const projectSettings = await platform + .project("project_123") + .quickLinkSettings.update( + { theme: "dark", enabled: true }, + { idempotencyKey: "quicklink-project-123-dark" }, + ); +``` -## Legacy widget settings +These methods manage saved settings only. The server SDK has no hosted +QuickLink creation, inspection, or cancellation method for `/api/quicklinks`. +Those ephemeral flows belong to an application adapter and the browser +QuickLink controller. Console-only logo routes are also outside this client. -`PlatformClient.widgetSettings.retrieve` and `update` still request -`/v1/widget`, which is absent from the pinned API contract. The API now exposes -QuickLink settings at `/v1/quicklink`. These methods do not implement that -contract, and the ledger marks both QuickLink settings operations missing. -Do not use the widget settings methods against this API revision. +## Management session lifecycle -## Batch session lifecycle +The organization client's `sessions.start` requests a start for one stopped or +failed session. It accepts a session UUID or stable slug and an optional project +context: + +```ts +const start = await platform.sessions.start( + "support", + { projectId: "11111111-2222-4333-8444-555555555555" }, + { idempotencyKey: "start-support" }, +); +``` -`PlatformClient.sessions.stopMany` and `deleteMany` cover the two exact batch -operations. They require `sessions:manage` and accept `sessionIds` plus an -optional `projectId`: +The returned `SessionStartResult` confirms that the start request was accepted; +it does not claim that the session has connected. `sessions.stopMany` and +`deleteMany` cover the two bounded batch operations. All three require +`sessions:manage`. Batch methods accept `sessionIds` plus an optional +`projectId`: ```ts const stop = await platform.sessions.stopMany( @@ -1093,5 +1253,5 @@ stream, watcher, or completion status. The transport retries these mutations only when an idempotency key is provided. The pinned handlers do not persist that header. A repeated stop can enqueue another stop command; a repeated delete reports only rows still found. -Widget updates are state upserts and can safely converge on the same supplied -values. +QuickLink settings updates are state upserts and can safely converge on the +same supplied values. diff --git a/packages/typescript/src/bridge.ts b/packages/typescript/src/bridge.ts new file mode 100644 index 0000000..d6cdb74 --- /dev/null +++ b/packages/typescript/src/bridge.ts @@ -0,0 +1,67 @@ +import { + assertServerRuntime, + validateClientCredential, + type SharedClientOptions, +} from "./credentials.js"; +import { PolymorfaConfigurationError } from "./errors.js"; +import { HttpTransport } from "./transport/http.js"; +import type { ApiResponse, RequestOptions } from "./transport/types.js"; + +export interface BridgeClientOptions extends SharedClientOptions { + readonly credential: { + readonly type: "projectToken"; + readonly value: string; + }; +} + +export type BridgeRegion = "BR" | "US" | "IN" | "Auto"; +export type BridgeKind = "sandbox" | "production"; +export type BridgeSignal = "customer" | "bartender"; + +export interface BridgeRoute { + readonly wsUrl: string; + readonly region: BridgeRegion; + readonly kind: BridgeKind; + readonly signal: BridgeSignal; + readonly tokenKind: "project"; + readonly expiresAt: number; +} + +export class BridgeRoutesResource { + constructor(private readonly transport: HttpTransport) {} + + resolve(options: RequestOptions = {}): Promise> { + return this.transport.request({ + method: "GET", + path: "/v1/bridge/route", + ...options, + }); + } +} + +/** Project-token client for regional Bridge route discovery only. */ +export class BridgeClient { + readonly routes: BridgeRoutesResource; + + constructor(options: BridgeClientOptions) { + const credential = validateClientCredential(options.credential); + if (credential.type !== "projectToken") { + throw new PolymorfaConfigurationError( + "BridgeClient requires a project token.", + "credential", + ); + } + assertServerRuntime(); + const transport = new HttpTransport({ + baseUrl: options.baseUrl ?? "https://api.polymorfa.com", + authorization: `Bearer ${credential.value}`, + timeoutMs: options.timeoutMs ?? 30_000, + maxNetworkRetries: options.maxNetworkRetries ?? 2, + ...(options.apiVersion === undefined + ? {} + : { apiVersion: options.apiVersion }), + ...(options.fetch === undefined ? {} : { fetch: options.fetch }), + }); + this.routes = new BridgeRoutesResource(transport); + } +} diff --git a/packages/typescript/src/client.ts b/packages/typescript/src/client.ts new file mode 100644 index 0000000..f5d450b --- /dev/null +++ b/packages/typescript/src/client.ts @@ -0,0 +1,200 @@ +import { + assertServerRuntime, + validateClientCredential, + type ClientOptions, + type OrganizationClientOptions, + type ProjectScopedClientOptions, +} from "./credentials.js"; +import { PolymorfaConfigurationError } from "./errors.js"; +import { + ConfinedProjectRawClient, + RawClient, + type ProjectScopedRawClient, +} from "./raw.js"; +import { HttpTransport } from "./transport/http.js"; +import { ApiKeysResource } from "./platform/api-keys.js"; +import { AudiencesResource } from "./platform/audiences.js"; +import { AuditLogsResource } from "./platform/audit-logs.js"; +import { BillingResource } from "./platform/billing.js"; +import { CampaignsResource } from "./platform/campaigns.js"; +import { CustomersResource } from "./platform/customers.js"; +import { + EventsResource, + OperationsResourceV2, + WebhookDeliveriesResource, + WebhooksResource, +} from "./platform/developer-resources.js"; +import type { ClientOwner } from "./platform/developer-types.js"; +import { MediaResource } from "./platform/media.js"; +import { MembersResource } from "./platform/members.js"; +import { OptOutsResource } from "./platform/opt-outs.js"; +import { OrganizationsResource } from "./platform/organizations.js"; +import { ProjectTokensResource } from "./platform/project-tokens.js"; +import { ProjectsResource } from "./platform/projects.js"; +import { QuickLinkSettingsResource } from "./platform/quicklink-settings.js"; +import { SecurityIncidentsResource } from "./platform/security-incidents.js"; +import { SessionBansResource } from "./platform/session-bans.js"; +import { PlatformSessionsResource } from "./platform/sessions.js"; + +export type EventsResourceFor = EventsResource; +export type WebhooksResourceFor = WebhooksResource; +export type WebhookDeliveriesResourceFor = + WebhookDeliveriesResource; +export type OperationsResourceFor = + OperationsResourceV2; +export type RawResourceFor = O extends "project" + ? ProjectScopedRawClient + : RawClient; + +export interface ClientBase { + readonly owner: O; + readonly projectId: O extends "project" ? string : null; + readonly events: EventsResourceFor; + readonly webhooks: WebhooksResourceFor; + readonly webhookDeliveries: WebhookDeliveriesResourceFor; + readonly operations: OperationsResourceFor; + readonly quickLinkSettings: QuickLinkSettingsResource; + readonly raw: RawResourceFor; + project(projectId: string): Client<"project">; +} + +export interface OrganizationControlPlaneResources { + readonly apiKeys: ApiKeysResource; + readonly audiences: AudiencesResource; + readonly auditLogs: AuditLogsResource; + readonly billing: BillingResource; + readonly campaigns: CampaignsResource; + readonly customers: CustomersResource; + readonly members: MembersResource; + readonly organizations: OrganizationsResource; + readonly media: MediaResource; + readonly optOuts: OptOutsResource; + readonly projects: ProjectsResource; + readonly projectTokens: ProjectTokensResource; + readonly securityIncidents: SecurityIncidentsResource; + readonly sessionBans: SessionBansResource; + readonly sessions: PlatformSessionsResource; +} + +export type Client = ClientBase & + (O extends "organization" ? OrganizationControlPlaneResources : object); + +export interface ClientConstructor { + new (options: OrganizationClientOptions): Client<"organization">; + new (options: ProjectScopedClientOptions): Client<"project">; +} + +class ClientImplementation implements ClientBase { + readonly owner: ClientOwner; + readonly projectId: string | null; + readonly events: EventsResource; + readonly webhooks: WebhooksResource; + readonly webhookDeliveries: WebhookDeliveriesResource; + readonly operations: OperationsResourceV2; + readonly quickLinkSettings: QuickLinkSettingsResource; + readonly raw: RawClient | ProjectScopedRawClient; + readonly #transport: HttpTransport; + readonly #credential: ClientOptions["credential"]; + + constructor(options: ClientOptions, transport?: HttpTransport) { + const credential = validateClientCredential(options.credential); + assertServerRuntime(); + const projectId = + options.projectId === undefined + ? null + : validateProjectId(options.projectId); + if (credential.type === "projectToken" && projectId === null) { + throw new PolymorfaConfigurationError( + "Project tokens require an explicit projectId.", + "projectId", + ); + } + this.owner = projectId === null ? "organization" : "project"; + this.projectId = projectId; + this.#credential = credential; + this.#transport = + transport ?? + new HttpTransport({ + baseUrl: options.baseUrl ?? "https://api.polymorfa.com", + authorization: `Bearer ${credential.value}`, + timeoutMs: options.timeoutMs ?? 30_000, + maxNetworkRetries: options.maxNetworkRetries ?? 2, + ...(options.apiVersion === undefined + ? {} + : { apiVersion: options.apiVersion }), + ...(options.fetch === undefined ? {} : { fetch: options.fetch }), + }); + const prefix = + projectId === null + ? "/v1" + : `/v1/projects/${encodeURIComponent(projectId)}`; + this.events = new EventsResource(this.#transport, prefix); + this.webhooks = new WebhooksResource(this.#transport, prefix); + this.webhookDeliveries = new WebhookDeliveriesResource( + this.#transport, + prefix, + ); + this.operations = new OperationsResourceV2(this.#transport, prefix); + this.quickLinkSettings = new QuickLinkSettingsResource( + this.#transport, + projectId, + ); + this.raw = + projectId === null + ? new RawClient(this.#transport) + : new ConfinedProjectRawClient(this.#transport, projectId); + + if (this.owner === "organization") { + Object.assign(this, { + apiKeys: new ApiKeysResource(this.#transport), + audiences: new AudiencesResource(this.#transport), + auditLogs: new AuditLogsResource(this.#transport), + billing: new BillingResource(this.#transport), + campaigns: new CampaignsResource(this.#transport), + customers: new CustomersResource(this.#transport), + members: new MembersResource(this.#transport), + organizations: new OrganizationsResource(this.#transport), + media: new MediaResource(this.#transport), + optOuts: new OptOutsResource(this.#transport), + projects: new ProjectsResource(this.#transport), + projectTokens: new ProjectTokensResource(this.#transport), + securityIncidents: new SecurityIncidentsResource(this.#transport), + sessionBans: new SessionBansResource(this.#transport), + sessions: new PlatformSessionsResource(this.#transport), + }); + } + Object.freeze(this); + } + + project(projectId: string): Client<"project"> { + const validated = validateProjectId(projectId); + if (this.#credential.type === "projectToken") { + if (this.projectId !== validated) { + throw new PolymorfaConfigurationError( + "A project token cannot be rebound to another project.", + "projectId", + ); + } + return this as Client<"project">; + } + if (this.owner === "project" && this.projectId === validated) { + return this as Client<"project">; + } + return new ClientImplementation( + { credential: this.#credential, projectId: validated }, + this.#transport, + ) as Client<"project">; + } +} + +function validateProjectId(projectId: unknown): string { + if (typeof projectId !== "string" || projectId.trim().length === 0) { + throw new PolymorfaConfigurationError( + "A non-empty projectId is required for project-scoped clients.", + "projectId", + ); + } + return projectId; +} + +export const Client = ClientImplementation as unknown as ClientConstructor; diff --git a/packages/typescript/src/credentials.ts b/packages/typescript/src/credentials.ts index e1f49f0..5d626e0 100644 --- a/packages/typescript/src/credentials.ts +++ b/packages/typescript/src/credentials.ts @@ -16,10 +16,26 @@ export interface MessagingClientOptions extends SharedClientOptions { readonly credential: MessagingCredential; } -export interface PlatformClientOptions extends SharedClientOptions { - readonly apiKey: string; +export type ClientCredential = + | { readonly type: "projectToken"; readonly value: string } + | { readonly type: "organizationApiKey"; readonly value: string }; + +export interface OrganizationClientOptions extends SharedClientOptions { + readonly credential: { + readonly type: "organizationApiKey"; + readonly value: string; + }; + readonly projectId?: never; +} + +export interface ProjectScopedClientOptions extends SharedClientOptions { + readonly credential: ClientCredential; + readonly projectId: string; } +export type ClientOptions = + OrganizationClientOptions | ProjectScopedClientOptions; + export function validateMessagingCredential( credential: MessagingCredential, ): MessagingCredential { @@ -45,16 +61,46 @@ export function validateMessagingCredential( return credential; } -export function validatePlatformApiKey(value: string): string { +export function validateClientCredential( + credential: ClientCredential, +): ClientCredential { + rejectListenerCredential(credential.value); + if (credential.type === "projectToken") { + if ( + !credential.value.startsWith("pmfa_pt_") || + credential.value.length <= "pmfa_pt_".length + ) { + throw new PolymorfaConfigurationError( + "Project tokens must use the pmfa_pt_ prefix.", + "credential", + ); + } + return credential; + } + validateOrganizationApiKey(credential.value); + return credential; +} + +export function validateOrganizationApiKey(value: string): string { + rejectListenerCredential(value); if (!isServerApiKey(value)) { throw new PolymorfaConfigurationError( - "Platform server API key must use the pmfa_ prefix.", - "apiKey", + "Organization server API keys must use the pmfa_ prefix.", + "credential", ); } return value; } +function rejectListenerCredential(value: string): void { + if (value.startsWith("pmfa_ls_")) { + throw new PolymorfaConfigurationError( + "Listener credentials are accepted only by the Polymorfa CLI listener protocol.", + "credential", + ); + } +} + export function assertServerRuntime( runtime: { readonly window?: unknown } = globalThis as { readonly window?: unknown; @@ -73,6 +119,7 @@ function isServerApiKey(value: string): boolean { value.startsWith("pmfa_") && value.length > "pmfa_".length && !value.startsWith("pmfa_ct_") && - !value.startsWith("pmfa_pt_") + !value.startsWith("pmfa_pt_") && + !value.startsWith("pmfa_ls_") ); } diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts index 997924f..68263e3 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -1,12 +1,29 @@ export { assertServerRuntime, + validateClientCredential, validateMessagingCredential, - validatePlatformApiKey, + validateOrganizationApiKey, + type ClientCredential, + type ClientOptions, type MessagingClientOptions, type MessagingCredential, - type PlatformClientOptions, + type OrganizationClientOptions, + type ProjectScopedClientOptions, type SharedClientOptions, } from "./credentials.js"; +export { + Client, + type ClientBase, + type ClientConstructor, + type EventsResourceFor, + type OperationsResourceFor, + type OrganizationControlPlaneResources, + type RawResourceFor, + type WebhookDeliveriesResourceFor, + type WebhooksResourceFor, + type Client as ClientInstance, +} from "./client.js"; +export type * from "./platform/developer-types.js"; export { PolymorfaAuthenticationError, PolymorfaAuthorizationError, @@ -23,6 +40,15 @@ export { type PolymorfaErrorOptions, } from "./errors.js"; export { MessagingClient } from "./messaging/client.js"; +export { + BridgeClient, + BridgeRoutesResource, + type BridgeClientOptions, + type BridgeKind, + type BridgeRegion, + type BridgeRoute, + type BridgeSignal, +} from "./bridge.js"; export { ChatsResource } from "./messaging/chats.js"; export { ChannelsResource } from "./messaging/channels.js"; export { BusinessResource } from "./messaging/business.js"; @@ -413,7 +439,7 @@ export type { CreateLabelRequest, CreateLabelResponse, } from "./messaging/types.js"; -export { WebhooksResource } from "./messaging/webhooks.js"; +export { WebhooksResource as MessagingWebhooksResource } from "./messaging/webhooks.js"; export { CursorPage, type PageDecoder, type PageResult } from "./pagination.js"; export { AudiencesResource } from "./platform/audiences.js"; export { ApiKeysResource } from "./platform/api-keys.js"; @@ -421,18 +447,26 @@ export { AuditLogsResource } from "./platform/audit-logs.js"; export { BillingResource } from "./platform/billing.js"; export { CampaignsResource } from "./platform/campaigns.js"; export { CustomersResource } from "./platform/customers.js"; -export { PlatformClient } from "./platform/client.js"; export { MediaResource } from "./platform/media.js"; export { MembersResource } from "./platform/members.js"; export { OptOutsResource } from "./platform/opt-outs.js"; -export { PlatformOperationsResource } from "./platform/operations.js"; +export { + QuickLinkSettingsResource, + type OrganizationQuickLinkSettings, + type ProjectQuickLinkSettings, + type QuickLinkHistorySync, + type QuickLinkLogoMode, + type QuickLinkMethod, + type QuickLinkShape, + type QuickLinkTheme, + type UpdateQuickLinkSettingsInput, +} from "./platform/quicklink-settings.js"; export { OrganizationsResource } from "./platform/organizations.js"; export { ProjectTokensResource } from "./platform/project-tokens.js"; export { ProjectsResource } from "./platform/projects.js"; export { SecurityIncidentsResource } from "./platform/security-incidents.js"; export { SessionBansResource } from "./platform/session-bans.js"; export { PlatformSessionsResource } from "./platform/sessions.js"; -export { WidgetSettingsResource } from "./platform/widget-settings.js"; export type { ApiKey, ApiKeyDeactivation, @@ -470,8 +504,6 @@ export type { ListCampaignsParams, ListCustomerEventsParams, ListCustomersParams, - ManagementOperation, - ManagementOperationStatus, ManagedSession, Organization, OrganizationMember, @@ -495,6 +527,7 @@ export type { SessionBanStatus, SessionProjectContext, SessionRemoveResult, + SessionStartResult, SessionStopResult, SessionTier, SessionTierOverrideRequest, @@ -502,19 +535,8 @@ export type { TransferCustomerNumberRequest, UpdateBillingReminderSettingsRequest, UpdateCustomerRequest, - RetrieveWidgetSettingsParams, - UpdateWidgetSettingsRequest, - WidgetColorPalette, - WidgetColors, - WidgetHistorySync, - WidgetLogoMode, - WidgetMethod, - WidgetMode, - WidgetSettings, - WidgetShape, - WidgetTheme, } from "./platform/types.js"; -export { RawClient } from "./raw.js"; +export { RawClient, type ProjectScopedRawClient } from "./raw.js"; export type { ApiResponse, HttpMethod, @@ -525,11 +547,21 @@ export type { ResponseMetadata, } from "./transport/types.js"; export { SDK_VERSION } from "./version.js"; +export { + SystemClient, + type HealthCheck, + type HealthResponse, + type PingResponse, + type StatusResponse, + type SystemClientOptions, + type VersionResponse, +} from "./system.js"; export { KNOWN_WEBHOOK_EVENT_TYPES, WebhookSignatureError, constructWebhookEvent, isEvent, + webhooks, verifyWebhookSignature, type BlocklistChange, type BlocklistUpdatePayload, @@ -578,6 +610,12 @@ export { type SessionStatusPayload, type UnknownWebhookEvent, type WebhookBody, + type WebhookFixture, + type WebhookUtilities, + type VerifyWebhookInput, + type VerifyWebhookSignatureInput, + type VerifyLocalWebhookInput, + type CreateWebhookFixtureInput, type WebhookEvent, type WebhookEventOf, type WebhookPayloadMap, diff --git a/packages/typescript/src/pagination.ts b/packages/typescript/src/pagination.ts index f86d9b2..7d067c2 100644 --- a/packages/typescript/src/pagination.ts +++ b/packages/typescript/src/pagination.ts @@ -1,11 +1,14 @@ -import type { ApiResponse } from "./transport/types.js"; +import type { ApiResponse, ResponseMetadata } from "./transport/types.js"; export interface PageResult { readonly items: readonly T[]; readonly nextCursor?: string | null; } -export type PageDecoder = (data: unknown) => PageResult; +export type PageDecoder = ( + data: unknown, + metadata: ResponseMetadata, +) => PageResult; interface CursorPageOptions { readonly items: readonly T[]; diff --git a/packages/typescript/src/platform/client.ts b/packages/typescript/src/platform/client.ts deleted file mode 100644 index ce8a02e..0000000 --- a/packages/typescript/src/platform/client.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { - assertServerRuntime, - validatePlatformApiKey, - type PlatformClientOptions, -} from "../credentials.js"; -import { RawClient } from "../raw.js"; -import { HttpTransport } from "../transport/http.js"; -import { AudiencesResource } from "./audiences.js"; -import { ApiKeysResource } from "./api-keys.js"; -import { AuditLogsResource } from "./audit-logs.js"; -import { BillingResource } from "./billing.js"; -import { CampaignsResource } from "./campaigns.js"; -import { CustomersResource } from "./customers.js"; -import { MediaResource } from "./media.js"; -import { MembersResource } from "./members.js"; -import { OptOutsResource } from "./opt-outs.js"; -import { PlatformOperationsResource } from "./operations.js"; -import { OrganizationsResource } from "./organizations.js"; -import { ProjectTokensResource } from "./project-tokens.js"; -import { ProjectsResource } from "./projects.js"; -import { SecurityIncidentsResource } from "./security-incidents.js"; -import { SessionBansResource } from "./session-bans.js"; -import { PlatformSessionsResource } from "./sessions.js"; -import { WidgetSettingsResource } from "./widget-settings.js"; - -export class PlatformClient { - readonly apiKeys: ApiKeysResource; - readonly audiences: AudiencesResource; - readonly auditLogs: AuditLogsResource; - readonly billing: BillingResource; - readonly campaigns: CampaignsResource; - readonly customers: CustomersResource; - readonly members: MembersResource; - readonly organizations: OrganizationsResource; - readonly media: MediaResource; - readonly optOuts: OptOutsResource; - readonly operations: PlatformOperationsResource; - readonly projects: ProjectsResource; - readonly projectTokens: ProjectTokensResource; - readonly securityIncidents: SecurityIncidentsResource; - readonly sessionBans: SessionBansResource; - readonly sessions: PlatformSessionsResource; - readonly widgetSettings: WidgetSettingsResource; - readonly raw: RawClient; - - constructor(options: PlatformClientOptions) { - const apiKey = validatePlatformApiKey(options.apiKey); - assertServerRuntime(); - const transport = new HttpTransport({ - baseUrl: options.baseUrl ?? "https://api.polymorfa.com", - authorization: `Bearer ${apiKey}`, - timeoutMs: options.timeoutMs ?? 30_000, - maxNetworkRetries: options.maxNetworkRetries ?? 2, - ...(options.apiVersion === undefined - ? {} - : { apiVersion: options.apiVersion }), - ...(options.fetch === undefined ? {} : { fetch: options.fetch }), - }); - this.apiKeys = new ApiKeysResource(transport); - this.audiences = new AudiencesResource(transport); - this.auditLogs = new AuditLogsResource(transport); - this.billing = new BillingResource(transport); - this.campaigns = new CampaignsResource(transport); - this.customers = new CustomersResource(transport); - this.members = new MembersResource(transport); - this.organizations = new OrganizationsResource(transport); - this.media = new MediaResource(transport); - this.optOuts = new OptOutsResource(transport); - this.operations = new PlatformOperationsResource(transport); - this.projects = new ProjectsResource(transport); - this.projectTokens = new ProjectTokensResource(transport); - this.securityIncidents = new SecurityIncidentsResource(transport); - this.sessionBans = new SessionBansResource(transport); - this.sessions = new PlatformSessionsResource(transport); - this.widgetSettings = new WidgetSettingsResource(transport); - this.raw = new RawClient(transport); - } -} diff --git a/packages/typescript/src/platform/developer-resources.ts b/packages/typescript/src/platform/developer-resources.ts new file mode 100644 index 0000000..a3bc560 --- /dev/null +++ b/packages/typescript/src/platform/developer-resources.ts @@ -0,0 +1,520 @@ +import { + PolymorfaCancelledError, + PolymorfaTimeoutError, + PolymorfaValidationError, +} from "../errors.js"; +import { CursorPage } from "../pagination.js"; +import { RawClient } from "../raw.js"; +import { HttpTransport } from "../transport/http.js"; +import type { ApiResponse, RequestOptions } from "../transport/types.js"; +import type { + ClientOwner, + CreateOrganizationWebhookInput, + CreateProjectWebhookInput, + ListDeliveryAttemptsParams, + ListEventsParams, + ListOperationTransitionsParams, + ListOperationsParams, + ListWebhookDeliveriesParams, + ListWebhooksParams, + OrganizationEvent, + OrganizationEventReplayReceipt, + OrganizationOperation, + OrganizationOperationCancellationReceipt, + OrganizationOperationTransition, + OrganizationWebhook, + OrganizationWebhookCreationReceipt, + OrganizationWebhookDeletionReceipt, + OrganizationWebhookDelivery, + OrganizationWebhookDeliveryAttempt, + OrganizationWebhookDeliveryRetryReceipt, + OrganizationWebhookMutationReceipt, + OrganizationWebhookSecretRotationReceipt, + OrganizationWebhookTestReceipt, + ProjectEvent, + ProjectEventReplayReceipt, + ProjectOperation, + ProjectOperationCancellationReceipt, + ProjectOperationTransition, + ProjectWebhook, + ProjectWebhookCreationReceipt, + ProjectWebhookDeletionReceipt, + ProjectWebhookDelivery, + ProjectWebhookDeliveryAttempt, + ProjectWebhookDeliveryRetryReceipt, + ProjectWebhookMutationReceipt, + ProjectWebhookSecretRotationReceipt, + ProjectWebhookTestReceipt, + ReplayEventInput, + RetryWebhookDeliveryInput, + RetrieveEventParams, + RotateWebhookSecretInput, + TestWebhookInput, + UpdateOrganizationWebhookInput, + UpdateProjectWebhookInput, + WaitForOperationOptions, +} from "./developer-types.js"; +import { + decodeCursorPage, + type DataEnvelope, + unwrapResponse, +} from "./response.js"; + +type EventFor = O extends "project" + ? ProjectEvent + : OrganizationEvent; +type EventReplayFor = O extends "project" + ? ProjectEventReplayReceipt + : OrganizationEventReplayReceipt; +type WebhookFor = O extends "project" + ? ProjectWebhook + : OrganizationWebhook; +type CreateWebhookFor = O extends "project" + ? CreateProjectWebhookInput + : CreateOrganizationWebhookInput; +type UpdateWebhookFor = O extends "project" + ? UpdateProjectWebhookInput + : UpdateOrganizationWebhookInput; +type WebhookCreationFor = O extends "project" + ? ProjectWebhookCreationReceipt + : OrganizationWebhookCreationReceipt; +type WebhookMutationFor = O extends "project" + ? ProjectWebhookMutationReceipt + : OrganizationWebhookMutationReceipt; +type WebhookDeletionFor = O extends "project" + ? ProjectWebhookDeletionReceipt + : OrganizationWebhookDeletionReceipt; +type WebhookRotationFor = O extends "project" + ? ProjectWebhookSecretRotationReceipt + : OrganizationWebhookSecretRotationReceipt; +type WebhookTestFor = O extends "project" + ? ProjectWebhookTestReceipt + : OrganizationWebhookTestReceipt; +type DeliveryFor = O extends "project" + ? ProjectWebhookDelivery + : OrganizationWebhookDelivery; +type AttemptFor = O extends "project" + ? ProjectWebhookDeliveryAttempt + : OrganizationWebhookDeliveryAttempt; +type DeliveryRetryFor = O extends "project" + ? ProjectWebhookDeliveryRetryReceipt + : OrganizationWebhookDeliveryRetryReceipt; +type OperationFor = O extends "project" + ? ProjectOperation + : OrganizationOperation; +type OperationTransitionFor = O extends "project" + ? ProjectOperationTransition + : OrganizationOperationTransition; +type OperationCancellationFor = O extends "project" + ? ProjectOperationCancellationReceipt + : OrganizationOperationCancellationReceipt; + +class ResourceBase { + protected readonly raw: RawClient; + constructor( + protected readonly transport: HttpTransport, + protected readonly prefix: string, + ) { + this.raw = new RawClient(transport); + } + + protected path(suffix: string): string { + return `${this.prefix}${suffix}`; + } + + protected async fetchResource( + path: string, + options: RequestOptions, + ): Promise> { + return unwrapResponse( + await this.transport.request>({ + method: "GET", + path, + ...options, + }), + ); + } + + protected async mutate( + method: "POST" | "PATCH" | "DELETE", + path: string, + body: unknown, + options: RequestOptions, + ): Promise> { + return unwrapResponse( + await this.transport.request>({ + method, + path, + ...(body === undefined ? {} : { body }), + ...options, + }), + ); + } + + protected page( + path: string, + query: Record, + options: RequestOptions, + ): Promise> { + return this.raw.paginate( + { method: "GET", path, query: query as never, ...options }, + decodeCursorPage, + ); + } +} + +export class EventsResource extends ResourceBase { + list( + params: ListEventsParams = {}, + options: RequestOptions = {}, + ): Promise>> { + return this.page(this.path("/events"), { ...params }, options); + } + retrieve( + eventId: string, + params: RetrieveEventParams = {}, + options: RequestOptions = {}, + ): Promise>> { + return this.transport + .request>>({ + method: "GET", + path: this.path(`/events/${encodeURIComponent(eventId)}`), + query: params as Readonly>, + ...options, + }) + .then(unwrapResponse); + } + replay( + eventId: string, + input: ReplayEventInput, + options: RequestOptions = {}, + ): Promise>> { + return this.mutate( + "POST", + this.path(`/events/${encodeURIComponent(eventId)}/replays`), + input, + options, + ); + } +} + +export class WebhooksResource extends ResourceBase { + list( + params: ListWebhooksParams = {}, + options: RequestOptions = {}, + ): Promise>> { + return this.page(this.path("/webhooks"), { ...params }, options); + } + create( + input: CreateWebhookFor, + options: RequestOptions = {}, + ): Promise>> { + return this.mutate("POST", this.path("/webhooks"), input, options); + } + retrieve( + webhookId: string, + options: RequestOptions = {}, + ): Promise>> { + return this.fetchResource( + this.path(`/webhooks/${encodeURIComponent(webhookId)}`), + options, + ); + } + update( + webhookId: string, + input: UpdateWebhookFor, + options: RequestOptions = {}, + ): Promise>> { + return this.mutate( + "PATCH", + this.path(`/webhooks/${encodeURIComponent(webhookId)}`), + input, + options, + ); + } + delete( + webhookId: string, + options: RequestOptions = {}, + ): Promise>> { + return this.mutate( + "DELETE", + this.path(`/webhooks/${encodeURIComponent(webhookId)}`), + undefined, + options, + ); + } + test( + webhookId: string, + input: TestWebhookInput = {}, + options: RequestOptions = {}, + ): Promise>> { + return this.mutate( + "POST", + this.path(`/webhooks/${encodeURIComponent(webhookId)}/tests`), + input, + options, + ); + } + rotateSecret( + webhookId: string, + input: RotateWebhookSecretInput = {}, + options: RequestOptions = {}, + ): Promise>> { + return this.mutate( + "POST", + this.path(`/webhooks/${encodeURIComponent(webhookId)}/secret-rotations`), + input, + options, + ); + } +} + +export class WebhookDeliveriesResource< + O extends ClientOwner, +> extends ResourceBase { + list( + params: ListWebhookDeliveriesParams = {}, + options: RequestOptions = {}, + ): Promise>> { + return this.page(this.path("/webhook-deliveries"), { ...params }, options); + } + retrieve( + deliveryId: string, + options: RequestOptions = {}, + ): Promise>> { + return this.fetchResource( + this.path(`/webhook-deliveries/${encodeURIComponent(deliveryId)}`), + options, + ); + } + listAttempts( + deliveryId: string, + params: ListDeliveryAttemptsParams = {}, + options: RequestOptions = {}, + ): Promise>> { + return this.page( + this.path( + `/webhook-deliveries/${encodeURIComponent(deliveryId)}/attempts`, + ), + { ...params }, + options, + ); + } + retrieveAttempt( + deliveryId: string, + attemptId: string, + options: RequestOptions = {}, + ): Promise>> { + return this.fetchResource( + this.path( + `/webhook-deliveries/${encodeURIComponent(deliveryId)}/attempts/${encodeURIComponent(attemptId)}`, + ), + options, + ); + } + retry( + deliveryId: string, + input: RetryWebhookDeliveryInput = {}, + options: RequestOptions = {}, + ): Promise>> { + return this.mutate( + "POST", + this.path(`/webhook-deliveries/${encodeURIComponent(deliveryId)}/retry`), + input, + options, + ); + } +} + +const TERMINAL = new Set([ + "action_required", + "succeeded", + "failed", + "cancelled", +]); + +export class OperationsResourceV2 extends ResourceBase { + list( + params: ListOperationsParams = {}, + options: RequestOptions = {}, + ): Promise>> { + if ( + (params.resourceType === undefined) !== + (params.resourceId === undefined) + ) { + throw new PolymorfaValidationError( + "resourceType and resourceId must be supplied together.", + { code: "invalid_operation_filter" }, + ); + } + return this.page(this.path("/operations"), { ...params }, options); + } + retrieve( + operationId: string, + options: RequestOptions = {}, + ): Promise>> { + return this.fetchResource( + this.path(`/operations/${encodeURIComponent(operationId)}`), + options, + ); + } + listTransitions( + operationId: string, + params: ListOperationTransitionsParams = {}, + options: RequestOptions = {}, + ): Promise>> { + return this.page( + this.path(`/operations/${encodeURIComponent(operationId)}/transitions`), + { ...params }, + options, + ); + } + cancel( + operationId: string, + options: RequestOptions = {}, + ): Promise>> { + return this.mutate( + "POST", + this.path(`/operations/${encodeURIComponent(operationId)}/cancel`), + undefined, + options, + ); + } + async wait( + operationId: string, + options: WaitForOperationOptions = {}, + ): Promise>> { + const maxWaitMs = integer( + options.maxWaitMs ?? 300_000, + "maxWaitMs", + 1, + Number.MAX_SAFE_INTEGER, + ); + const pollIntervalMs = integer( + options.pollIntervalMs ?? 1_000, + "pollIntervalMs", + 250, + 30_000, + ); + const deadline = Date.now() + maxWaitMs; + const deadlineController = new AbortController(); + let deadlineReached = false; + const abortForCaller = () => + deadlineController.abort(options.signal?.reason); + options.signal?.addEventListener("abort", abortForCaller, { once: true }); + const deadlineTimer = setTimeout(() => { + deadlineReached = true; + deadlineController.abort(); + }, maxWaitMs); + try { + while (true) { + if (options.signal?.aborted === true) + throw new PolymorfaCancelledError( + "The operation wait was cancelled.", + { code: "operation_wait_cancelled" }, + ); + const remaining = deadline - Date.now(); + if (remaining <= 0) throw operationWaitTimeout(maxWaitMs); + let response: ApiResponse>; + try { + response = await this.retrieve(operationId, { + ...options.requestOptions, + timeoutMs: Math.min( + options.requestOptions?.timeoutMs ?? remaining, + remaining, + ), + signal: deadlineController.signal, + }); + } catch (error) { + if (deadlineReached && !isAborted(options.signal)) { + throw operationWaitTimeout(maxWaitMs); + } + throw error; + } + if (Date.now() >= deadline) throw operationWaitTimeout(maxWaitMs); + if (TERMINAL.has(response.data.status)) return response; + const retryAfter = retryAfterMilliseconds( + response.metadata.headers["retry-after"], + ); + try { + await wait( + Math.min(Math.max(pollIntervalMs, retryAfter ?? 0), remaining), + deadlineController.signal, + ); + } catch (error) { + if (deadlineReached && !isAborted(options.signal)) { + throw operationWaitTimeout(maxWaitMs); + } + throw error; + } + } + } finally { + clearTimeout(deadlineTimer); + options.signal?.removeEventListener("abort", abortForCaller); + } + } +} + +function operationWaitTimeout(maxWaitMs: number): PolymorfaTimeoutError { + return new PolymorfaTimeoutError( + `The operation did not reach a terminal state within ${maxWaitMs}ms.`, + { code: "operation_wait_timeout" }, + ); +} + +function isAborted(signal: AbortSignal | undefined): boolean { + return signal?.aborted === true; +} + +function retryAfterMilliseconds( + value: string | undefined, + now = Date.now(), +): number | undefined { + if (value === undefined) return undefined; + if (/^\d+$/.test(value)) { + const seconds = Number(value); + return Number.isSafeInteger(seconds) ? seconds * 1_000 : undefined; + } + const retryAt = Date.parse(value); + return Number.isNaN(retryAt) ? undefined : Math.max(0, retryAt - now); +} + +function integer( + value: number, + name: string, + min: number, + max: number, +): number { + if (!Number.isSafeInteger(value) || value < min || value > max) + throw new PolymorfaValidationError( + `${name} must be an integer from ${min} through ${max}.`, + { code: `invalid_${name}` }, + ); + return value; +} + +function wait(milliseconds: number, signal?: AbortSignal): Promise { + if (signal?.aborted === true) { + return Promise.reject( + new PolymorfaCancelledError("The operation wait was cancelled.", { + code: "operation_wait_cancelled", + }), + ); + } + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + signal?.removeEventListener("abort", abort); + resolve(); + }, milliseconds); + const abort = () => { + clearTimeout(timer); + signal?.removeEventListener("abort", abort); + reject( + new PolymorfaCancelledError("The operation wait was cancelled.", { + code: "operation_wait_cancelled", + }), + ); + }; + signal?.addEventListener("abort", abort, { once: true }); + }); +} diff --git a/packages/typescript/src/platform/developer-types.ts b/packages/typescript/src/platform/developer-types.ts new file mode 100644 index 0000000..bf651a8 --- /dev/null +++ b/packages/typescript/src/platform/developer-types.ts @@ -0,0 +1,385 @@ +import type { RequestOptions } from "../transport/types.js"; + +export type ClientOwner = "organization" | "project"; +export type JsonValue = + | null + | boolean + | number + | string + | readonly JsonValue[] + | { readonly [key: string]: JsonValue }; + +export interface EncodedEventPayload { + readonly encoding: "base64"; + readonly contentType: "application/json"; + readonly data: string; +} + +export type PayloadAvailability = + "available" | "not_retained" | "expired" | "redacted" | "unavailable"; + +interface EventBase { + readonly id: string; + readonly organizationId: string; + readonly type: string; + readonly source: "runtime" | "platform" | "test"; + readonly environment: "development" | "production"; + readonly createdAt: string; + readonly payloadAvailability: PayloadAvailability; + readonly payload: EncodedEventPayload | null; + readonly replayableUntil: string | null; + readonly metadataExpiresAt: string; +} +export interface OrganizationEvent extends EventBase { + readonly projectId: null; +} +export interface ProjectEvent extends EventBase { + readonly projectId: string; +} + +export interface ListEventsParams { + readonly type?: string; + readonly since?: string; + readonly until?: string; + readonly limit?: number; + readonly cursor?: string; +} +export type ListOrganizationEventsParams = ListEventsParams; +export interface RetrieveEventParams { + readonly includePayload?: boolean; +} +export interface ReplayEventInput { + readonly webhookId: string; +} +export type ReplayOrganizationEventInput = ReplayEventInput; + +export interface IdempotencyReceipt { + readonly id: string; + readonly key: string; + readonly replayed: boolean; + readonly createdAt: string; + readonly expiresAt: string; +} +interface ReplayReceipt { + readonly eventId: string; + readonly deliveryId: string; + readonly operationId: string; + readonly idempotency: IdempotencyReceipt; +} +export type ProjectEventReplayReceipt = ReplayReceipt; +export type OrganizationEventReplayReceipt = ReplayReceipt; + +export interface WebhookRetryPolicyInput { + readonly maximumAttempts: number; + readonly backoff: "constant" | "linear" | "exponential"; + readonly initialDelaySeconds: number; +} +export type WebhookRetryPolicy = WebhookRetryPolicyInput; +export interface WebhookHeaderInput { + readonly name: string; + readonly value: string; +} +export interface WebhookHeaderMetadata { + readonly name: string; +} +export interface WebhookSigningSecretMetadata { + readonly version: number; + readonly createdAt: string; + readonly previousValidUntil: string | null; +} +interface WebhookBase { + readonly id: string; + readonly organizationId: string; + readonly url: string; + readonly eventTypes: readonly string[]; + readonly enabled: boolean; + readonly format: "native" | "meta"; + readonly retryPolicy: WebhookRetryPolicy; + readonly headers: readonly WebhookHeaderMetadata[]; + readonly secret: WebhookSigningSecretMetadata; + readonly createdAt: string; + readonly updatedAt: string; +} +export interface OrganizationWebhook extends WebhookBase { + readonly owner: "organization"; + readonly projectId: null; +} +export interface ProjectWebhook extends WebhookBase { + readonly owner: "project"; + readonly projectId: string; +} +interface CreateWebhookInputBase { + readonly url: string; + readonly eventTypes: readonly string[]; + readonly enabled?: boolean; + readonly format?: "native" | "meta"; + readonly retryPolicy?: WebhookRetryPolicyInput; + readonly headers?: readonly WebhookHeaderInput[]; +} +export type CreateOrganizationWebhookInput = CreateWebhookInputBase; +export type CreateProjectWebhookInput = CreateWebhookInputBase; +interface UpdateWebhookInputBase { + readonly url?: string; + readonly eventTypes?: readonly string[]; + readonly enabled?: boolean; + readonly format?: "native" | "meta"; + readonly retryPolicy?: WebhookRetryPolicyInput; + readonly headers?: readonly WebhookHeaderInput[]; +} +export type UpdateOrganizationWebhookInput = UpdateWebhookInputBase; +export type UpdateProjectWebhookInput = UpdateWebhookInputBase; +export interface ListWebhooksParams { + readonly eventType?: string; + readonly enabled?: boolean; + readonly limit?: number; + readonly cursor?: string; +} +export type ListOrganizationWebhooksParams = ListWebhooksParams; +export interface RotateWebhookSecretInput { + readonly overlapSeconds?: number; +} +export type RetryWebhookDeliveryInput = Readonly>; +export type TestWebhookInput = + | { + readonly eventType?: string; + readonly body?: never; + readonly sessionId?: never; + } + | { + readonly eventType?: string; + readonly body: EncodedEventPayload; + readonly sessionId: string; + }; + +interface WebhookCreationReceipt { + readonly webhook: T; + readonly operationId: null; + readonly idempotency: IdempotencyReceipt; + readonly secret: string | null; + readonly secretAvailable: boolean; +} +export type OrganizationWebhookCreationReceipt = + WebhookCreationReceipt; +export type ProjectWebhookCreationReceipt = + WebhookCreationReceipt; +interface WebhookMutationReceipt { + readonly webhook: T; + readonly operationId: null; + readonly idempotency: IdempotencyReceipt; +} +export type OrganizationWebhookMutationReceipt = + WebhookMutationReceipt; +export type ProjectWebhookMutationReceipt = + WebhookMutationReceipt; +interface WebhookDeletionReceipt { + readonly webhookId: string; + readonly deleted: true; + readonly operationId: null; + readonly idempotency: IdempotencyReceipt; +} +export type OrganizationWebhookDeletionReceipt = WebhookDeletionReceipt; +export type ProjectWebhookDeletionReceipt = WebhookDeletionReceipt; +interface WebhookSecretRotationReceipt { + readonly webhookId: string; + readonly operationId: null; + readonly secret: string | null; + readonly secretAvailable: boolean; + readonly secretMetadata: WebhookSigningSecretMetadata; + readonly idempotency: IdempotencyReceipt; +} +export type OrganizationWebhookSecretRotationReceipt = + WebhookSecretRotationReceipt; +export type ProjectWebhookSecretRotationReceipt = WebhookSecretRotationReceipt; +export type OrganizationWebhookTestReceipt = ReplayReceipt; +export type ProjectWebhookTestReceipt = ReplayReceipt; + +export type WebhookDeliveryStatus = + "pending" | "delivering" | "retrying" | "succeeded" | "failed"; +interface WebhookDeliveryBase { + readonly id: string; + readonly organizationId: string; + readonly eventId: string; + readonly webhookId: string; + readonly status: WebhookDeliveryStatus; + readonly attemptCount: number; + readonly capabilities: { readonly retryable: boolean }; + readonly payloadAvailability: PayloadAvailability; + readonly replayableUntil: string | null; + readonly metadataExpiresAt: string; + readonly nextAttemptAt: string | null; + readonly lastAttemptAt: string | null; + readonly completedAt: string | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly lastOutcome: { + readonly statusCode: number | null; + readonly errorCode: string | null; + } | null; +} +export interface OrganizationWebhookDelivery extends WebhookDeliveryBase { + readonly projectId: null; +} +export interface ProjectWebhookDelivery extends WebhookDeliveryBase { + readonly projectId: string; +} +export interface ListWebhookDeliveriesParams { + readonly webhookId?: string; + readonly eventId?: string; + readonly status?: WebhookDeliveryStatus; + readonly since?: string; + readonly until?: string; + readonly limit?: number; + readonly cursor?: string; +} +export type ListOrganizationWebhookDeliveriesParams = + ListWebhookDeliveriesParams; +export interface ListDeliveryAttemptsParams { + readonly limit?: number; + readonly cursor?: string; +} +interface WebhookDeliveryAttemptBase { + readonly id: string; + readonly organizationId: string; + readonly deliveryId: string; + readonly number: number; + readonly status: "pending" | "delivering" | "succeeded" | "failed"; + readonly startedAt: string | null; + readonly completedAt: string | null; + readonly nextRetryAt: string | null; + readonly durationMs: number | null; + readonly statusCode: number | null; + readonly errorCode: string | null; + readonly metadataExpiresAt: string; +} +export interface OrganizationWebhookDeliveryAttempt extends WebhookDeliveryAttemptBase { + readonly projectId: null; +} +export interface ProjectWebhookDeliveryAttempt extends WebhookDeliveryAttemptBase { + readonly projectId: string; +} +interface WebhookDeliveryRetryReceipt { + readonly deliveryId: string; + readonly attemptId: string; + readonly operationId: string; + readonly idempotency: IdempotencyReceipt; +} +export type OrganizationWebhookDeliveryRetryReceipt = + WebhookDeliveryRetryReceipt; +export type ProjectWebhookDeliveryRetryReceipt = WebhookDeliveryRetryReceipt; + +export type OperationStatus = + | "pending" + | "running" + | "action_required" + | "cancelling" + | "succeeded" + | "failed" + | "cancelled"; +export type OperationKind = + | "auth_projection_repair" + | "session_lifecycle" + | "auth_session_purge" + | "label_projection_purge" + | "billing_reconciliation" + | "auto_top_up" + | "campaign" + | "production_enrollment" + | "retention_sweep" + | "webhook_redrive"; +export interface OperationProgress { + readonly code: string; + readonly current: number | null; + readonly total: number | null; +} +export interface OperationError { + readonly code: string; + readonly retryable: boolean; + readonly details: Readonly> | null; +} +export interface OperationActionRequired { + readonly code: string; + readonly details: Readonly> | null; +} +interface OperationBase { + readonly id: string; + readonly organizationId: string; + readonly kind: OperationKind; + readonly resource: { readonly type: string; readonly id: string }; + readonly status: OperationStatus; + readonly sequence: number; + readonly capabilities: { + readonly cancellable: boolean; + readonly watchable: boolean; + }; + readonly progress: OperationProgress | null; + readonly result: JsonValue; + readonly error: OperationError | null; + readonly actionRequired: OperationActionRequired | null; + readonly createdAt: string; + readonly updatedAt: string; + readonly completedAt: string | null; +} +export interface OrganizationOperation extends OperationBase { + readonly projectId: null; +} +export interface ProjectOperation extends OperationBase { + readonly projectId: string; +} +export type ManagementOperation = OrganizationOperation | ProjectOperation; +export type ManagementOperationStatus = OperationStatus; +export type ManagementOperationKind = OperationKind; +export type ManagementOperationProgress = OperationProgress; +export type ManagementOperationError = OperationError; +export type ManagementOperationActionRequired = OperationActionRequired; +export interface ListOperationsParams { + readonly status?: OperationStatus; + readonly kind?: OperationKind; + readonly resourceType?: string; + readonly resourceId?: string; + readonly since?: string; + readonly until?: string; + readonly limit?: number; + readonly cursor?: string; +} +export type ListOrganizationOperationsParams = ListOperationsParams; +export type ListOperationTransitionsParams = + | { + readonly afterSequence?: number; + readonly cursor?: never; + readonly limit?: number; + } + | { + readonly cursor: string; + readonly afterSequence?: never; + readonly limit?: number; + }; +export interface OperationTransition { + readonly operationId: string; + readonly sequence: number; + readonly fromStatus: OperationStatus | null; + readonly toStatus: OperationStatus; + readonly reasonCode: string | null; + readonly occurredAt: string; + readonly snapshot: { + readonly progress: OperationProgress | null; + readonly error: OperationError | null; + readonly actionRequired: OperationActionRequired | null; + }; +} +export type OrganizationOperationTransition = OperationTransition; +export type ProjectOperationTransition = OperationTransition; +interface OperationCancellationReceipt { + readonly operation: T; + readonly operationId: string; + readonly idempotency: IdempotencyReceipt; +} +export type OrganizationOperationCancellationReceipt = + OperationCancellationReceipt; +export type ProjectOperationCancellationReceipt = + OperationCancellationReceipt; +export interface WaitForOperationOptions { + readonly maxWaitMs?: number; + readonly pollIntervalMs?: number; + readonly requestOptions?: Omit; + readonly signal?: AbortSignal; +} diff --git a/packages/typescript/src/platform/operations.ts b/packages/typescript/src/platform/operations.ts deleted file mode 100644 index 73d75ca..0000000 --- a/packages/typescript/src/platform/operations.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { HttpTransport } from "../transport/http.js"; -import type { ApiResponse, RequestOptions } from "../transport/types.js"; -import type { DataEnvelope, ManagementOperation } from "./types.js"; - -export class PlatformOperationsResource { - constructor(private readonly transport: HttpTransport) {} - - retrieve( - operationId: string, - options: RequestOptions = {}, - ): Promise>> { - return this.transport.request({ - method: "GET", - path: `/v1/operations/${encodeURIComponent(operationId)}`, - ...options, - }); - } -} diff --git a/packages/typescript/src/platform/quicklink-settings.ts b/packages/typescript/src/platform/quicklink-settings.ts new file mode 100644 index 0000000..d071950 --- /dev/null +++ b/packages/typescript/src/platform/quicklink-settings.ts @@ -0,0 +1,111 @@ +import { HttpTransport } from "../transport/http.js"; +import type { ApiResponse, RequestOptions } from "../transport/types.js"; +import type { ClientOwner } from "./developer-types.js"; +import { type DataEnvelope, unwrapResponse } from "./response.js"; + +export type QuickLinkTheme = "light" | "dark" | "system"; +export type QuickLinkShape = "square" | "rounded" | "pill"; +export type QuickLinkLogoMode = "none" | "custom" | "organization" | "project"; +export type QuickLinkHistorySync = "ask" | "force_on" | "force_off"; +export type QuickLinkMethod = "qr" | "pairing"; + +interface QuickLinkSettingsBase { + readonly id: string; + readonly enabled: boolean; + readonly allowedRedirectUris: readonly string[]; + readonly businessName: string | null; + readonly headline: string | null; + readonly description: string | null; + readonly successMessage: string | null; + readonly supportUrl: string | null; + readonly privacyUrl: string | null; + readonly termsUrl: string | null; + readonly accent: string | null; + readonly theme: QuickLinkTheme; + readonly hideWatermark: boolean; + readonly shape: QuickLinkShape | null; + readonly radiusPx: number | null; + readonly logoMode: QuickLinkLogoMode; + readonly logoStorageId: string | null; + readonly logoSourceStorageId: string | null; + readonly logoUrl: string | null; + readonly historySync: QuickLinkHistorySync; + readonly methods: readonly QuickLinkMethod[] | null; + readonly defaultMethod: QuickLinkMethod | null; + readonly createdAt: number; + readonly updatedAt: number; +} + +export interface OrganizationQuickLinkSettings extends QuickLinkSettingsBase { + readonly projectId: null; +} + +export interface ProjectQuickLinkSettings extends QuickLinkSettingsBase { + readonly projectId: string; +} + +export interface UpdateQuickLinkSettingsInput { + readonly enabled?: boolean; + readonly allowedRedirectUris?: readonly string[]; + readonly businessName?: string | null; + readonly headline?: string | null; + readonly description?: string | null; + readonly successMessage?: string | null; + readonly supportUrl?: string | null; + readonly privacyUrl?: string | null; + readonly termsUrl?: string | null; + readonly accent?: string | null; + readonly theme?: QuickLinkTheme; + readonly hideWatermark?: boolean; + readonly shape?: QuickLinkShape | null; + readonly radiusPx?: number | null; + readonly logoMode?: QuickLinkLogoMode; + readonly logoStorageId?: string | null; + readonly logoSourceStorageId?: string | null; + readonly historySync?: QuickLinkHistorySync; + readonly methods?: readonly QuickLinkMethod[] | null; + readonly defaultMethod?: QuickLinkMethod | null; +} + +type QuickLinkSettingsFor = O extends "project" + ? ProjectQuickLinkSettings + : OrganizationQuickLinkSettings; + +/** Saved QuickLink configuration bound to the client's ownership context. */ +export class QuickLinkSettingsResource { + constructor( + private readonly transport: HttpTransport, + private readonly projectId: O extends "project" ? string : null, + ) {} + + retrieve( + options: RequestOptions = {}, + ): Promise | null>> { + return this.transport + .request | null>>({ + method: "GET", + path: "/v1/quicklink", + ...(this.projectId === null + ? {} + : { query: { projectId: this.projectId } }), + ...options, + }) + .then(unwrapResponse); + } + + update( + input: UpdateQuickLinkSettingsInput = {}, + options: RequestOptions = {}, + ): Promise>> { + const body = + this.projectId === null ? input : { ...input, projectId: this.projectId }; + return this.transport + .request>>({ + method: "PUT", + path: "/v1/quicklink", + body, + ...options, + }) + .then(unwrapResponse); + } +} diff --git a/packages/typescript/src/platform/response.ts b/packages/typescript/src/platform/response.ts new file mode 100644 index 0000000..7f0fe8d --- /dev/null +++ b/packages/typescript/src/platform/response.ts @@ -0,0 +1,66 @@ +import { PolymorfaServerError } from "../errors.js"; +import type { ApiResponse, ResponseMetadata } from "../transport/types.js"; + +export interface DataEnvelope { + readonly data: T; +} + +export function unwrapResponse( + response: ApiResponse>, +): ApiResponse { + if ( + typeof response.data !== "object" || + response.data === null || + !Object.hasOwn(response.data, "data") || + response.data.data === undefined + ) { + throw new PolymorfaServerError( + "The Polymorfa API returned an invalid response envelope.", + { + code: "invalid_response", + status: response.metadata.status, + ...(response.metadata.requestId === undefined + ? {} + : { requestId: response.metadata.requestId }), + metadata: response.metadata, + details: response.data, + }, + ); + } + return Object.freeze({ + data: response.data.data, + metadata: response.metadata, + }); +} + +export function decodeCursorPage( + data: unknown, + metadata: ResponseMetadata, +): { + readonly items: readonly T[]; + readonly nextCursor?: string | null; +} { + const envelope = data as { + readonly data?: readonly T[]; + readonly page?: { readonly nextCursor?: string | null }; + }; + if (!Array.isArray(envelope?.data)) { + throw new PolymorfaServerError( + "The Polymorfa API returned an invalid collection envelope.", + { + code: "invalid_response", + status: metadata.status, + ...(metadata.requestId === undefined + ? {} + : { requestId: metadata.requestId }), + metadata, + details: data, + }, + ); + } + const nextCursor = envelope.page?.nextCursor; + return { + items: envelope.data, + ...(nextCursor === undefined ? {} : { nextCursor }), + }; +} diff --git a/packages/typescript/src/platform/sessions.ts b/packages/typescript/src/platform/sessions.ts index 35c0b4a..53b2cce 100644 --- a/packages/typescript/src/platform/sessions.ts +++ b/packages/typescript/src/platform/sessions.ts @@ -11,6 +11,7 @@ import type { SessionBatchStopResult, SessionProjectContext, SessionRemoveResult, + SessionStartResult, SessionStopResult, SessionTierOverrideRequest, } from "./types.js"; @@ -32,6 +33,19 @@ export class PlatformSessionsResource { }); } + start( + sessionId: string, + body: SessionProjectContext = {}, + options: RequestOptions = {}, + ): Promise>> { + return this.transport.request({ + method: "POST", + path: `${sessionPath(sessionId)}/start`, + body, + ...options, + }); + } + stop( sessionId: string, body: SessionProjectContext = {}, diff --git a/packages/typescript/src/platform/types.ts b/packages/typescript/src/platform/types.ts index 3c57d10..65a06f4 100644 --- a/packages/typescript/src/platform/types.ts +++ b/packages/typescript/src/platform/types.ts @@ -334,28 +334,6 @@ export interface SecurityIncidentAcknowledgement { readonly acknowledged: true; } -export type ManagementOperationStatus = - | "pending" - | "running" - | "action_required" - | "succeeded" - | "failed" - | "cancelled"; - -export interface ManagementOperation { - readonly id: string; - readonly kind: string; - readonly resourceType: string; - readonly resourceId: string; - readonly projectId: string | null; - readonly status: ManagementOperationStatus; - readonly progressCode: string | null; - readonly failureCode: string | null; - readonly createdAt: string; - readonly updatedAt: string; - readonly completedAt: string | null; -} - /** Project-token metadata. The bearer token itself is never returned. */ export interface ProjectToken { readonly id: string; @@ -470,6 +448,11 @@ export interface SessionProjectContext { readonly projectId?: string; } +export interface SessionStartResult { + readonly starting: true; + readonly sessionId: string; +} + export interface SessionStopResult { readonly stopping: true; readonly sessionId: string; @@ -506,84 +489,3 @@ export interface CreateTestingSessionRequest { readonly name?: string; readonly country?: "US" | "GB" | "BR" | "IN"; } - -export type WidgetMode = "embedded" | "redirect"; -export type WidgetMethod = "qr" | "pairing" | "cloud-api"; -export type WidgetTheme = "light" | "dark" | "system"; -export type WidgetShape = "square" | "rounded" | "pill"; -export type WidgetLogoMode = "none" | "custom" | "organization" | "project"; -export type WidgetHistorySync = "ask" | "force_on" | "force_off"; - -export interface WidgetColorPalette { - readonly background?: string; - readonly foreground?: string; - readonly card?: string; - readonly cardForeground?: string; - readonly primary?: string; - readonly primaryForeground?: string; - readonly muted?: string; - readonly mutedForeground?: string; - readonly border?: string; - readonly accent?: string; - readonly destructive?: string; - readonly ring?: string; -} - -export interface WidgetColors { - readonly light?: WidgetColorPalette; - readonly dark?: WidgetColorPalette; -} - -/** Saved widget configuration returned by the live Platform repository. */ -export interface WidgetSettings { - readonly id: string; - readonly projectId: string | null; - readonly enabled: boolean; - readonly modesAllowed: readonly WidgetMode[]; - readonly allowedRedirectUris: readonly string[]; - readonly allowedOrigins: readonly string[]; - readonly businessName: string | null; - readonly accent: string | null; - readonly theme: WidgetTheme; - readonly hideWatermark: boolean; - readonly colors: WidgetColors | null; - readonly shape: WidgetShape | null; - readonly radiusPx: number | null; - readonly qrLogoMode: WidgetLogoMode; - readonly qrLogoStorageId: string | null; - readonly qrLogoSourceStorageId: string | null; - readonly historySync: WidgetHistorySync; - readonly methods: readonly WidgetMethod[] | null; - readonly createdAt: number; - readonly updatedAt: number; -} - -export interface RetrieveWidgetSettingsParams { - readonly projectId?: string; -} - -/** - * Partial settings update accepted by `PUT /v1/widget`. - * - * URL, palette, uniqueness, and numeric bounds are enforced by the API. Empty - * `businessName` strings are normalized to `null` by the live handler. - */ -export interface UpdateWidgetSettingsRequest { - readonly projectId?: string; - readonly enabled?: boolean; - readonly modesAllowed?: readonly WidgetMode[]; - readonly allowedRedirectUris?: readonly string[]; - readonly allowedOrigins?: readonly string[]; - readonly businessName?: string | null; - readonly accent?: string | null; - readonly theme?: WidgetTheme; - readonly hideWatermark?: boolean; - readonly colors?: WidgetColors | null; - readonly shape?: WidgetShape | null; - readonly radiusPx?: number | null; - readonly qrLogoMode?: WidgetLogoMode; - readonly qrLogoStorageId?: string | null; - readonly qrLogoSourceStorageId?: string | null; - readonly historySync?: WidgetHistorySync; - readonly methods?: readonly WidgetMethod[] | null; -} diff --git a/packages/typescript/src/platform/widget-settings.ts b/packages/typescript/src/platform/widget-settings.ts deleted file mode 100644 index 157ab28..0000000 --- a/packages/typescript/src/platform/widget-settings.ts +++ /dev/null @@ -1,39 +0,0 @@ -import { HttpTransport } from "../transport/http.js"; -import type { ApiResponse, RequestOptions } from "../transport/types.js"; -import type { - DataEnvelope, - RetrieveWidgetSettingsParams, - UpdateWidgetSettingsRequest, - WidgetSettings, -} from "./types.js"; - -/** Organization-key management of saved Connect widget configuration. */ -export class WidgetSettingsResource { - constructor(private readonly transport: HttpTransport) {} - - retrieve( - params: RetrieveWidgetSettingsParams = {}, - options: RequestOptions = {}, - ): Promise>> { - return this.transport.request({ - method: "GET", - path: "/v1/widget", - ...(params.projectId === undefined - ? {} - : { query: { projectId: params.projectId } }), - ...options, - }); - } - - update( - body: UpdateWidgetSettingsRequest = {}, - options: RequestOptions = {}, - ): Promise>> { - return this.transport.request({ - method: "PUT", - path: "/v1/widget", - body, - ...options, - }); - } -} diff --git a/packages/typescript/src/raw.ts b/packages/typescript/src/raw.ts index 831fbf9..e1b0a0d 100644 --- a/packages/typescript/src/raw.ts +++ b/packages/typescript/src/raw.ts @@ -1,5 +1,6 @@ import { CursorPage, type PageDecoder } from "./pagination.js"; import { HttpTransport } from "./transport/http.js"; +import { PolymorfaValidationError } from "./errors.js"; import type { ApiResponse, RawRequest } from "./transport/types.js"; export class RawClient { @@ -35,7 +36,7 @@ export class RawClient { ...request, ...(query === undefined ? {} : { query }), }); - const decoded = decode(response.data); + const decoded = decode(response.data, response.metadata); const nextCursor = decoded.nextCursor ?? undefined; return new CursorPage({ items: decoded.items, @@ -50,3 +51,76 @@ export class RawClient { }); } } + +export interface ProjectScopedRawClient { + request(request: RawRequest): Promise>; + paginate( + request: RawRequest, + decode: PageDecoder, + options?: { readonly cursorParameter?: string }, + ): Promise>; +} + +export class ConfinedProjectRawClient implements ProjectScopedRawClient { + readonly #raw: RawClient; + readonly #prefix: string; + + constructor(transport: HttpTransport, projectId: string) { + this.#raw = new RawClient(transport); + this.#prefix = `/v1/projects/${encodeURIComponent(projectId)}`; + } + + async request(request: RawRequest): Promise> { + return this.#raw.request(this.#confine(request)); + } + + async paginate( + request: RawRequest, + decode: PageDecoder, + options: { readonly cursorParameter?: string } = {}, + ): Promise> { + return this.#raw.paginate(this.#confine(request), decode, options); + } + + #confine(request: RawRequest): RawRequest { + validateProjectRelativeRequest(request); + return { ...request, path: `${this.#prefix}${request.path}` }; + } +} + +function validateProjectRelativeRequest(request: RawRequest): void { + const path = request.path; + let decoded = path; + try { + decoded = decodeURIComponent(path); + } catch { + throw invalidProjectRawPath(); + } + if ( + !path.startsWith("/") || + path.startsWith("//") || + path.includes("\\") || + URL.canParse(path) || + decoded.split("/").includes("..") || + decoded.startsWith("/v1/projects/") + ) { + throw invalidProjectRawPath(); + } + if ( + Object.keys(request.headers ?? {}).some( + (name) => name.toLowerCase() === "authorization", + ) + ) { + throw new PolymorfaValidationError( + "Project raw requests cannot override Authorization.", + { code: "authorization_override_forbidden" }, + ); + } +} + +function invalidProjectRawPath(): PolymorfaValidationError { + return new PolymorfaValidationError( + "Project raw paths must be relative to the bound project.", + { code: "invalid_project_request_path" }, + ); +} diff --git a/packages/typescript/src/system.ts b/packages/typescript/src/system.ts new file mode 100644 index 0000000..473018e --- /dev/null +++ b/packages/typescript/src/system.ts @@ -0,0 +1,83 @@ +import type { SharedClientOptions } from "./credentials.js"; +import { HttpTransport } from "./transport/http.js"; +import type { ApiResponse, RequestOptions } from "./transport/types.js"; + +export type SystemClientOptions = SharedClientOptions; + +export interface StatusResponse { + readonly status: string; + readonly uptime: string; + readonly version: string; + readonly env: string; +} + +export interface VersionResponse { + readonly version: string; + readonly buildTime: string; + readonly env: string; + readonly apiVersion: string; + readonly minSupportedVersion: string; +} + +export interface HealthCheck { + readonly status: string; + readonly error?: string; +} + +export interface HealthResponse { + readonly status: string; + readonly checks: Readonly>; +} + +export interface PingResponse { + readonly status: string; +} + +/** Credential-free API version, liveness, and readiness probes. */ +export class SystemClient { + readonly #transport: HttpTransport; + + constructor(options: SystemClientOptions = {}) { + this.#transport = new HttpTransport({ + baseUrl: options.baseUrl ?? "https://api.polymorfa.com", + timeoutMs: options.timeoutMs ?? 30_000, + maxNetworkRetries: options.maxNetworkRetries ?? 2, + ...(options.apiVersion === undefined + ? {} + : { apiVersion: options.apiVersion }), + ...(options.fetch === undefined ? {} : { fetch: options.fetch }), + }); + } + + status(options: RequestOptions = {}): Promise> { + return this.#transport.request({ + method: "GET", + path: "/api/info/status", + ...options, + }); + } + + version(options: RequestOptions = {}): Promise> { + return this.#transport.request({ + method: "GET", + path: "/api/info/version", + ...options, + }); + } + + health(options: RequestOptions = {}): Promise> { + return this.#transport.request({ + method: "GET", + path: "/health", + ...options, + }); + } + + ping(options: RequestOptions = {}): Promise> { + return this.#transport.request({ + method: "GET", + path: "/ping", + ...options, + }); + } +} diff --git a/packages/typescript/src/transport/http.ts b/packages/typescript/src/transport/http.ts index 89f2438..01192dc 100644 --- a/packages/typescript/src/transport/http.ts +++ b/packages/typescript/src/transport/http.ts @@ -3,6 +3,7 @@ import { PolymorfaAuthorizationError, PolymorfaCancelledError, PolymorfaConflictError, + PolymorfaConfigurationError, PolymorfaConnectionError, PolymorfaError, PolymorfaNotFoundError, @@ -29,7 +30,7 @@ import type { export class HttpTransport { readonly #baseUrl: string; - readonly #authorization: string; + readonly #authorization: string | undefined; readonly #apiVersion: string | undefined; readonly #timeoutMs: number; readonly #maxNetworkRetries: number; @@ -41,7 +42,7 @@ export class HttpTransport { readonly #random: () => number; constructor(options: TransportOptions) { - this.#baseUrl = options.baseUrl.replace(/\/+$/, ""); + this.#baseUrl = validateBaseUrl(options.baseUrl, options.authorization); this.#authorization = options.authorization; this.#apiVersion = options.apiVersion; this.#timeoutMs = assertNonNegativeInteger( @@ -164,7 +165,9 @@ export class HttpTransport { const url = requestUrl(this.#baseUrl, request.path, request.query); const headers = new Headers(request.headers); if (!headers.has("accept")) headers.set("accept", accept); - headers.set("authorization", this.#authorization); + if (this.#authorization !== undefined) { + headers.set("authorization", this.#authorization); + } headers.set("user-agent", `polymorfa-node/${SDK_VERSION}`); const apiVersion = request.apiVersion ?? this.#apiVersion; if (apiVersion !== undefined) { @@ -200,6 +203,7 @@ export class HttpTransport { const response = await this.#fetch(url, { method: request.method, headers, + redirect: this.#authorization === undefined ? "follow" : "error", ...(encoded.body === undefined ? {} : { body: encoded.body }), signal: controller.signal, }); @@ -216,6 +220,49 @@ export class HttpTransport { } } +function validateBaseUrl( + value: string, + authorization: string | undefined, +): string { + let url: URL; + try { + url = new URL(value); + } catch { + throw new PolymorfaConfigurationError( + "baseUrl must be an absolute HTTP or HTTPS URL.", + "baseUrl", + ); + } + if (url.username !== "" || url.password !== "") { + throw new PolymorfaConfigurationError( + "baseUrl must not contain credentials.", + "baseUrl", + ); + } + const isLoopbackHttp = + url.protocol === "http:" && + (url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "[::1]"); + if ( + authorization !== undefined && + url.protocol !== "https:" && + !isLoopbackHttp + ) { + throw new PolymorfaConfigurationError( + "Credentialed clients require HTTPS, except for loopback development servers.", + "baseUrl", + ); + } + if (url.protocol !== "http:" && url.protocol !== "https:") { + throw new PolymorfaConfigurationError( + "baseUrl must use HTTP or HTTPS.", + "baseUrl", + ); + } + return value.replace(/\/+$/, ""); +} + class RequestTimeout extends Error { constructor( readonly timeoutMs: number, @@ -262,9 +309,10 @@ function responseMetadata( attempts: number, ): ResponseMetadata { const headerRecord: Record = {}; - response.headers.forEach((value, key) => { - headerRecord[key] = value; - }); + for (const key of SAFE_RESPONSE_HEADERS) { + const value = response.headers.get(key); + if (value !== null) headerRecord[key] = value; + } const requestId = response.headers.get("x-request-id") ?? response.headers.get("request-id") ?? @@ -279,6 +327,15 @@ function responseMetadata( }); } +const SAFE_RESPONSE_HEADERS = [ + "content-type", + "x-request-id", + "polymorfa-version", + "retry-after", + "x-ratelimit-limit", + "x-ratelimit-remaining", +] as const; + function apiError( response: Response, body: unknown, diff --git a/packages/typescript/src/transport/types.ts b/packages/typescript/src/transport/types.ts index de34eea..8518078 100644 --- a/packages/typescript/src/transport/types.ts +++ b/packages/typescript/src/transport/types.ts @@ -36,7 +36,7 @@ export interface ApiResponse { export interface TransportOptions { readonly baseUrl: string; - readonly authorization: string; + readonly authorization?: string; readonly apiVersion?: string; readonly timeoutMs: number; readonly maxNetworkRetries: number; diff --git a/packages/typescript/src/webhooks/index.ts b/packages/typescript/src/webhooks/index.ts index f72c245..bf1de9c 100644 --- a/packages/typescript/src/webhooks/index.ts +++ b/packages/typescript/src/webhooks/index.ts @@ -57,3 +57,12 @@ export { verifyWebhookSignature, type WebhookBody, } from "./verify.js"; +export { + webhooks, + type CreateWebhookFixtureInput, + type VerifyLocalWebhookInput, + type VerifyWebhookInput, + type VerifyWebhookSignatureInput, + type WebhookFixture, + type WebhookUtilities, +} from "./utilities.js"; diff --git a/packages/typescript/src/webhooks/utilities.ts b/packages/typescript/src/webhooks/utilities.ts new file mode 100644 index 0000000..9ebeb19 --- /dev/null +++ b/packages/typescript/src/webhooks/utilities.ts @@ -0,0 +1,103 @@ +import { createHmac, timingSafeEqual } from "node:crypto"; + +import type { WebhookEvent } from "./events.js"; +import { + WebhookSignatureError, + constructWebhookEvent, + verifyWebhookSignature, + type WebhookBody, +} from "./verify.js"; + +export interface VerifyWebhookInput { + readonly body: WebhookBody; + readonly signature: string; + readonly secret: string; +} +export type VerifyWebhookSignatureInput = VerifyWebhookInput; +export interface VerifyLocalWebhookInput extends VerifyWebhookInput { + readonly toleranceSeconds?: number; + readonly nowUnixSeconds?: number; +} +export interface CreateWebhookFixtureInput { + readonly event: WebhookEvent; + readonly secret: string; +} +export interface WebhookFixture { + readonly body: Uint8Array; + readonly contentType: "application/json"; + readonly signature: string; + readonly headers: Readonly<{ + readonly "content-type": "application/json"; + readonly "x-webhook-signature": string; + }>; +} +export interface WebhookUtilities { + verify(input: VerifyWebhookInput): Promise; + verifySignature(input: VerifyWebhookSignatureInput): Promise; + verifyLocal(input: VerifyLocalWebhookInput): Promise; + createFixture(input: CreateWebhookFixtureInput): Promise; +} + +export const webhooks: WebhookUtilities = Object.freeze({ + verify: (input: VerifyWebhookInput) => + constructWebhookEvent(input.body, input.signature, input.secret), + verifySignature: (input: VerifyWebhookSignatureInput) => + verifyWebhookSignature(input.body, input.signature, input.secret), + async verifyLocal(input: VerifyLocalWebhookInput) { + const secret = decodeLocalSecret(input.secret); + if (!verifyLocalSignature(input, secret)) throw new WebhookSignatureError(); + const verificationSecret = "local-verification-complete"; + const body = toBytes(input.body); + const signature = createHmac("sha256", verificationSecret) + .update(body) + .digest("hex"); + return constructWebhookEvent(body, signature, verificationSecret); + }, + async createFixture(input: CreateWebhookFixtureInput) { + const body = new TextEncoder().encode(JSON.stringify(input.event)); + const secret = input.secret; + const signature = createHmac("sha256", secret).update(body).digest("hex"); + return Object.freeze({ + body, + contentType: "application/json", + signature, + headers: Object.freeze({ + "content-type": "application/json", + "x-webhook-signature": signature, + }), + }); + }, +}); + +function verifyLocalSignature( + input: VerifyLocalWebhookInput, + secret: Uint8Array, +): boolean { + const match = /^t=(\d+),v1=([a-f0-9]{64})$/.exec(input.signature); + if (match === null) return false; + const timestamp = Number(match[1]); + if (!Number.isSafeInteger(timestamp)) return false; + const now = input.nowUnixSeconds ?? Math.floor(Date.now() / 1_000); + const tolerance = input.toleranceSeconds ?? 300; + if (!Number.isSafeInteger(tolerance) || tolerance < 0) return false; + if (Math.abs(now - timestamp) > tolerance) return false; + const expected = createHmac("sha256", secret) + .update(new TextEncoder().encode(`${timestamp}.`)) + .update(toBytes(input.body)) + .digest(); + const actual = Buffer.from(match[2] ?? "", "hex"); + return actual.length === expected.length && timingSafeEqual(actual, expected); +} + +function decodeLocalSecret(value: string): Uint8Array { + if (!/^[A-Za-z0-9_-]{43}$/.test(value)) throw new WebhookSignatureError(); + const bytes = Buffer.from(value, "base64url"); + if (bytes.length !== 32) throw new WebhookSignatureError(); + return bytes; +} + +function toBytes(body: WebhookBody): Uint8Array { + if (typeof body === "string") return new TextEncoder().encode(body); + if (body instanceof ArrayBuffer) return new Uint8Array(body); + return new Uint8Array(body.buffer, body.byteOffset, body.byteLength); +} diff --git a/packages/typescript/test/bridge.test.ts b/packages/typescript/test/bridge.test.ts new file mode 100644 index 0000000..6e70b84 --- /dev/null +++ b/packages/typescript/test/bridge.test.ts @@ -0,0 +1,77 @@ +import { describe, expect, expectTypeOf, it, vi } from "vitest"; + +import { + BridgeClient, + PolymorfaConfigurationError, + type ApiResponse, + type BridgeRoute, +} from "../src/index.js"; + +describe("BridgeClient", () => { + it("resolves a bridge route using only a project token", async () => { + const route: BridgeRoute = { + wsUrl: "wss://bridge.example.com/connect", + region: "US", + kind: "production", + signal: "customer", + tokenKind: "project", + expiresAt: 1_788_825_600_000, + }; + const fetch = vi.fn(async () => + Response.json(route), + ); + const client = new BridgeClient({ + credential: { type: "projectToken", value: "pmfa_pt_bridge" }, + baseUrl: "https://api.example.com", + fetch, + }); + + const response = await client.routes.resolve(); + + expectTypeOf(response).toEqualTypeOf>(); + expect(response.data).toEqual(route); + expect(fetch).toHaveBeenCalledTimes(1); + const [url, init] = fetch.mock.calls[0]!; + expect(new URL(String(url)).pathname).toBe("/v1/bridge/route"); + expect(new Headers(init?.headers).get("authorization")).toBe( + "Bearer pmfa_pt_bridge", + ); + }); + + it("rejects listener credentials before transport", () => { + const fetch = vi.fn(); + expect( + () => + new BridgeClient({ + credential: { type: "projectToken", value: "pmfa_ls_listener" }, + fetch, + }), + ).toThrow(PolymorfaConfigurationError); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("rejects organization credentials supplied by untyped callers", () => { + const fetch = vi.fn(); + expect( + () => + new BridgeClient({ + credential: { + type: "organizationApiKey", + value: "pmfa_organization", + }, + fetch, + } as never), + ).toThrow(PolymorfaConfigurationError); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("rejects cleartext non-loopback API origins", () => { + expect( + () => + new BridgeClient({ + credential: { type: "projectToken", value: "pmfa_pt_bridge" }, + baseUrl: "http://api.example.com", + }), + ).toThrow(PolymorfaConfigurationError); + }); +}); diff --git a/packages/typescript/test/calls-lids-users.test.ts b/packages/typescript/test/calls-lids-users.test.ts index a29e71e..27efd61 100644 --- a/packages/typescript/test/calls-lids-users.test.ts +++ b/packages/typescript/test/calls-lids-users.test.ts @@ -203,7 +203,7 @@ describe("MessagingClient compact Calls, LIDs, and Users surfaces", () => { }, metadata: { requestId: "req_compact_surface" }, }); - expect(result.metadata.headers["cache-control"]).toBe("private, no-store"); + expect(result.metadata.headers).not.toHaveProperty("cache-control"); expect(result.data.data).not.toHaveProperty("verificationQRCode"); expect(requests).toHaveLength(1); expect(requests[0]).toMatchObject({ diff --git a/packages/typescript/test/client.test.ts b/packages/typescript/test/client.test.ts new file mode 100644 index 0000000..cf0086f --- /dev/null +++ b/packages/typescript/test/client.test.ts @@ -0,0 +1,231 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; + +import { + Client, + MessagingClient, + PolymorfaConfigurationError, + PolymorfaValidationError, + type OrganizationEvent, + type OrganizationQuickLinkSettings, + type ProjectEvent, + type ProjectQuickLinkSettings, +} from "../src/index.js"; +import { + startTestServer, + type RecordedRequest, + type TestServer, +} from "./support/http-server.js"; + +const servers: TestServer[] = []; + +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())); +}); + +async function testClient(projectId?: string): Promise<{ + client: Client<"organization"> | Client<"project">; + requests: RecordedRequest[]; +}> { + const server = await startTestServer(({ path }) => ({ + headers: { + "content-type": "application/json", + "x-request-id": "req_client", + }, + body: + path.includes("/transitions") || path.includes("/events?") + ? '{"data":[],"page":{"nextCursor":null,"hasMore":false}}' + : '{"data":{"id":"resource_1","status":"succeeded","sequence":1,"capabilities":{"cancellable":false,"watchable":false}}}', + })); + servers.push(server); + const shared = { + baseUrl: server.url, + maxNetworkRetries: 0, + } as const; + return { + requests: server.requests, + client: + projectId === undefined + ? new Client({ + ...shared, + credential: { type: "organizationApiKey", value: "pmfa_org" }, + }) + : new Client({ + ...shared, + credential: { type: "projectToken", value: "pmfa_pt_project" }, + projectId, + }), + }; +} + +describe("Client ownership", () => { + it("binds organization roots and immutable project views without probing", async () => { + const { client, requests } = await testClient(); + if (client.owner !== "organization") throw new Error("unexpected owner"); + expect(client.projectId).toBeNull(); + expectTypeOf(client.events.retrieve).returns.resolves.toHaveProperty( + "data", + ); + + const project = client.project("project/one"); + expect(project.owner).toBe("project"); + expect(project.projectId).toBe("project/one"); + expectTypeOf< + Awaited>["data"] + >().toEqualTypeOf(); + expectTypeOf< + Awaited>["data"] + >().toEqualTypeOf(); + + await client.events.retrieve("event/org"); + await project.events.retrieve("event/project"); + expect(requests.map(({ path }) => path)).toEqual([ + "/v1/events/event%2Forg", + "/v1/projects/project%2Fone/events/event%2Fproject", + ]); + }); + + it("requires explicit project binding and rejects listener credentials before fetch", () => { + const fetch = vi.fn(); + expect( + () => + new (Client as unknown as new (options: unknown) => Client<"project">)({ + credential: { type: "projectToken", value: "pmfa_pt_project" }, + projectId: undefined, + fetch, + }), + ).toThrow(PolymorfaConfigurationError); + expect( + () => + new Client({ + credential: { type: "organizationApiKey", value: "pmfa_ls_once" }, + fetch, + }), + ).toThrow(PolymorfaConfigurationError); + expect( + () => + new MessagingClient({ + credential: { type: "apiKey", value: "pmfa_ls_once" }, + fetch, + }), + ).toThrow(PolymorfaConfigurationError); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("treats an explicitly undefined organization projectId as unscoped", () => { + const client = new (Client as unknown as new (options: unknown) => Client)({ + credential: { type: "organizationApiKey", value: "pmfa_org" }, + projectId: undefined, + }); + + expect(client.owner).toBe("organization"); + expect(client.projectId).toBeNull(); + }); + + it("rejects a second project binding for an opaque project token before fetch", async () => { + const { client, requests } = await testClient("project_1"); + expect(client.project("project_1")).toBe(client); + expect(() => client.project("project_2")).toThrow( + PolymorfaConfigurationError, + ); + expect(requests).toEqual([]); + }); +}); + +describe("project raw confinement", () => { + it("prefixes safe relative paths and rejects escape attempts before fetch", async () => { + const { client, requests } = await testClient("project/a"); + await client.raw.request({ method: "GET", path: "/custom" }); + expect(requests[0]?.path).toBe("/v1/projects/project%2Fa/custom"); + + for (const path of [ + "https://evil.test/x", + "//evil.test/x", + "/../events", + "/v1/projects/project_2/events", + "/\\evil.test/x", + ]) { + await expect( + client.raw.request({ method: "GET", path }), + ).rejects.toBeInstanceOf(PolymorfaValidationError); + } + await expect( + client.raw.request({ + method: "GET", + path: "/events", + headers: { Authorization: "Bearer stolen" }, + }), + ).rejects.toBeInstanceOf(PolymorfaValidationError); + expect(requests).toHaveLength(1); + }); +}); + +describe("durable developer resources", () => { + it("uses exact project routes for events, deliveries, attempts, and operations", async () => { + const { client, requests } = await testClient("project/a"); + await client.events.list({ type: "message.received", limit: 10 }); + await client.events.replay( + "event/a", + { webhookId: "webhook/a" }, + { idempotencyKey: "replay-1" }, + ); + await client.webhookDeliveries.retrieveAttempt("delivery/a", "attempt/a"); + await client.operations.listTransitions("operation/a", { + afterSequence: 4, + }); + await client.operations.cancel("operation/a", { + idempotencyKey: "cancel-1", + }); + + expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + "GET /v1/projects/project%2Fa/events?type=message.received&limit=10", + "POST /v1/projects/project%2Fa/events/event%2Fa/replays", + "GET /v1/projects/project%2Fa/webhook-deliveries/delivery%2Fa/attempts/attempt%2Fa", + "GET /v1/projects/project%2Fa/operations/operation%2Fa/transitions?afterSequence=4", + "POST /v1/projects/project%2Fa/operations/operation%2Fa/cancel", + ]); + expect(requests[1]?.body).toBe('{"webhookId":"webhook/a"}'); + expect(requests[1]?.headers["idempotency-key"]).toBe("replay-1"); + }); +}); + +describe("QuickLink settings", () => { + it("binds settings reads and updates to the client ownership context", async () => { + const { client, requests } = await testClient(); + if (client.owner !== "organization") throw new Error("unexpected owner"); + const project = client.project("project/a"); + + expectTypeOf< + Awaited>["data"] + >().toEqualTypeOf(); + expectTypeOf< + Awaited>["data"] + >().toEqualTypeOf(); + + await client.quickLinkSettings.retrieve({ apiVersion: "1.0.0" }); + await project.quickLinkSettings.retrieve(); + await client.quickLinkSettings.update( + { enabled: true, methods: ["qr", "pairing"] }, + { idempotencyKey: "quicklink-org-1" }, + ); + await project.quickLinkSettings.update( + { headline: "Connect your number", defaultMethod: "qr" }, + { idempotencyKey: "quicklink-project-1" }, + ); + + expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ + "GET /v1/quicklink", + "GET /v1/quicklink?projectId=project%2Fa", + "PUT /v1/quicklink", + "PUT /v1/quicklink", + ]); + expect(requests[0]?.headers["polymorfa-version"]).toBe("1.0.0"); + expect(requests[2]?.body).toBe( + '{"enabled":true,"methods":["qr","pairing"]}', + ); + expect(requests[3]?.body).toBe( + '{"headline":"Connect your number","defaultMethod":"qr","projectId":"project/a"}', + ); + expect(requests[2]?.headers["idempotency-key"]).toBe("quicklink-org-1"); + expect(requests[3]?.headers["idempotency-key"]).toBe("quicklink-project-1"); + }); +}); diff --git a/packages/typescript/test/coverage-reconciliation.test.ts b/packages/typescript/test/coverage-reconciliation.test.ts index 5d0c5f8..01e1153 100644 --- a/packages/typescript/test/coverage-reconciliation.test.ts +++ b/packages/typescript/test/coverage-reconciliation.test.ts @@ -3,7 +3,7 @@ import { readFileSync } from "node:fs"; import { describe, expect, it, vi } from "vitest"; import { HttpCallsApi } from "../../calls/src/index.js"; -import { PlatformClient } from "../src/index.js"; +import { Client } from "../src/index.js"; type LedgerEntry = { family: string; @@ -163,45 +163,44 @@ describe("reconciled coverage evidence", () => { }, ); - it("does not transfer obsolete widget settings coverage to QuickLink", async () => { + it("covers QuickLink settings through the exact management routes", async () => { const fetch = vi.fn(async () => Response.json({ data: {} })); - const client = new PlatformClient({ apiKey: "pmfa_coverage", fetch }); - await client.widgetSettings.retrieve(); - await client.widgetSettings.update({}); + const client = new Client({ + credential: { type: "organizationApiKey", value: "pmfa_coverage" }, + fetch, + }); + await client.quickLinkSettings.retrieve(); + await client.quickLinkSettings.update({}); expect(fetch.mock.calls).toHaveLength(2); for (const call of fetch.mock.calls) { const [url] = call as unknown as [string]; - expect(new URL(url).pathname).toBe("/v1/widget"); + expect(new URL(url).pathname).toBe("/v1/quicklink"); } - for (const operationId of [ - "getQuickLinkSettings", - "updateQuickLinkSettings", - ]) { - expect(entry(operationId).typescript).toMatchObject({ - status: "missing", - reason: expect.stringContaining("/v1/widget"), - }); - } - expect( - ledger.operations.filter(({ path }) => /\/widget(?:\/|$)/.test(path)), - ).toEqual([]); + expect(entry("getQuickLinkSettings").typescript).toEqual({ + status: "covered", + method: "Client.quickLinkSettings.retrieve", + }); + expect(entry("updateQuickLinkSettings").typescript).toEqual({ + status: "covered", + method: "Client.quickLinkSettings.update", + }); }); - it("keeps unimplemented durable Platform resources explicitly missing", () => { + it("covers every durable Platform developer resource", () => { const operations = ledger.operations.filter( ({ family, path, operationId }) => family === "platform" && /^\/v1\/(?:projects\/\{projectId\}\/)?(?:events|operations|webhooks|webhook-deliveries)(?:\/|$)/.test( path, ) && - operationId !== "getOrganizationOperation", + operationId.length > 0, ); - expect(operations).toHaveLength(37); + expect(operations).toHaveLength(38); for (const operation of operations) { expect(operation.typescript.status, operation.operationId).toBe( - "missing", + "covered", ); - expect(operation.typescript.method).toBeUndefined(); + expect(operation.typescript.method).toMatch(/^Client\./); } expect( ledger.operations.some( diff --git a/packages/typescript/test/coverage.test.ts b/packages/typescript/test/coverage.test.ts index c9d5bab..96bd8e1 100644 --- a/packages/typescript/test/coverage.test.ts +++ b/packages/typescript/test/coverage.test.ts @@ -7,7 +7,12 @@ import { spawnSync } from "node:child_process"; import { describe, expect, it } from "vitest"; import { HttpCallsApi } from "../../calls/src/index.js"; import { BrowserMessagingClient } from "../../browser/src/index.js"; -import { MessagingClient, PlatformClient } from "../src/index.js"; +import { + BridgeClient, + Client, + MessagingClient, + SystemClient, +} from "../src/index.js"; const checker = fileURLToPath( new URL("../../../scripts/check-coverage.mjs", import.meta.url), @@ -147,10 +152,10 @@ describe("coverage checker", () => { expect(result.report).toMatchObject({ sourceCommit: "8c244aab0e5626d101a2c8c4915287427f39e014", total: 402, - covered: 226, + covered: 271, partial: 0, - missing: 103, - excluded: 73, + missing: 0, + excluded: 131, changed: 0, resolutions: [], }); @@ -171,20 +176,20 @@ describe("coverage checker", () => { ); expect(customerMappings).toEqual({ - archiveCustomer: "PlatformClient.customers.archive", - createCustomer: "PlatformClient.customers.create", - createCustomerPairingLink: "PlatformClient.customers.createPairingLink", - enableCustomers: "PlatformClient.customers.enable", - getCustomer: "PlatformClient.customers.retrieve", - getCustomersStatus: "PlatformClient.customers.status", - listCustomerEvents: "PlatformClient.customers.listEvents", - listCustomerNumbers: "PlatformClient.customers.listNumbers", - listCustomerPairingLinks: "PlatformClient.customers.listPairingLinks", - listCustomers: "PlatformClient.customers.list", - restoreCustomer: "PlatformClient.customers.restore", - revokeCustomerPairingLink: "PlatformClient.customers.revokePairingLink", - transferCustomerNumber: "PlatformClient.customers.transferNumber", - updateCustomer: "PlatformClient.customers.update", + archiveCustomer: "Client.customers.archive", + createCustomer: "Client.customers.create", + createCustomerPairingLink: "Client.customers.createPairingLink", + enableCustomers: "Client.customers.enable", + getCustomer: "Client.customers.retrieve", + getCustomersStatus: "Client.customers.status", + listCustomerEvents: "Client.customers.listEvents", + listCustomerNumbers: "Client.customers.listNumbers", + listCustomerPairingLinks: "Client.customers.listPairingLinks", + listCustomers: "Client.customers.list", + restoreCustomer: "Client.customers.restore", + revokeCustomerPairingLink: "Client.customers.revokePairingLink", + transferCustomerNumber: "Client.customers.transferNumber", + updateCustomer: "Client.customers.update", }); }); @@ -240,8 +245,8 @@ describe("coverage checker", () => { ); expect(mappings).toEqual({ - deleteSessions: "PlatformClient.sessions.deleteMany", - stopSessions: "PlatformClient.sessions.stopMany", + deleteSessions: "Client.sessions.deleteMany", + stopSessions: "Client.sessions.stopMany", }); }); @@ -251,11 +256,19 @@ describe("coverage checker", () => { typescript: { status: string; method?: string }; }>; }; + const client = new Client({ + credential: { type: "organizationApiKey", value: "pmfa_platform" }, + }); + const projectClient = client.project("project_coverage"); const roots: Readonly> = { MessagingClient: new MessagingClient({ credential: { type: "apiKey", value: "pmfa_messaging" }, }), - PlatformClient: new PlatformClient({ apiKey: "pmfa_platform" }), + Client: client, + SystemClient: new SystemClient(), + BridgeClient: new BridgeClient({ + credential: { type: "projectToken", value: "pmfa_pt_bridge" }, + }), HttpCallsApi: new HttpCallsApi({ apiKey: "pmfa_calls" }), BrowserMessagingClient: new BrowserMessagingClient({ session: "coverage", @@ -266,8 +279,11 @@ describe("coverage checker", () => { for (const operation of ledger.operations) { if (operation.typescript.status !== "covered") continue; const parts = operation.typescript.method?.split(".") ?? []; - let value: unknown = roots[parts[0] ?? ""]; - for (const part of parts.slice(1)) { + const projectScoped = parts[1] === "project(projectId)"; + let value: unknown = projectScoped + ? projectClient + : roots[parts[0] ?? ""]; + for (const part of parts.slice(projectScoped ? 2 : 1)) { expect(value, operation.typescript.method).toBeTypeOf("object"); value = (value as Readonly>)[part]; } @@ -557,43 +573,43 @@ describe("coverage checker", () => { expect(mappings).toMatchObject({ deactivateApiKey: { status: "covered", - method: "PlatformClient.apiKeys.deactivate", + method: "Client.apiKeys.deactivate", }, listApiKeys: { status: "covered", - method: "PlatformClient.apiKeys.list", + method: "Client.apiKeys.list", }, listMembers: { status: "covered", - method: "PlatformClient.members.list", + method: "Client.members.list", }, listAuditLogs: { status: "covered", - method: "PlatformClient.auditLogs.list", + method: "Client.auditLogs.list", }, listSessionBans: { status: "covered", - method: "PlatformClient.sessionBans.list", + method: "Client.sessionBans.list", }, listActiveSessionBans: { status: "covered", - method: "PlatformClient.sessionBans.listActive", + method: "Client.sessionBans.listActive", }, listSecurityIncidents: { status: "covered", - method: "PlatformClient.securityIncidents.list", + method: "Client.securityIncidents.list", }, acknowledgeSecurityIncident: { status: "covered", - method: "PlatformClient.securityIncidents.acknowledge", + method: "Client.securityIncidents.acknowledge", }, getOrganizationOperation: { status: "covered", - method: "PlatformClient.operations.retrieve", + method: "Client.operations.retrieve", }, listPolymorfaTokens: { status: "covered", - method: "PlatformClient.projectTokens.list", + method: "Client.projectTokens.list", }, inviteMember: { status: "excluded" }, updateMemberRole: { status: "excluded" }, diff --git a/packages/typescript/test/credentials.test.ts b/packages/typescript/test/credentials.test.ts index b8066a6..fd58fcb 100644 --- a/packages/typescript/test/credentials.test.ts +++ b/packages/typescript/test/credentials.test.ts @@ -2,8 +2,9 @@ import { describe, expect, it } from "vitest"; import { assertServerRuntime, + validateClientCredential, validateMessagingCredential, - validatePlatformApiKey, + validateOrganizationApiKey, } from "../src/credentials.js"; import { PolymorfaConfigurationError } from "../src/errors.js"; @@ -41,13 +42,25 @@ describe("credential validation", () => { ).toThrow(/Messaging client token/); }); - it("rejects client and project tokens as Platform server keys", () => { - expect(() => validatePlatformApiKey("pmfa_ct_example")).toThrow( + it("rejects client, project, and listener tokens as organization keys", () => { + expect(() => validateOrganizationApiKey("pmfa_ct_example")).toThrow( PolymorfaConfigurationError, ); - expect(() => validatePlatformApiKey("pmfa_pt_example")).toThrow( - /Platform server API key/, + expect(() => validateOrganizationApiKey("pmfa_pt_example")).toThrow( + /Organization server API key/, ); + expect(() => validateOrganizationApiKey("pmfa_ls_example")).toThrow( + /Listener credentials/, + ); + }); + + it("accepts the single opaque project token format", () => { + expect( + validateClientCredential({ + type: "projectToken", + value: "pmfa_pt_example", + }), + ).toEqual({ type: "projectToken", value: "pmfa_pt_example" }); }); it("rejects server API keys in a browser runtime", () => { diff --git a/packages/typescript/test/customers.test.ts b/packages/typescript/test/customers.test.ts index b7a3a3e..d8e11b3 100644 --- a/packages/typescript/test/customers.test.ts +++ b/packages/typescript/test/customers.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; -import { PlatformClient } from "../src/platform/client.js"; +import { Client } from "../src/client.js"; import { startTestServer, type RecordedRequest, @@ -14,7 +14,7 @@ afterEach(async () => { }); async function customersServer(): Promise<{ - client: PlatformClient; + client: Client; requests: RecordedRequest[]; }> { const server = await startTestServer((request) => { @@ -38,15 +38,15 @@ async function customersServer(): Promise<{ servers.push(server); return { requests: server.requests, - client: new PlatformClient({ - apiKey: "pmfa_platform", + client: new Client({ + credential: { type: "organizationApiKey", value: "pmfa_platform" }, baseUrl: server.url, maxNetworkRetries: 0, }), }; } -describe("PlatformClient customers", () => { +describe("Client customers", () => { it("maps enablement, collection, and profile operations", async () => { const { client, requests } = await customersServer(); diff --git a/packages/typescript/test/developer-operations.test.ts b/packages/typescript/test/developer-operations.test.ts new file mode 100644 index 0000000..e06f0a5 --- /dev/null +++ b/packages/typescript/test/developer-operations.test.ts @@ -0,0 +1,119 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +import { + Client, + PolymorfaCancelledError, + PolymorfaTimeoutError, +} from "../src/index.js"; + +const operation = (status: "running" | "succeeded") => ({ + data: { + id: "operation_1", + organizationId: "organization_1", + projectId: null, + kind: "session_lifecycle", + resource: { type: "session", id: "session_1" }, + status, + sequence: status === "running" ? 1 : 2, + capabilities: { cancellable: true, watchable: true }, + progress: null, + result: status === "succeeded" ? { started: true } : null, + error: null, + actionRequired: null, + createdAt: "2026-09-08T00:00:00.000Z", + updatedAt: "2026-09-08T00:00:01.000Z", + completedAt: status === "succeeded" ? "2026-09-08T00:00:01.000Z" : null, + }, +}); + +afterEach(() => { + vi.useRealTimers(); +}); + +describe("Client.operations.wait", () => { + it("honors a larger server Retry-After before the next retrieval", async () => { + vi.useFakeTimers(); + const fetch = vi + .fn() + .mockResolvedValueOnce( + Response.json(operation("running"), { + headers: { "retry-after": "1" }, + }), + ) + .mockResolvedValueOnce(Response.json(operation("succeeded"))); + const client = new Client({ + credential: { type: "organizationApiKey", value: "pmfa_operations" }, + baseUrl: "https://api.example.com", + fetch, + }); + + const result = client.operations.wait("operation_1", { + maxWaitMs: 2_000, + pollIntervalMs: 250, + }); + await vi.advanceTimersByTimeAsync(999); + expect(fetch).toHaveBeenCalledTimes(1); + await vi.advanceTimersByTimeAsync(1); + + await expect(result).resolves.toMatchObject({ + data: { status: "succeeded", sequence: 2 }, + }); + expect(fetch).toHaveBeenCalledTimes(2); + }); + + it("fails locally on abort and total-wait timeout without cancelling remotely", async () => { + const fetch = vi.fn(async () => + Response.json(operation("running")), + ); + const client = new Client({ + credential: { type: "organizationApiKey", value: "pmfa_operations" }, + baseUrl: "https://api.example.com", + fetch, + }); + const controller = new AbortController(); + controller.abort(); + await expect( + client.operations.wait("operation_1", { signal: controller.signal }), + ).rejects.toBeInstanceOf(PolymorfaCancelledError); + await expect( + client.operations.wait("operation_1", { + maxWaitMs: 1, + pollIntervalMs: 250, + }), + ).rejects.toBeInstanceOf(PolymorfaTimeoutError); + expect(fetch).toHaveBeenCalledTimes(1); + expect(fetch.mock.calls[0]?.[1]?.method).toBe("GET"); + }); + + it("bounds an in-flight retrieval by the total wait deadline", async () => { + vi.useFakeTimers(); + const fetch = vi.fn( + async (_input, init) => + new Promise((_resolve, reject) => { + init?.signal?.addEventListener( + "abort", + () => reject(init.signal?.reason), + { once: true }, + ); + }), + ); + const client = new Client({ + credential: { type: "organizationApiKey", value: "pmfa_operations" }, + baseUrl: "https://api.example.com", + timeoutMs: 60_000, + fetch, + }); + + const result = client.operations.wait("operation_1", { + maxWaitMs: 500, + pollIntervalMs: 250, + }); + const assertion = expect(result).rejects.toMatchObject({ + name: PolymorfaTimeoutError.name, + code: "operation_wait_timeout", + }); + await vi.advanceTimersByTimeAsync(500); + await assertion; + expect(fetch).toHaveBeenCalledTimes(1); + }); +}); diff --git a/packages/typescript/test/exports.test.ts b/packages/typescript/test/exports.test.ts index 9a0435b..a4a42ae 100644 --- a/packages/typescript/test/exports.test.ts +++ b/packages/typescript/test/exports.test.ts @@ -28,8 +28,8 @@ import { PresenceResource, PrivacyResource, QuickRepliesResource, - PlatformClient, - PlatformOperationsResource, + QuickLinkSettingsResource, + Client, ProjectTokensResource, PRIVACY_SETTING_VALUES, PRESENCE_CHAT_STATES, @@ -47,7 +47,6 @@ import { TemplatesResource, UsersResource, VoipResource, - WidgetSettingsResource, constructWebhookEvent, isEvent, verifyWebhookSignature, @@ -90,9 +89,14 @@ import { type QRCodeData, type ListCampaignsParams, type ManagementOperation, + type ManagementOperationActionRequired, + type ManagementOperationError, + type ManagementOperationKind, + type ManagementOperationProgress, + type ManagementOperationStatus, type OrganizationMember, type PlatformPayload, - type PlatformClientOptions, + type ClientOptions, type ProjectToken, type RawRequest, type RequestOptions, @@ -107,7 +111,8 @@ import { type UpdateBillingReminderSettingsRequest, type WebhookEvent, type UserSecurityCode, - type WidgetSettings, + type OrganizationQuickLinkSettings, + type ProjectQuickLinkSettings, } from "../src/index.js"; describe("public exports", () => { @@ -124,7 +129,7 @@ describe("public exports", () => { MessagingClient, MessagingCampaignsResource, MessagingMediaResource, - PlatformClient, + Client, ApiKeysResource, AuditLogsResource, AudiencesResource, @@ -148,7 +153,6 @@ describe("public exports", () => { PrivacyResource, PresenceResource, QuickRepliesResource, - PlatformOperationsResource, ProjectTokensResource, CursorPage, PolymorfaError, @@ -162,11 +166,11 @@ describe("public exports", () => { TemplatesResource, UsersResource, VoipResource, - WidgetSettingsResource, + QuickLinkSettingsResource, constructWebhookEvent, verifyWebhookSignature, isEvent, - ]).toHaveLength(45); + ]).toHaveLength(44); }); it("exposes every public CLI-facing type from one entrypoint", () => { @@ -190,9 +194,18 @@ describe("public exports", () => { >(); expectTypeOf().toHaveProperty("path"); expectTypeOf().toHaveProperty("credential"); - expectTypeOf().toHaveProperty("apiKey"); + expectTypeOf().toHaveProperty("credential"); expectTypeOf().toHaveProperty("projectId"); expectTypeOf().toHaveProperty("status"); + expectTypeOf().toEqualTypeOf< + ManagementOperation["status"] + >(); + expectTypeOf().toEqualTypeOf< + ManagementOperation["kind"] + >(); + expectTypeOf().toHaveProperty("code"); + expectTypeOf().toHaveProperty("retryable"); + expectTypeOf().toHaveProperty("details"); expectTypeOf().toHaveProperty("role"); expectTypeOf().toMatchTypeOf< Readonly> @@ -231,7 +244,13 @@ describe("public exports", () => { expectTypeOf().toHaveProperty("variables"); expectTypeOf().toHaveProperty("event"); expectTypeOf().toHaveProperty("numericCode"); - expectTypeOf().toHaveProperty("allowedOrigins"); + expectTypeOf().toHaveProperty("headline"); + expectTypeOf< + OrganizationQuickLinkSettings["projectId"] + >().toEqualTypeOf(); + expectTypeOf< + ProjectQuickLinkSettings["projectId"] + >().toEqualTypeOf(); expectTypeOf().toHaveProperty("payload"); }); }); diff --git a/packages/typescript/test/names.test.ts b/packages/typescript/test/names.test.ts index 3763aeb..abfd111 100644 --- a/packages/typescript/test/names.test.ts +++ b/packages/typescript/test/names.test.ts @@ -1,4 +1,4 @@ -import { mkdtempSync, writeFileSync } from "node:fs"; +import { mkdtempSync, unlinkSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { fileURLToPath } from "node:url"; @@ -52,4 +52,15 @@ describe("retired product-name checker", () => { expect(result.status).toBe(1); expect(result.stderr).toContain("README.md"); }); + + it("ignores a tracked file removed in the working tree", () => { + const directory = makeRepository("Polymorfa SDK"); + unlinkSync(join(directory, "README.md")); + + const result = spawnSync(process.execPath, [checker, "--root", directory], { + encoding: "utf8", + }); + + expect(result.status, result.stderr).toBe(0); + }); }); diff --git a/packages/typescript/test/package.test.ts b/packages/typescript/test/package.test.ts index 7b67ae3..6fa0dcc 100644 --- a/packages/typescript/test/package.test.ts +++ b/packages/typescript/test/package.test.ts @@ -9,17 +9,22 @@ const repositoryRoot = new URL("../../../", import.meta.url).pathname; describe("npm package", () => { it("packs and imports in a clean consumer without runtime dependencies", () => { + const directory = mkdtempSync(join(tmpdir(), "polymorfa-package-")); + const environment = { + ...process.env, + npm_config_cache: join(directory, "npm-cache"), + }; const build = spawnSync("npm", ["run", "build"], { cwd: repositoryRoot, encoding: "utf8", + env: environment, }); expect(build.status, build.stderr).toBe(0); - const directory = mkdtempSync(join(tmpdir(), "polymorfa-package-")); const packed = spawnSync( "npm", ["pack", "--json", "--ignore-scripts", "--pack-destination", directory], - { cwd: repositoryRoot, encoding: "utf8" }, + { cwd: repositoryRoot, encoding: "utf8", env: environment }, ); expect(packed.status, packed.stderr).toBe(0); const metadata = JSON.parse(packed.stdout) as Array<{ @@ -39,21 +44,29 @@ describe("npm package", () => { const initialize = spawnSync("npm", ["init", "-y"], { cwd: directory, encoding: "utf8", + env: environment, }); expect(initialize.status, initialize.stderr).toBe(0); const tarball = join(directory, metadata[0]?.filename ?? "missing.tgz"); const install = spawnSync("npm", ["install", "--ignore-scripts", tarball], { cwd: directory, encoding: "utf8", + env: environment, }); expect(install.status, install.stderr).toBe(0); writeFileSync( consumer, [ - 'import { MessagingClient, PlatformClient, PRESENCE_STATES, PRIVACY_SETTING_VALUES, SDK_VERSION } from "@polymorfa/sdk";', + 'import * as sdk from "@polymorfa/sdk";', + "const { MessagingClient, Client, SystemClient, BridgeClient, PRESENCE_STATES, PRIVACY_SETTING_VALUES, SDK_VERSION, webhooks } = sdk;", 'const messaging = new MessagingClient({ credential: { type: "apiKey", value: "pmfa_fixture" } });', - 'const platform = new PlatformClient({ apiKey: "pmfa_fixture" });', - "console.log(JSON.stringify({ version: SDK_VERSION, messaging: !!messaging.raw, business: typeof messaging.business.getCatalog, calls: typeof messaging.calls.reject, campaigns: typeof messaging.campaigns.launch, messagingMedia: typeof messaging.media.download, chats: typeof messaging.chats.editMessage, channels: typeof messaging.channels.listMessageUpdates, contacts: typeof messaging.contacts.list, groups: typeof messaging.groups.list, labels: typeof messaging.labels.list, lids: typeof messaging.lids.resolve, observationPolicies: typeof messaging.observationPolicies.retrieveForProject, profile: typeof messaging.profile.get, privacy: typeof messaging.privacy.set, privacyValues: PRIVACY_SETTING_VALUES.defense, presence: typeof messaging.presence.getForChat, presenceStates: PRESENCE_STATES, quickReplies: typeof messaging.quickReplies.list, pairing: typeof messaging.sessions.requestPairingCode, operations: typeof messaging.operations.retrieve, templates: typeof messaging.templates.create, users: typeof messaging.users.getSecurityCode, platform: !!platform.raw, apiKeys: typeof platform.apiKeys.deactivate, auditLogs: typeof platform.auditLogs.list, billing: typeof platform.billing.usage, members: typeof platform.members.list, platformOperations: typeof platform.operations.retrieve, projectTokens: typeof platform.projectTokens.list, securityIncidents: typeof platform.securityIncidents.acknowledge, sessionBans: typeof platform.sessionBans.listActive, batchStop: typeof platform.sessions.stopMany, widgetSettings: typeof platform.widgetSettings.update, memberInvite: typeof platform.members.invite, organizationUpdate: typeof platform.organizations.update }));", + 'const platform = new Client({ credential: { type: "organizationApiKey", value: "pmfa_fixture" } });', + 'const project = platform.project("project_123");', + "const system = new SystemClient();", + 'const bridge = new BridgeClient({ credential: { type: "projectToken", value: "pmfa_pt_fixture" } });', + "let listenerCredentialRejected = false;", + 'try { new Client({ credential: { type: "organizationApiKey", value: "pmfa_ls_fixture" } }); } catch { listenerCredentialRejected = true; }', + 'console.log(JSON.stringify({ version: SDK_VERSION, messaging: !!messaging.raw, business: typeof messaging.business.getCatalog, calls: typeof messaging.calls.reject, campaigns: typeof messaging.campaigns.launch, messagingMedia: typeof messaging.media.download, chats: typeof messaging.chats.editMessage, channels: typeof messaging.channels.listMessageUpdates, contacts: typeof messaging.contacts.list, groups: typeof messaging.groups.list, labels: typeof messaging.labels.list, lids: typeof messaging.lids.resolve, observationPolicies: typeof messaging.observationPolicies.retrieveForProject, profile: typeof messaging.profile.get, privacy: typeof messaging.privacy.set, privacyValues: PRIVACY_SETTING_VALUES.defense, presence: typeof messaging.presence.getForChat, presenceStates: PRESENCE_STATES, quickReplies: typeof messaging.quickReplies.list, pairing: typeof messaging.sessions.requestPairingCode, messagingOperations: typeof messaging.operations.retrieve, templates: typeof messaging.templates.create, users: typeof messaging.users.getSecurityCode, systemStatus: typeof system.status, systemVersion: typeof system.version, systemHealth: typeof system.health, systemPing: typeof system.ping, bridgeRoutes: typeof bridge.routes.resolve, bridgeListen: typeof bridge.listen, platform: !!platform.raw, platformOwner: platform.owner, projectOwner: project.owner, projectId: project.projectId, apiKeys: typeof platform.apiKeys.deactivate, auditLogs: typeof platform.auditLogs.list, billing: typeof platform.billing.usage, members: typeof platform.members.list, events: typeof project.events.replay, webhooks: typeof project.webhooks.rotateSecret, webhookDeliveries: typeof project.webhookDeliveries.retrieveAttempt, operations: typeof project.operations.wait, projectTokens: typeof platform.projectTokens.list, securityIncidents: typeof platform.securityIncidents.acknowledge, sessionBans: typeof platform.sessionBans.listActive, sessionStart: typeof platform.sessions.start, batchStop: typeof platform.sessions.stopMany, quickLinkSettings: typeof project.quickLinkSettings.update, verifyWebhook: typeof webhooks.verify, createWebhookFixture: typeof webhooks.createFixture, listenerCredentialRejected, platformClientExported: "PlatformClient" in sdk, listenerApiExported: "eventStreams" in sdk, memberInvite: typeof platform.members.invite, organizationUpdate: typeof platform.organizations.update }));', ].join("\n"), ); const imported = spawnSync(process.execPath, [consumer], { @@ -82,20 +95,38 @@ describe("npm package", () => { presenceStates: ["available", "unavailable"], quickReplies: "function", pairing: "function", - operations: "function", + messagingOperations: "function", templates: "function", users: "function", + systemStatus: "function", + systemVersion: "function", + systemHealth: "function", + systemPing: "function", + bridgeRoutes: "function", + bridgeListen: "undefined", platform: true, + platformOwner: "organization", + projectOwner: "project", + projectId: "project_123", apiKeys: "function", auditLogs: "function", billing: "function", members: "function", - platformOperations: "function", + events: "function", + webhooks: "function", + webhookDeliveries: "function", + operations: "function", projectTokens: "function", securityIncidents: "function", sessionBans: "function", + sessionStart: "function", batchStop: "function", - widgetSettings: "function", + quickLinkSettings: "function", + verifyWebhook: "function", + createWebhookFixture: "function", + listenerCredentialRejected: true, + platformClientExported: false, + listenerApiExported: false, memberInvite: "undefined", organizationUpdate: "undefined", }); diff --git a/packages/typescript/test/platform-access.test.ts b/packages/typescript/test/platform-access.test.ts index bf17162..975bc52 100644 --- a/packages/typescript/test/platform-access.test.ts +++ b/packages/typescript/test/platform-access.test.ts @@ -4,8 +4,7 @@ import { ApiKeysResource, AuditLogsResource, MembersResource, - PlatformClient, - PlatformOperationsResource, + Client, ProjectTokensResource, SecurityIncidentsResource, SessionBansResource, @@ -14,6 +13,7 @@ import { type AuditLog, type DataEnvelope, type ManagementOperation, + type OrganizationOperation, type OrganizationMember, type ProjectToken, type SecurityIncident, @@ -32,7 +32,7 @@ afterEach(async () => { }); async function platformAccessServer(): Promise<{ - client: PlatformClient; + client: Client; requests: RecordedRequest[]; }> { const server = await startTestServer(() => ({ @@ -45,32 +45,26 @@ async function platformAccessServer(): Promise<{ servers.push(server); return { requests: server.requests, - client: new PlatformClient({ - apiKey: "pmfa_platform", + client: new Client({ + credential: { type: "organizationApiKey", value: "pmfa_platform" }, baseUrl: server.url, maxNetworkRetries: 0, }), }; } -describe("PlatformClient organization access and operations", () => { +describe("Client organization access and operations", () => { it("exports the complete resource and response contracts", () => { - expectTypeOf().toEqualTypeOf(); - expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); + expectTypeOf().toEqualTypeOf(); expectTypeOf< - PlatformClient["auditLogs"] - >().toEqualTypeOf(); - expectTypeOf< - PlatformClient["sessionBans"] - >().toEqualTypeOf(); - expectTypeOf< - PlatformClient["securityIncidents"] + Client["securityIncidents"] >().toEqualTypeOf(); + expectTypeOf().toHaveProperty("list"); expectTypeOf< - PlatformClient["operations"] - >().toEqualTypeOf(); - expectTypeOf< - PlatformClient["projectTokens"] + Client["projectTokens"] >().toEqualTypeOf(); expectTypeOf().toHaveProperty("lastUsed"); @@ -78,7 +72,7 @@ describe("PlatformClient organization access and operations", () => { expectTypeOf().toHaveProperty("metadata"); expectTypeOf().toHaveProperty("banExpiresAt"); expectTypeOf().toHaveProperty("acknowledgedAt"); - expectTypeOf().toHaveProperty("failureCode"); + expectTypeOf().toHaveProperty("capabilities"); expectTypeOf().toHaveProperty("revokedAt"); }); @@ -187,9 +181,7 @@ describe("PlatformClient organization access and operations", () => { timeoutMs: 5_000, }); - expectTypeOf(operation).toEqualTypeOf< - ApiResponse> - >(); + expectTypeOf(operation).toEqualTypeOf>(); expectTypeOf(tokens).toEqualTypeOf< ApiResponse> >(); diff --git a/packages/typescript/test/platform-automation.test.ts b/packages/typescript/test/platform-automation.test.ts index c539201..b5ff4e9 100644 --- a/packages/typescript/test/platform-automation.test.ts +++ b/packages/typescript/test/platform-automation.test.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from "vitest"; -import { PlatformClient } from "../src/platform/client.js"; +import { Client } from "../src/client.js"; import { startTestServer, type RecordedRequest, @@ -14,7 +14,7 @@ afterEach(async () => { }); async function platformServer(): Promise<{ - client: PlatformClient; + client: Client; requests: RecordedRequest[]; }> { const server = await startTestServer(() => ({ @@ -27,15 +27,15 @@ async function platformServer(): Promise<{ servers.push(server); return { requests: server.requests, - client: new PlatformClient({ - apiKey: "pmfa_platform", + client: new Client({ + credential: { type: "organizationApiKey", value: "pmfa_platform" }, baseUrl: server.url, maxNetworkRetries: 0, }), }; } -describe("PlatformClient billing", () => { +describe("Client billing", () => { it("maps the complete organization-key billing read surface", async () => { const { client, requests } = await platformServer(); @@ -73,7 +73,7 @@ describe("PlatformClient billing", () => { }); }); -describe("PlatformClient media", () => { +describe("Client media", () => { it("maps media URL, delete, and upload operations", async () => { const { client, requests } = await platformServer(); const retrieved = await client.media.retrieve("media/a"); @@ -96,7 +96,7 @@ describe("PlatformClient media", () => { }); }); -describe("PlatformClient opt-outs", () => { +describe("Client opt-outs", () => { it("maps list, single, batch, and encoded delete operations", async () => { const { client, requests } = await platformServer(); await client.optOuts.list(); @@ -119,7 +119,7 @@ describe("PlatformClient opt-outs", () => { }); }); -describe("PlatformClient audiences", () => { +describe("Client audiences", () => { it("maps collection, encoded item, and upload operations", async () => { const { client, requests } = await platformServer(); await client.audiences.list(); @@ -144,7 +144,7 @@ describe("PlatformClient audiences", () => { }); }); -describe("PlatformClient campaigns", () => { +describe("Client campaigns", () => { it("maps collection, encoded item, and project query operations", async () => { const { client, requests } = await platformServer(); await client.campaigns.list({ diff --git a/packages/typescript/test/platform-response.test.ts b/packages/typescript/test/platform-response.test.ts new file mode 100644 index 0000000..3d17651 --- /dev/null +++ b/packages/typescript/test/platform-response.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it, vi } from "vitest"; + +import { Client, PolymorfaServerError } from "../src/index.js"; + +describe("management response envelopes", () => { + it.each([undefined, null, {}, { data: undefined }])( + "rejects malformed successful response %j with a typed error", + async (body) => { + const fetch = vi.fn(async () => + body === undefined + ? new Response(undefined, { + status: 200, + headers: { "x-request-id": "req_invalid" }, + }) + : Response.json(body, { + headers: { "x-request-id": "req_invalid" }, + }), + ); + const client = new Client({ + credential: { type: "organizationApiKey", value: "pmfa_org" }, + baseUrl: "https://api.example.com", + fetch, + }); + + await expect(client.events.retrieve("event_1")).rejects.toMatchObject({ + name: PolymorfaServerError.name, + code: "invalid_response", + status: 200, + requestId: "req_invalid", + }); + }, + ); + + it.each([undefined, null, {}, { page: {} }, { data: null }])( + "rejects malformed collection response %j with a typed error", + async (body) => { + const fetch = vi.fn(async () => + body === undefined + ? new Response(undefined, { + status: 200, + headers: { "x-request-id": "req_invalid_collection" }, + }) + : Response.json(body, { + headers: { "x-request-id": "req_invalid_collection" }, + }), + ); + const client = new Client({ + credential: { type: "organizationApiKey", value: "pmfa_org" }, + baseUrl: "https://api.example.com", + fetch, + }); + + await expect(client.events.list()).rejects.toMatchObject({ + name: PolymorfaServerError.name, + code: "invalid_response", + status: 200, + requestId: "req_invalid_collection", + }); + }, + ); +}); diff --git a/packages/typescript/test/platform-session-start.test.ts b/packages/typescript/test/platform-session-start.test.ts new file mode 100644 index 0000000..62ef5d6 --- /dev/null +++ b/packages/typescript/test/platform-session-start.test.ts @@ -0,0 +1,44 @@ +import { describe, expect, expectTypeOf, it, vi } from "vitest"; + +import { + Client, + type ApiResponse, + type DataEnvelope, + type SessionStartResult, +} from "../src/index.js"; + +describe("Client.sessions.start", () => { + it("starts an organization-managed session", async () => { + const fetch = vi.fn(async () => + Response.json({ data: { starting: true, sessionId: "session-uuid" } }), + ); + const client = new Client({ + credential: { type: "organizationApiKey", value: "pmfa_sessions" }, + baseUrl: "https://api.example.com", + fetch, + }); + + const response = await client.sessions.start( + "support/us", + { projectId: "project-a" }, + { idempotencyKey: "start-support" }, + ); + + expectTypeOf(response).toEqualTypeOf< + ApiResponse> + >(); + expect(response.data.data).toEqual({ + starting: true, + sessionId: "session-uuid", + }); + const [url, init] = fetch.mock.calls[0]!; + expect(new URL(String(url)).pathname).toBe( + "/v1/sessions/support%2Fus/start", + ); + expect(init?.method).toBe("POST"); + expect(JSON.parse(String(init?.body))).toEqual({ projectId: "project-a" }); + expect(new Headers(init?.headers).get("idempotency-key")).toBe( + "start-support", + ); + }); +}); diff --git a/packages/typescript/test/platform-widget-sessions.test.ts b/packages/typescript/test/platform-widget-sessions.test.ts index a3cba7e..aead2a8 100644 --- a/packages/typescript/test/platform-widget-sessions.test.ts +++ b/packages/typescript/test/platform-widget-sessions.test.ts @@ -1,13 +1,11 @@ import { afterEach, describe, expect, expectTypeOf, it } from "vitest"; import { - PlatformClient, - WidgetSettingsResource, + Client, type ApiResponse, type DataEnvelope, type SessionBatchRemoveResult, type SessionBatchStopResult, - type WidgetSettings, } from "../src/index.js"; import { startTestServer, @@ -22,7 +20,7 @@ afterEach(async () => { }); async function platformServer(): Promise<{ - client: PlatformClient; + client: Client; requests: RecordedRequest[]; }> { const server = await startTestServer(() => ({ @@ -35,102 +33,15 @@ async function platformServer(): Promise<{ servers.push(server); return { requests: server.requests, - client: new PlatformClient({ - apiKey: "pmfa_platform", + client: new Client({ + credential: { type: "organizationApiKey", value: "pmfa_platform" }, baseUrl: server.url, maxNetworkRetries: 0, }), }; } -describe("PlatformClient widget settings", () => { - it("exports the exact settings resource and typed wire shape", () => { - expectTypeOf< - PlatformClient["widgetSettings"] - >().toEqualTypeOf(); - expectTypeOf().toHaveProperty("modesAllowed"); - expectTypeOf().toHaveProperty("allowedOrigins"); - expectTypeOf().toHaveProperty("qrLogoMode"); - expectTypeOf().toHaveProperty("createdAt"); - }); - - it("retrieves organization or project settings with response metadata", async () => { - const { client, requests } = await platformServer(); - - const organization = await client.widgetSettings.retrieve(); - const project = await client.widgetSettings.retrieve( - { projectId: "project/a" }, - { apiVersion: "next" }, - ); - - expectTypeOf(organization).toEqualTypeOf< - ApiResponse> - >(); - expect(requests.map(({ method, path }) => `${method} ${path}`)).toEqual([ - "GET /v1/widget", - "GET /v1/widget?projectId=project%2Fa", - ]); - expect(requests[1]?.headers["polymorfa-version"]).toBe("next"); - expect(project.metadata.requestId).toBe("req_platform_widget_sessions"); - }); - - it("updates only source-defined settings and forwards idempotency", async () => { - const { client, requests } = await platformServer(); - - const updated = await client.widgetSettings.update( - { - projectId: "11111111-2222-4333-8444-555555555555", - enabled: true, - modesAllowed: ["embedded", "redirect"], - methods: ["qr", "pairing", "cloud-api"], - allowedRedirectUris: ["https://example.test/callback"], - allowedOrigins: ["https://example.test"], - businessName: "Support", - accent: "#6A3DE8", - theme: "system", - hideWatermark: false, - colors: { dark: { primary: "#6A3DE8" } }, - shape: "rounded", - radiusPx: 16, - qrLogoMode: "custom", - qrLogoStorageId: "logo/a", - qrLogoSourceStorageId: "source/a", - historySync: "ask", - }, - { idempotencyKey: "widget-settings-1" }, - ); - - expectTypeOf(updated).toEqualTypeOf< - ApiResponse> - >(); - expect(requests[0]).toMatchObject({ - method: "PUT", - path: "/v1/widget", - }); - expect(requests[0]?.headers["idempotency-key"]).toBe("widget-settings-1"); - expect(JSON.parse(requests[0]?.body ?? "{}")).toEqual({ - projectId: "11111111-2222-4333-8444-555555555555", - enabled: true, - modesAllowed: ["embedded", "redirect"], - methods: ["qr", "pairing", "cloud-api"], - allowedRedirectUris: ["https://example.test/callback"], - allowedOrigins: ["https://example.test"], - businessName: "Support", - accent: "#6A3DE8", - theme: "system", - hideWatermark: false, - colors: { dark: { primary: "#6A3DE8" } }, - shape: "rounded", - radiusPx: 16, - qrLogoMode: "custom", - qrLogoStorageId: "logo/a", - qrLogoSourceStorageId: "source/a", - historySync: "ask", - }); - }); -}); - -describe("PlatformClient batch session lifecycle", () => { +describe("Client batch session lifecycle", () => { it("requests bounded asynchronous stops without inventing per-item results", async () => { const { client, requests } = await platformServer(); diff --git a/packages/typescript/test/platform.test.ts b/packages/typescript/test/platform.test.ts index 887a488..294dbc1 100644 --- a/packages/typescript/test/platform.test.ts +++ b/packages/typescript/test/platform.test.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from "vitest"; import { PolymorfaConfigurationError } from "../src/errors.js"; -import { PlatformClient } from "../src/platform/client.js"; +import { Client } from "../src/client.js"; import { startTestServer, type RecordedRequest, @@ -15,7 +15,7 @@ afterEach(async () => { }); async function platformServer(): Promise<{ - client: PlatformClient; + client: Client; requests: RecordedRequest[]; }> { const server = await startTestServer(() => ({ @@ -28,26 +28,32 @@ async function platformServer(): Promise<{ servers.push(server); return { requests: server.requests, - client: new PlatformClient({ - apiKey: "pmfa_platform", + client: new Client({ + credential: { type: "organizationApiKey", value: "pmfa_platform" }, baseUrl: server.url, maxNetworkRetries: 0, }), }; } -describe("PlatformClient credentials", () => { +describe("Client credentials", () => { it("rejects client and project tokens before issuing a request", () => { - expect(() => new PlatformClient({ apiKey: "pmfa_ct_browser" })).toThrow( - PolymorfaConfigurationError, - ); - expect(() => new PlatformClient({ apiKey: "pmfa_pt_project" })).toThrow( - PolymorfaConfigurationError, - ); + expect( + () => + new Client({ + credential: { type: "organizationApiKey", value: "pmfa_ct_browser" }, + }), + ).toThrow(PolymorfaConfigurationError); + expect( + () => + new Client({ + credential: { type: "organizationApiKey", value: "pmfa_pt_project" }, + }), + ).toThrow(PolymorfaConfigurationError); }); }); -describe("PlatformClient organizations and projects", () => { +describe("Client organizations and projects", () => { it("retrieves the active organization without exposing dashboard-only updates", async () => { const { client, requests } = await platformServer(); await client.organizations.retrieve(); @@ -104,7 +110,7 @@ describe("PlatformClient organizations and projects", () => { }); }); -describe("PlatformClient sessions", () => { +describe("Client sessions", () => { it("lists sessions with optional project scope", async () => { const { client, requests } = await platformServer(); await client.sessions.list(); diff --git a/packages/typescript/test/system.test.ts b/packages/typescript/test/system.test.ts new file mode 100644 index 0000000..9c42606 --- /dev/null +++ b/packages/typescript/test/system.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, expectTypeOf, it, vi } from "vitest"; + +import { + SystemClient, + type ApiResponse, + type HealthResponse, + type PingResponse, + type StatusResponse, + type VersionResponse, +} from "../src/index.js"; + +describe("SystemClient", () => { + it("calls the credential-free health and information routes", async () => { + const fetch = vi.fn(async (input) => { + const path = new URL(String(input)).pathname; + const data: Record = { + "/api/info/status": { + status: "ready", + uptime: "2h", + version: "1.2.3", + env: "staging", + }, + "/api/info/version": { + version: "1.2.3", + buildTime: "2026-09-08T00:00:00Z", + env: "staging", + apiVersion: "1.0.0", + minSupportedVersion: "1.0.0", + }, + "/health": { + status: "healthy", + checks: { database: { status: "healthy" } }, + }, + "/ping": { status: "ok" }, + }; + return Response.json(data[path]); + }); + const system = new SystemClient({ + baseUrl: "https://api.example.com", + fetch, + }); + + const status = await system.status(); + const version = await system.version(); + const health = await system.health(); + const ping = await system.ping(); + + expectTypeOf(status).toEqualTypeOf>(); + expectTypeOf(version).toEqualTypeOf>(); + expectTypeOf(health).toEqualTypeOf>(); + expectTypeOf(ping).toEqualTypeOf>(); + expect( + fetch.mock.calls.map(([input]) => new URL(String(input)).pathname), + ).toEqual(["/api/info/status", "/api/info/version", "/health", "/ping"]); + for (const [, init] of fetch.mock.calls) { + expect(new Headers(init?.headers).has("authorization")).toBe(false); + } + }); +}); diff --git a/packages/typescript/test/transport.test.ts b/packages/typescript/test/transport.test.ts index 555a77f..e475395 100644 --- a/packages/typescript/test/transport.test.ts +++ b/packages/typescript/test/transport.test.ts @@ -1,8 +1,9 @@ -import { afterEach, describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; import { PolymorfaAuthenticationError, PolymorfaCancelledError, + PolymorfaConfigurationError, PolymorfaConnectionError, PolymorfaRateLimitError, PolymorfaServerError, @@ -42,6 +43,23 @@ function makeTransport( } describe("HttpTransport", () => { + it("rejects cleartext credential transport outside loopback", () => { + expect(() => makeTransport("http://api.example.com")).toThrow( + PolymorfaConfigurationError, + ); + }); + + it("disables automatic redirects for credentialed requests", async () => { + const fetch = vi.fn(async () => + Response.json({ ok: true }), + ); + const transport = makeTransport("https://api.example.com", { fetch }); + + await transport.request({ method: "GET", path: "/v1/check" }); + + expect(fetch.mock.calls[0]?.[1]?.redirect).toBe("error"); + }); + it("sends protected headers and returns immutable response metadata", async () => { const server = await serverFor(() => ({ headers: { @@ -49,6 +67,8 @@ describe("HttpTransport", () => { "x-request-id": "req_123", "polymorfa-version": "2026-08-19", "x-ratelimit-remaining": "41", + "set-cookie": "session=secret", + "x-internal-debug": "database-host", }, body: '{"ok":true}', })); @@ -69,6 +89,8 @@ describe("HttpTransport", () => { attempts: 1, }); expect(response.metadata.headers["x-ratelimit-remaining"]).toBe("41"); + expect(response.metadata.headers).not.toHaveProperty("set-cookie"); + expect(response.metadata.headers).not.toHaveProperty("x-internal-debug"); expect(Object.isFrozen(response.metadata)).toBe(true); expect(server.requests[0]?.headers.authorization).toBe( "Bearer pmfa_example", diff --git a/packages/typescript/test/webhook-event-types.test.ts b/packages/typescript/test/webhook-event-types.test.ts index f2a531d..4e0692b 100644 --- a/packages/typescript/test/webhook-event-types.test.ts +++ b/packages/typescript/test/webhook-event-types.test.ts @@ -4,12 +4,14 @@ import type { BlocklistUpdatePayload, BusinessQuickReplyUpdatePayload, CallAcceptedPayload, + CallEndedPayload, CallMissedPayload, CallParticipant, CallParticipantLeftPayload, CallParticipantPayload, CallReceivedPayload, CallRejectedPayload, + CallTelemetryPayload, ChatArchivePayload, ChatClearPayload, ChatDeletePayload, @@ -126,6 +128,14 @@ type ExpectedPayloads = { readonly from: ExpectedJidReference; readonly callId: string; }; + readonly "call.ended": { + readonly from: ExpectedJidReference | null; + readonly callId: string; + readonly durationSeconds: number; + readonly reason: string; + readonly direction: "inbound" | "outbound"; + readonly hadVideo: boolean; + }; readonly "call.missed": { readonly from: ExpectedJidReference; readonly callId: string; @@ -164,6 +174,19 @@ type ExpectedPayloads = { readonly from: ExpectedJidReference; readonly callId: string; }; + readonly "call.telemetry": { + readonly callId: string; + readonly setupMs: number; + readonly ringMs: number; + readonly durationSeconds: number; + readonly terminateReason: string; + readonly codec: string; + readonly jitterMs: number; + readonly packetsLost: number; + readonly rttMs: number; + readonly recvKbps: number; + readonly sendKbps: number; + }; readonly "chat.archive": { readonly from: ExpectedJidReference; readonly archive?: boolean; @@ -284,12 +307,14 @@ type ExportedPayloads = { readonly "blocklist.update": BlocklistUpdatePayload; readonly "business.quick_reply.update": BusinessQuickReplyUpdatePayload; readonly "call.accepted": CallAcceptedPayload; + readonly "call.ended": CallEndedPayload; readonly "call.missed": CallMissedPayload; readonly "call.participant_joined": CallParticipantPayload; readonly "call.participant_left": CallParticipantLeftPayload; readonly "call.participant_state": CallParticipantPayload; readonly "call.received": CallReceivedPayload; readonly "call.rejected": CallRejectedPayload; + readonly "call.telemetry": CallTelemetryPayload; readonly "chat.archive": ChatArchivePayload; readonly "chat.clear": ChatClearPayload; readonly "chat.delete": ChatDeletePayload; diff --git a/packages/typescript/test/webhooks.test.ts b/packages/typescript/test/webhooks.test.ts index f4f4785..93d9c72 100644 --- a/packages/typescript/test/webhooks.test.ts +++ b/packages/typescript/test/webhooks.test.ts @@ -9,6 +9,7 @@ import { type CallTelemetryPayload, constructWebhookEvent, isEvent, + webhooks, verifyWebhookSignature, type MessageReceivedEvent, } from "../src/webhooks/index.js"; @@ -53,6 +54,62 @@ describe("verifyWebhookSignature", () => { }); }); +describe("webhook utilities", () => { + it("creates canonical exact-byte fixtures and verifies them", async () => { + const event = JSON.parse(raw.toString("utf8")) as MessageReceivedEvent; + const fixture = await webhooks.createFixture({ + event, + secret: "fixture-secret", + }); + expect(Buffer.from(fixture.body)).toEqual(raw); + expect(fixture.signature).toBe(signature); + expect(fixture.contentType).toBe("application/json"); + expect(fixture.headers).toEqual({ + "content-type": "application/json", + "x-webhook-signature": fixture.signature, + }); + await expect( + webhooks.verifySignature({ + body: fixture.body, + signature: fixture.signature, + secret: "fixture-secret", + }), + ).resolves.toBe(true); + await expect( + webhooks.verify({ + body: fixture.body, + signature: fixture.signature, + secret: "fixture-secret", + }), + ).resolves.toEqual(event); + }); + + it("keeps the timestamped local-forward signature separate", async () => { + const secretBytes = Buffer.alloc(32, 7); + const secret = secretBytes.toString("base64url"); + const timestamp = 1_787_133_600; + const digest = createHmac("sha256", secretBytes) + .update(Buffer.from(`${timestamp}.`)) + .update(raw) + .digest("hex"); + await expect( + webhooks.verifyLocal({ + body: raw, + signature: `t=${timestamp},v1=${digest}`, + secret, + nowUnixSeconds: timestamp + 10, + }), + ).resolves.toMatchObject({ id: "evt_1", event: "message.received" }); + await expect( + webhooks.verify({ + body: raw, + signature: `t=${timestamp},v1=${digest}`, + secret, + }), + ).rejects.toThrow(WebhookSignatureError); + }); +}); + describe("constructWebhookEvent", () => { it("verifies before parsing and narrows a known event", async () => { const event = await constructWebhookEvent(raw, signature, "fixture-secret"); diff --git a/scripts/check-retired-name.mjs b/scripts/check-retired-name.mjs index e349b22..693b934 100644 --- a/scripts/check-retired-name.mjs +++ b/scripts/check-retired-name.mjs @@ -1,7 +1,7 @@ #!/usr/bin/env node import { execFileSync } from "node:child_process"; -import { readFileSync } from "node:fs"; +import { existsSync, readFileSync } from "node:fs"; import { resolve } from "node:path"; const retiredName = Buffer.from([116, 105, 116, 97, 110]).toString("utf8"); @@ -23,7 +23,9 @@ try { ); const matches = []; for (const path of output.split("\0").filter(Boolean)) { - const contents = readFileSync(resolve(root, path)); + const absolutePath = resolve(root, path); + if (!existsSync(absolutePath)) continue; + const contents = readFileSync(absolutePath); if (contents.includes(0)) continue; if (contents.toString("utf8").toLowerCase().includes(retiredName)) matches.push(path);