diff --git a/README.md b/README.md index bc6d77d..ccd3ebf 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ The development branch contains the TypeScript server SDK, a framework-neutral browser runtime, shared UI contracts, Web Components, React bindings, thin Next.js server helpers, and a production-gated developer assistant. It follows the Messaging and Platform contracts recorded at source revision -`8c244aab0e5626d101a2c8c4915287427f39e014`. Graph-compatible APIs are outside +`6918c56135e28ba64557e344cb72889f1f517eb5`. Graph-compatible APIs are outside this SDK's initial scope. ## Package architecture @@ -76,16 +76,18 @@ const sent = await messaging.messages.send( console.log(sent.data.data.id, sent.metadata.attempts); ``` -Messaging credentials use an explicit discriminator. Server keys use -`{ type: "apiKey", value }`; short-lived client tokens use +Messaging credentials use an explicit discriminator. Organization server keys +use `{ type: "apiKey", value }`, project tokens use +`{ type: "projectToken", value }`, and short-lived client tokens use `{ type: "clientToken", value }`. A discriminator/prefix mismatch fails before -any network request. Server keys are rejected in browser runtimes. +any network request. Server credentials are rejected in browser runtimes. The handwritten Messaging resources in this milestone are: - `sessions`: list, create, retrieve, update, delete, start, stop, restart, - logout, account, JSON QR retrieval, and phone pairing codes + logout, account, and entitlement-gated direct JSON QR or phone pairing - `operations`: retrieve durable lifecycle operation status +- `quickLinks`: create, retrieve, and cancel hosted QuickLink pairing sessions - `business`: manage the connected Business App profile, commerce catalog, products, collections, orders, compliance, linked accounts, and eligibility - `calls`: reject an identified incoming Linked Device call @@ -377,16 +379,21 @@ The SDK has no listener, event stream, `AsyncIterable`, or forwarding API. credential cannot be used by `Client`, `MessagingClient`, or their raw request helpers. -## QuickLink settings +## QuickLink lifecycle and settings + +`MessagingClient.quickLinks.create()`, `retrieve()`, and `cancel()` map the +authenticated hosted lifecycle at `/api/quicklinks`. They accept organization +API keys or project tokens with `quicklink:manage`; browser client tokens fail +before transport. Organization keys can set `projectId` on creation, while a +project token remains bound by the server. + +These methods expose the short-lived connection URL and status record. They do +not add list, recovery, or history operations that the API does not provide. `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 Browser code accepts only short-lived `pmfa_ct_` tokens returned by an diff --git a/contracts/README.md b/contracts/README.md index 5a2043f..905016a 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -2,14 +2,14 @@ The snapshots are byte-identical copies of the Messaging and Platform OpenAPI files at `polymorfa/polymorfa` commit -`8c244aab0e5626d101a2c8c4915287427f39e014`. `source.json` records their original +`6918c56135e28ba64557e344cb72889f1f517eb5`. `source.json` records their original paths and SHA-256 hashes. `coverage.json` uses the same source revision. | Status | Operations | | ------------------- | ---------: | -| Covered | 271 | +| Covered | 274 | | Missing | 0 | -| Excluded | 131 | +| Excluded | 128 | | Partial | 0 | | Changed fingerprint | 0 | | Total | 402 | @@ -20,13 +20,17 @@ successful live call. ## Reconciliation -This refresh adds and removes no operations. Two Messaging operation -fingerprints changed: browser candidate retrieval remains excluded from the -server SDK, and `MessagingClient.voip.agentToken` remains covered by its typed -method and request test. Schema updates include literal webhook event names, -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. +This contract refresh adds and removes no operations and changes no operation +fingerprints. It retires the `session.qr` webhook schema and callback, and it +documents that direct QR and pairing-code session routes require an +organization entitlement while hosted QuickLinks are the standard pairing +flow. + +The SDK now covers the three existing QuickLink operations through +`MessagingClient.quickLinks.create`, `retrieve`, and `cancel`. Those rows moved +from excluded to covered, increasing covered operations from 271 to 274 and +reducing excluded operations from 131 to 128. The missing-operation inventory +remains empty. This snapshot records complete handwritten TypeScript coverage for every customer-credential-compatible operation in the pinned contracts. Routes that diff --git a/contracts/coverage.json b/contracts/coverage.json index 9305426..42b661a 100644 --- a/contracts/coverage.json +++ b/contracts/coverage.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceCommit": "8c244aab0e5626d101a2c8c4915287427f39e014", + "sourceCommit": "6918c56135e28ba64557e344cb72889f1f517eb5", "excludedContracts": [ { "family": "graph", @@ -26,9 +26,8 @@ "operationId": "cancelQuickLink", "fingerprint": "763ce4c4e8f4fdec20a081a53a40bcbc10101940bbe9fc41647ca5338215d5f7", "typescript": { - "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" + "status": "covered", + "method": "MessagingClient.quickLinks.cancel" } }, { @@ -303,9 +302,8 @@ "operationId": "getQuickLink", "fingerprint": "76ece78254ba9f50816a3ebb40365ac699bd7b2ceb4b15134a3e6c2521672b4c", "typescript": { - "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" + "status": "covered", + "method": "MessagingClient.quickLinks.retrieve" } }, { @@ -1022,9 +1020,8 @@ "operationId": "createQuickLink", "fingerprint": "0f8aa33283ca0a7b8f5ce21aec512a471ec96fee773a16f1bf96e55c5cea9367", "typescript": { - "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" + "status": "covered", + "method": "MessagingClient.quickLinks.create" } }, { diff --git a/contracts/openapi.messaging.json b/contracts/openapi.messaging.json index fe03803..53685cd 100644 --- a/contracts/openapi.messaging.json +++ b/contracts/openapi.messaging.json @@ -725,51 +725,6 @@ "payload" ] }, - "SessionQRPayload": { - "type": "object", - "properties": { - "code": { - "type": "string" - } - }, - "required": [ - "code" - ] - }, - "SessionQREvent": { - "type": "object", - "properties": { - "id": { - "type": "string", - "description": "Unique event identifier" - }, - "session": { - "type": "string", - "description": "Session that produced this event" - }, - "timestamp": { - "type": "string", - "description": "ISO 8601 timestamp" - }, - "event": { - "type": "string", - "enum": [ - "session.qr" - ], - "description": "Event type" - }, - "payload": { - "$ref": "#/components/schemas/SessionQRPayload" - } - }, - "required": [ - "id", - "session", - "timestamp", - "event", - "payload" - ] - }, "SessionConnectedPayload": { "type": "object", "properties": { @@ -13377,6 +13332,7 @@ "Pairing" ], "summary": "Get QR code for device pairing", + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sessions:manage`\n\nDirect QR retrieval requires an explicit organization entitlement. Create a QuickLink for the standard pairing flow.", "security": [ { "BearerAuth": [] @@ -13444,7 +13400,7 @@ } }, "403": { - "description": "Authenticated but not permitted (missing scope or rule violation)", + "description": "Direct pairing is unavailable; create a QuickLink unless the organization has the direct-pairing entitlement", "content": { "application/json": { "schema": { @@ -13479,8 +13435,7 @@ }, "x-protocols": [ "Linked Device API" - ], - "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sessions:manage`" + ] } }, "/api/{session}/pair/code": { @@ -13490,6 +13445,7 @@ "Pairing" ], "summary": "Request a pairing code for a phone number", + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sessions:manage`\n\nDirect pairing-code retrieval requires an explicit organization entitlement. Create a QuickLink for the standard pairing flow.", "security": [ { "BearerAuth": [] @@ -13547,7 +13503,7 @@ } }, "403": { - "description": "Authenticated but not permitted (missing scope or rule violation)", + "description": "Direct pairing is unavailable; create a QuickLink unless the organization has the direct-pairing entitlement", "content": { "application/json": { "schema": { @@ -13582,8 +13538,7 @@ }, "x-protocols": [ "Linked Device API" - ], - "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sessions:manage`" + ] } }, "/api/{session}/messages/send": { @@ -30333,30 +30288,6 @@ } } }, - "session.qr": { - "post": { - "operationId": "onSessionQr", - "summary": "QR code issued for device linking", - "tags": [ - "Webhooks" - ], - "requestBody": { - "required": true, - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/SessionQREvent" - } - } - } - }, - "responses": { - "2XX": { - "description": "Return any 2xx status to acknowledge receipt. Non-2xx responses are retried according to the retry policy." - } - } - } - }, "session.connected": { "post": { "operationId": "onSessionConnected", diff --git a/contracts/source.json b/contracts/source.json index 4fba6a6..bab6a9c 100644 --- a/contracts/source.json +++ b/contracts/source.json @@ -1,11 +1,11 @@ { "repository": "polymorfa/polymorfa", - "commit": "8c244aab0e5626d101a2c8c4915287427f39e014", + "commit": "6918c56135e28ba64557e344cb72889f1f517eb5", "contracts": { "messaging": { "sourcePath": "apps/api/docs/openapi.json", "snapshotPath": "contracts/openapi.messaging.json", - "sha256": "a60bc482b8e830b063895708fbf0b9ea68a05b9d8df3f7256df085349d88ef2e" + "sha256": "2f5d06b1e5103a00e62896636f122c570729578afd3cc5636ae57711941d85cf" }, "platform": { "sourcePath": "apps/api/docs/openapi.management.json", diff --git a/packages/typescript/README.md b/packages/typescript/README.md index ea5ee77..0f85910 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -67,6 +67,10 @@ only owner-bound management resources; organization resources such as `MessagingClient` remains separate because its session APIs and credentials have a different authorization boundary. +Messaging credentials are explicit: `apiKey` for an organization server key, +`projectToken` for the single opaque project-token format, and `clientToken` +for the browser action allowlist. Server credentials fail in browser runtimes. + 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 @@ -156,33 +160,28 @@ session rules can delegate to the browser token. ## Session connection lifecycle -Start a Linked Device session, retrieve its JSON QR payload or request a phone -pairing code, then poll the returned durable lifecycle operation. Pairing -requires `sessions:manage`; operation retrieval accepts any of -`sessions:read`, `campaigns:read`, or `webhooks:manage`. +Start a Linked Device session, then poll the returned durable lifecycle +operation. The standard pairing flow is QuickLink. Direct JSON QR and phone +pairing-code routes require `sessions:manage` plus an explicit organization +entitlement; without it, the API returns `403` and the application must create +a QuickLink. Operation retrieval accepts any of `sessions:read`, +`campaigns:read`, or `webhooks:manage`. ```ts const started = await messaging.sessions.start("support", { idempotencyKey: "start-support", }); -const qr = await messaging.sessions.qr("support"); -console.log(qr.data.data.qr, qr.metadata.requestId); - -const pairingCode = await messaging.sessions.requestPairingCode( - "support", - { phone: "+15551234567" }, - { idempotencyKey: "pair-support-phone" }, -); - const operation = await messaging.operations.retrieve(started.data.operationId); -console.log(pairingCode.data.data.code, operation.data.data.status); +console.log(operation.data.data.status); ``` `sessions.retrieve` is the typed source of session connection status. The pinned API contract does not expose session logs or a separate -connection-status endpoint. QR image rendering remains an application concern; -the server SDK deliberately requests the typed JSON QR representation. +connection-status endpoint. The API no longer emits `session.qr` webhook +events. Applications must observe QuickLink state through the QuickLink flow; +the entitlement-gated `sessions.qr` and `sessions.requestPairingCode` methods +remain available only for organizations that have direct pairing enabled. ## Project templates @@ -927,11 +926,9 @@ event history, replay, or delivery-listener endpoint. 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 -`MessagingClient` credential contract does not accept project tokens, so this -resource deliberately remains organization-key-only. Browser client tokens -are not allowlisted for any campaign action and fail before the handler. +accept organization API keys and project tokens bound to the exact path +project. Browser client tokens are not allowlisted for any campaign action and +fail before the handler. Campaigns are project control-plane objects and have no Linked Device versus Cloud session-mode discriminator. @@ -1185,7 +1182,30 @@ are not exposed by the server SDK. Browser client tokens are rejected by the 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 +## QuickLink lifecycle and settings + +`MessagingClient.quickLinks` owns the authenticated hosted pairing lifecycle: + +```ts +const quickLink = await messaging.quickLinks.create( + { + projectId: "11111111-2222-4333-8444-555555555555", + methods: ["qr", "pairing"], + expiresInSeconds: 900, + }, + { idempotencyKey: crypto.randomUUID() }, +); + +const status = await messaging.quickLinks.retrieve(quickLink.data.data.id); +console.log(quickLink.data.data.url, status.data.data.status); +``` + +The resource accepts organization API keys or project tokens with +`quicklink:manage`. It rejects browser client tokens before transport. An +organization key can select `projectId` when creating a link; a project token +is bound by the server. `cancel()` invalidates a pending link and removes its +pending session. Connected links cannot be cancelled. The source exposes no +list, recover, or history operation. `Client.quickLinkSettings.retrieve` and `update` map the management `GET /v1/quicklink` and `PUT /v1/quicklink` operations. Use them on the root @@ -1201,10 +1221,10 @@ const projectSettings = await platform ); ``` -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. +These methods manage saved settings only. Hosted lifecycle methods stay on +`MessagingClient.quickLinks`, not `Client` or `client.project(...)`, because +the `/api/quicklinks/{id}` routes do not carry an immutable project path for an +organization-key project view. Console-only logo routes are outside the SDK. ## Management session lifecycle diff --git a/packages/typescript/src/credentials.ts b/packages/typescript/src/credentials.ts index 5d626e0..5745d65 100644 --- a/packages/typescript/src/credentials.ts +++ b/packages/typescript/src/credentials.ts @@ -2,6 +2,7 @@ import { PolymorfaConfigurationError } from "./errors.js"; export type MessagingCredential = | { readonly type: "apiKey"; readonly value: string } + | { readonly type: "projectToken"; readonly value: string } | { readonly type: "clientToken"; readonly value: string }; export interface SharedClientOptions { @@ -39,6 +40,15 @@ export type ClientOptions = export function validateMessagingCredential( credential: MessagingCredential, ): MessagingCredential { + if (credential.type === "projectToken") { + if (!isProjectToken(credential.value)) { + throw new PolymorfaConfigurationError( + "Messaging project tokens must use the pmfa_pt_ prefix.", + "credential", + ); + } + return credential; + } if (credential.type === "clientToken") { if ( !credential.value.startsWith("pmfa_ct_") || @@ -102,13 +112,23 @@ function rejectListenerCredential(value: string): void { } export function assertServerRuntime( - runtime: { readonly window?: unknown } = globalThis as { + runtime: { readonly window?: unknown; - }, + readonly importScripts?: unknown; + readonly constructor?: { readonly name?: string }; + } = globalThis, ): void { - if (typeof runtime.window !== "undefined") { + const globalName = runtime.constructor?.name ?? ""; + const isBrowserWorker = + typeof runtime.importScripts === "function" || + globalName === "DedicatedWorkerGlobalScope" || + globalName === "SharedWorkerGlobalScope" || + globalName === "ServiceWorkerGlobalScope" || + globalName.endsWith("WorkletGlobalScope"); + + if (typeof runtime.window !== "undefined" || isBrowserWorker) { throw new PolymorfaConfigurationError( - "Polymorfa server API keys cannot be used in browsers.", + "Polymorfa server credentials cannot be used in browser runtimes.", "runtime", ); } @@ -123,3 +143,7 @@ function isServerApiKey(value: string): boolean { !value.startsWith("pmfa_ls_") ); } + +function isProjectToken(value: string): boolean { + return value.startsWith("pmfa_pt_") && value.length > "pmfa_pt_".length; +} diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts index 68263e3..a7ff083 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -165,6 +165,16 @@ export { PRESENCE_UNKNOWN_REASONS, } from "./messaging/types.js"; export { QuickRepliesResource } from "./messaging/quick-replies.js"; +export { + QuickLinksResource, + type CancelQuickLinkResponse, + type CreateQuickLinkRequest, + type CreateQuickLinkResponse, + type GetQuickLinkResponse, + type QuickLink, + type QuickLinkStatus, + type QuickLinkStatusValue, +} from "./messaging/quicklinks.js"; export { SessionsResource } from "./messaging/sessions.js"; export { TemplatesResource } from "./messaging/templates.js"; export { UsersResource } from "./messaging/users.js"; @@ -606,7 +616,6 @@ export { type SessionConnectedPayload, type SessionLoggedOutPayload, type SessionPhoneOfflinePayload, - type SessionQrPayload, type SessionStatusPayload, type UnknownWebhookEvent, type WebhookBody, diff --git a/packages/typescript/src/messaging/client.ts b/packages/typescript/src/messaging/client.ts index ca7b95c..380a0ba 100644 --- a/packages/typescript/src/messaging/client.ts +++ b/packages/typescript/src/messaging/client.ts @@ -23,6 +23,7 @@ import { ProfileResource } from "./profile.js"; import { PrivacyResource } from "./privacy.js"; import { PresenceResource } from "./presence.js"; import { QuickRepliesResource } from "./quick-replies.js"; +import { QuickLinksResource } from "./quicklinks.js"; import { SessionsResource } from "./sessions.js"; import { TemplatesResource } from "./templates.js"; import { UsersResource } from "./users.js"; @@ -49,6 +50,7 @@ export class MessagingClient { readonly privacy: PrivacyResource; readonly presence: PresenceResource; readonly quickReplies: QuickRepliesResource; + readonly quickLinks: QuickLinksResource; readonly templates: TemplatesResource; readonly users: UsersResource; readonly voip: VoipResource; @@ -57,7 +59,7 @@ export class MessagingClient { constructor(options: MessagingClientOptions) { const credential = validateMessagingCredential(options.credential); - if (credential.type === "apiKey") assertServerRuntime(); + if (credential.type !== "clientToken") assertServerRuntime(); const transport = new HttpTransport({ baseUrl: options.baseUrl ?? "https://api.polymorfa.com", authorization: `Bearer ${credential.value}`, @@ -87,6 +89,7 @@ export class MessagingClient { this.privacy = new PrivacyResource(transport); this.presence = new PresenceResource(transport); this.quickReplies = new QuickRepliesResource(transport); + this.quickLinks = new QuickLinksResource(transport, credential.type); this.templates = new TemplatesResource(transport); this.users = new UsersResource(transport); this.voip = new VoipResource(transport); diff --git a/packages/typescript/src/messaging/quicklinks.ts b/packages/typescript/src/messaging/quicklinks.ts new file mode 100644 index 0000000..e3ae951 --- /dev/null +++ b/packages/typescript/src/messaging/quicklinks.ts @@ -0,0 +1,116 @@ +import { PolymorfaConfigurationError } from "../errors.js"; +import type { + QuickLinkHistorySync, + QuickLinkMethod, + QuickLinkTheme, +} from "../platform/quicklink-settings.js"; +import { HttpTransport } from "../transport/http.js"; +import type { ApiResponse, RequestOptions } from "../transport/types.js"; +import type { MessagingCredential } from "../credentials.js"; + +export interface CreateQuickLinkRequest { + readonly projectId?: string; + readonly customerId?: string; + readonly methods?: readonly QuickLinkMethod[]; + readonly businessName?: string; + readonly historySync?: QuickLinkHistorySync; + readonly callbackUrl?: string; + readonly theme?: QuickLinkTheme; + readonly accent?: string; + readonly prefillPhone?: string; + readonly expiresInSeconds?: number; +} + +export interface QuickLink { + readonly id: string; + readonly url: string; + readonly session: string; + readonly expiresAt: string; +} + +export type QuickLinkStatusValue = + "pending" | "opened" | "linked" | "connected" | "failed" | "cancelled"; + +export interface QuickLinkStatus { + readonly id: string; + readonly status: QuickLinkStatusValue; + readonly session: string; + readonly expiresAt: string; + readonly openedAt: string | null; + readonly connectedAt: string | null; + readonly phone: string | null; + readonly errorCode: string | null; +} + +export interface CreateQuickLinkResponse { + readonly success: true; + readonly data: QuickLink; +} + +export interface GetQuickLinkResponse { + readonly success: true; + readonly data: QuickLinkStatus; +} + +export interface CancelQuickLinkResponse { + readonly success: true; + readonly message: string; +} + +/** Hosted QuickLink lifecycle. Client tokens are rejected before transport. */ +export class QuickLinksResource { + constructor( + private readonly transport: HttpTransport, + private readonly credentialType: MessagingCredential["type"], + ) {} + + create( + input: CreateQuickLinkRequest = {}, + options: RequestOptions = {}, + ): Promise> { + this.assertServerCredential(); + return this.transport.request({ + method: "POST", + path: "/api/quicklinks", + body: input, + ...options, + }); + } + + retrieve( + id: string, + options: RequestOptions = {}, + ): Promise> { + this.assertServerCredential(); + return this.transport.request({ + method: "GET", + path: quickLinkPath(id), + ...options, + }); + } + + cancel( + id: string, + options: RequestOptions = {}, + ): Promise> { + this.assertServerCredential(); + return this.transport.request({ + method: "DELETE", + path: quickLinkPath(id), + ...options, + }); + } + + private assertServerCredential(): void { + if (this.credentialType === "clientToken") { + throw new PolymorfaConfigurationError( + "QuickLink management requires an organization API key or project token.", + "credential", + ); + } + } +} + +function quickLinkPath(id: string): string { + return `/api/quicklinks/${encodeURIComponent(id)}`; +} diff --git a/packages/typescript/src/messaging/sessions.ts b/packages/typescript/src/messaging/sessions.ts index 1070e4c..9fe1ff8 100644 --- a/packages/typescript/src/messaging/sessions.ts +++ b/packages/typescript/src/messaging/sessions.ts @@ -113,6 +113,7 @@ export class SessionsResource { }); } + /** Direct pairing is entitlement-gated. Use QuickLink for the standard flow. */ qr( session: string, options: RequestOptions = {}, @@ -125,6 +126,7 @@ export class SessionsResource { }); } + /** Direct pairing is entitlement-gated. Use QuickLink for the standard flow. */ requestPairingCode( session: string, body: PairCodeRequest, diff --git a/packages/typescript/src/webhooks/events.ts b/packages/typescript/src/webhooks/events.ts index ba5096b..fa8076b 100644 --- a/packages/typescript/src/webhooks/events.ts +++ b/packages/typescript/src/webhooks/events.ts @@ -35,7 +35,6 @@ export const KNOWN_WEBHOOK_EVENT_TYPES = [ "session.connected", "session.logged_out", "session.phone_offline", - "session.qr", "session.status", ] as const; @@ -158,10 +157,6 @@ export interface SessionStatusPayload { readonly statusReason?: string; } -export interface SessionQrPayload { - readonly code: string; -} - export interface SessionConnectedPayload { readonly phoneNumber: string; readonly lid?: string; @@ -410,7 +405,6 @@ export interface WebhookPayloadMap { readonly "session.connected": SessionConnectedPayload; readonly "session.logged_out": SessionLoggedOutPayload; readonly "session.phone_offline": SessionPhoneOfflinePayload; - readonly "session.qr": SessionQrPayload; readonly "session.status": SessionStatusPayload; } diff --git a/packages/typescript/src/webhooks/index.ts b/packages/typescript/src/webhooks/index.ts index bf1de9c..a2604a2 100644 --- a/packages/typescript/src/webhooks/index.ts +++ b/packages/typescript/src/webhooks/index.ts @@ -44,7 +44,6 @@ export { type SessionConnectedPayload, type SessionLoggedOutPayload, type SessionPhoneOfflinePayload, - type SessionQrPayload, type SessionStatusPayload, type UnknownWebhookEvent, type WebhookEvent, diff --git a/packages/typescript/test/coverage-reconciliation.test.ts b/packages/typescript/test/coverage-reconciliation.test.ts index 01e1153..cf86fd0 100644 --- a/packages/typescript/test/coverage-reconciliation.test.ts +++ b/packages/typescript/test/coverage-reconciliation.test.ts @@ -103,7 +103,7 @@ describe("reconciled coverage evidence", () => { }; expect(source.repository).toBe("polymorfa/polymorfa"); // Repinning the reviewed source requires updating this regression gate too. - expect(source.commit).toBe("8c244aab0e5626d101a2c8c4915287427f39e014"); + expect(source.commit).toBe("6918c56135e28ba64557e344cb72889f1f517eb5"); expect(ledger.sourceCommit).toBe(source.commit); expect(Object.keys(source.contracts).sort()).toEqual([ "messaging", diff --git a/packages/typescript/test/coverage.test.ts b/packages/typescript/test/coverage.test.ts index 96bd8e1..202e2d9 100644 --- a/packages/typescript/test/coverage.test.ts +++ b/packages/typescript/test/coverage.test.ts @@ -150,12 +150,12 @@ describe("coverage checker", () => { const result = runRepositoryChecker(); expect(result.status, result.stderr).toBe(0); expect(result.report).toMatchObject({ - sourceCommit: "8c244aab0e5626d101a2c8c4915287427f39e014", + sourceCommit: "6918c56135e28ba64557e344cb72889f1f517eb5", total: 402, - covered: 271, + covered: 274, partial: 0, missing: 0, - excluded: 131, + excluded: 128, changed: 0, resolutions: [], }); diff --git a/packages/typescript/test/credentials.test.ts b/packages/typescript/test/credentials.test.ts index fd58fcb..1eef939 100644 --- a/packages/typescript/test/credentials.test.ts +++ b/packages/typescript/test/credentials.test.ts @@ -30,6 +30,15 @@ describe("credential validation", () => { }); }); + it("accepts the single project token format for Messaging routes", () => { + expect( + validateMessagingCredential({ + type: "projectToken", + value: "pmfa_pt_example", + }), + ).toEqual({ type: "projectToken", value: "pmfa_pt_example" }); + }); + it("rejects a mismatched Messaging credential discriminator", () => { expect(() => validateMessagingCredential({ type: "apiKey", value: "pmfa_ct_example" }), @@ -40,6 +49,12 @@ describe("credential validation", () => { value: "pmfa_example", }), ).toThrow(/Messaging client token/); + expect(() => + validateMessagingCredential({ + type: "projectToken", + value: "pmfa_example", + }), + ).toThrow(/Messaging project token/); }); it("rejects client, project, and listener tokens as organization keys", () => { @@ -65,7 +80,13 @@ describe("credential validation", () => { it("rejects server API keys in a browser runtime", () => { expect(() => assertServerRuntime({ window: {} })).toThrow( - /server API keys cannot be used in browsers/, + /server credentials cannot be used in browser runtimes/, ); }); + + it("rejects server credentials in browser workers", () => { + expect(() => + assertServerRuntime({ importScripts: () => undefined }), + ).toThrow(/server credentials cannot be used in browser runtimes/); + }); }); diff --git a/packages/typescript/test/quicklinks.test.ts b/packages/typescript/test/quicklinks.test.ts new file mode 100644 index 0000000..f49fbc3 --- /dev/null +++ b/packages/typescript/test/quicklinks.test.ts @@ -0,0 +1,169 @@ +import { afterEach, describe, expect, expectTypeOf, it, vi } from "vitest"; + +import { + MessagingClient, + PolymorfaConfigurationError, + type ApiResponse, + type CancelQuickLinkResponse, + type CreateQuickLinkResponse, + type GetQuickLinkResponse, +} from "../src/index.js"; +import { startTestServer, type TestServer } from "./support/http-server.js"; + +const servers: TestServer[] = []; + +afterEach(async () => { + vi.unstubAllGlobals(); + await Promise.all(servers.splice(0).map((server) => server.close())); +}); + +describe("MessagingClient.quickLinks", () => { + it("creates, retrieves, and cancels the exact hosted QuickLink resource", async () => { + const server = await startTestServer((request) => { + if (request.method === "POST") { + return { + status: 201, + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + success: true, + data: { + id: "ql_123", + url: "https://connect.polymorfa.com/quicklink/token", + session: "quicklink-123", + expiresAt: "2026-09-08T12:15:00.000Z", + }, + }), + }; + } + if (request.method === "DELETE") { + return { + headers: { "content-type": "application/json" }, + body: JSON.stringify({ success: true, message: "cancelled" }), + }; + } + return { + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + success: true, + data: { + id: "ql_123", + status: "pending", + session: "quicklink-123", + expiresAt: "2026-09-08T12:15:00.000Z", + openedAt: null, + connectedAt: null, + phone: null, + errorCode: null, + }, + }), + }; + }); + servers.push(server); + const client = new MessagingClient({ + credential: { type: "apiKey", value: "pmfa_quicklink" }, + baseUrl: server.url, + maxNetworkRetries: 0, + }); + + const created = await client.quickLinks.create( + { + projectId: "11111111-2222-4333-8444-555555555555", + customerId: "66666666-7777-4888-8999-000000000000", + methods: ["qr", "pairing"], + businessName: "Acme", + historySync: "ask", + callbackUrl: "https://acme.example/callback", + theme: "system", + accent: "#6633ff", + prefillPhone: "+15551234567", + expiresInSeconds: 900, + }, + { idempotencyKey: "quicklink-123" }, + ); + const retrieved = await client.quickLinks.retrieve("ql/123"); + const cancelled = await client.quickLinks.cancel("ql/123", { + idempotencyKey: "cancel-quicklink-123", + }); + + expectTypeOf(created).toEqualTypeOf>(); + expectTypeOf(retrieved).toEqualTypeOf>(); + expectTypeOf(cancelled).toEqualTypeOf< + ApiResponse + >(); + expect(created.data.data.id).toBe("ql_123"); + expect(retrieved.data.data.status).toBe("pending"); + expect(cancelled.data.message).toBe("cancelled"); + expect( + server.requests.map(({ method, path }) => `${method} ${path}`), + ).toEqual([ + "POST /api/quicklinks", + "GET /api/quicklinks/ql%2F123", + "DELETE /api/quicklinks/ql%2F123", + ]); + expect(server.requests[0]?.body).toContain('"expiresInSeconds":900'); + expect(server.requests[0]?.headers["idempotency-key"]).toBe( + "quicklink-123", + ); + expect(server.requests[2]?.headers["idempotency-key"]).toBe( + "cancel-quicklink-123", + ); + }); + + it("accepts a project token as a server-only Messaging credential", async () => { + const fetch = vi.fn(async () => + Response.json({ + success: true, + data: { + id: "ql_project", + status: "pending", + session: "quicklink-project", + expiresAt: "2026-09-08T12:15:00.000Z", + openedAt: null, + connectedAt: null, + phone: null, + errorCode: null, + }, + }), + ); + const client = new MessagingClient({ + credential: { type: "projectToken", value: "pmfa_pt_project" }, + baseUrl: "https://api.example.com", + fetch, + }); + + await client.quickLinks.retrieve("ql_project"); + + expect( + new Headers(fetch.mock.calls[0]?.[1]?.headers).get("authorization"), + ).toBe("Bearer pmfa_pt_project"); + }); + + it("rejects project tokens before transport in a browser worker", () => { + const fetch = vi.fn(); + vi.stubGlobal("importScripts", () => undefined); + + expect( + () => + new MessagingClient({ + credential: { type: "projectToken", value: "pmfa_pt_project" }, + baseUrl: "https://api.example.com", + fetch, + }), + ).toThrow(PolymorfaConfigurationError); + expect(fetch).not.toHaveBeenCalled(); + }); + + it("rejects browser client tokens before transport", async () => { + const fetch = vi.fn(); + const client = new MessagingClient({ + credential: { type: "clientToken", value: "pmfa_ct_browser" }, + baseUrl: "https://api.example.com", + fetch, + }); + + expect(() => client.quickLinks.retrieve("ql_123")).toThrow( + PolymorfaConfigurationError, + ); + expect(fetch).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/typescript/test/webhooks.test.ts b/packages/typescript/test/webhooks.test.ts index 93d9c72..415d279 100644 --- a/packages/typescript/test/webhooks.test.ts +++ b/packages/typescript/test/webhooks.test.ts @@ -55,6 +55,10 @@ describe("verifyWebhookSignature", () => { }); describe("webhook utilities", () => { + it("does not advertise the retired direct-QR event", () => { + expect(KNOWN_WEBHOOK_EVENT_TYPES).not.toContain("session.qr"); + }); + it("creates canonical exact-byte fixtures and verifies them", async () => { const event = JSON.parse(raw.toString("utf8")) as MessageReceivedEvent; const fixture = await webhooks.createFixture({