From e77def78512e4c6cd58af3e611668d9d8563e97e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:15:40 +0200 Subject: [PATCH 1/9] Add Official API template and operational consumers --- README.md | 2 + contracts/README.md | 32 +- contracts/cloud-api-supplements.json | 25 + contracts/coverage.json | 54 +- contracts/openapi.messaging.json | 940 ++++++++++++++++++ contracts/openapi.platform.json | 2 +- contracts/source.json | 12 +- packages/typescript/README.md | 47 + packages/typescript/src/index.ts | 19 + packages/typescript/src/messaging/chats.ts | 24 + packages/typescript/src/messaging/client.ts | 3 + .../src/messaging/cloud-templates.ts | 121 +++ .../typescript/src/messaging/cloud-types.ts | 72 ++ packages/typescript/src/messaging/sessions.ts | 49 + packages/typescript/src/webhooks/events.ts | 9 + packages/typescript/src/webhooks/index.ts | 1 + .../typescript/test/cloud-operations.test.ts | 171 ++++ .../typescript/test/cloud-templates.test.ts | 117 +++ .../test/coverage-reconciliation.test.ts | 2 +- packages/typescript/test/coverage.test.ts | 6 +- 20 files changed, 1682 insertions(+), 26 deletions(-) create mode 100644 contracts/cloud-api-supplements.json create mode 100644 packages/typescript/src/messaging/cloud-templates.ts create mode 100644 packages/typescript/src/messaging/cloud-types.ts create mode 100644 packages/typescript/test/cloud-operations.test.ts create mode 100644 packages/typescript/test/cloud-templates.test.ts diff --git a/README.md b/README.md index 1ab6fdbf..62ec3350 100644 --- a/README.md +++ b/README.md @@ -135,6 +135,8 @@ The handwritten Messaging resources in this milestone are: - `quickLinks`: create, retrieve, and cancel hosted QuickLink pairing sessions - `cloudOnboarding`: continue an issued Meta Cloud API QuickLink from a trusted server +- `cloudTemplates`: list, retrieve, create and delete Meta templates for a + Number with an Official API connection; separate from project template drafts - `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 diff --git a/contracts/README.md b/contracts/README.md index 9d5ef405..89b34ff4 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -41,15 +41,27 @@ published SDK package before updating their pinned dependency. ## Full snapshots +`cloud-api-supplements.json` records the exact pending API sources for service +windows, pricing counts, acknowledgement pricing, and credential health and +reauthorization. Their SDK consumers are implemented, but the full snapshots +below do not yet include those separate branches. Consolidating them onto one +merged API revision remains a publication gate. These methods do not establish +deployed access or package availability. + The snapshots are byte-identical copies of the Messaging and Platform OpenAPI files at `polymorfa/polymorfa` API source commit -`6839976e7ad54e044c4d789fd296edc44772a907` on API `dev`. This revision retires the three unproduced BanSafe event names -and their synthetic test fixtures. The four live BanSafe events remain typed. -The two changed Testing operation fingerprints have been reconciled against -the fixture catalog. Four webhook create/update fingerprints now match the -Platform source's HTTP field pattern, which was already present on API `dev`. -Operation mappings are unchanged. `source.json` records the source paths and -SHA-256 hashes. SDK package publication and deployment remain separate. +`d25ddad0c48c3444f235bc2d6ce5c23e736d3318` in API PR #310. This is an +unmerged API dependency. `MessagingClient.cloudTemplates` covers its four +Number-scoped Meta template operations. Project template drafts remain under +`MessagingClient.templates`. Template create and delete make one attempt even +when an idempotency key or retry override is supplied; the API does not yet +provide durable replay for these provider writes. + +The snapshot also fixes the JSON escape sequence in the Platform webhook header +value pattern. The four create/update fingerprints were reviewed against that +pattern correction; their request types and method mappings are unchanged. +`source.json` records the source paths and SHA-256 hashes. API merge, SDK +publication and deployed availability remain separate. The merged Campaigns failed-state follow-up updates the `campaign.failed` reason example and the public archive/delete `409` descriptions. It changes no @@ -72,7 +84,7 @@ from SDK `dev`. The three public Calls analytics operations are covered by `Client.calls.list`, `Client.calls.export`, and `Client.calls.stats`. `Client.calls.exportAll` walks export pages. The 13 public Voice operations are covered by `Client.voice.audio` and `Client.voice.providerCredentials`; their -13 Console counterparts are excluded. The full 509-operation snapshot includes +13 Console counterparts are excluded. The full 513-operation snapshot includes all 15 Functions routes already merged to API `dev`. HMS history adds four server-only Messaging reads under @@ -160,12 +172,12 @@ signed events with these names still decode as unknown events. | Status | Operations | | ------------------- | ---------: | -| Covered | 379 | +| Covered | 383 | | Missing | 0 | | Excluded | 130 | | Partial | 0 | | Changed fingerprint | 0 | -| Total | 509 | +| Total | 513 | This revision adds test event triggering (`POST /messaging/testing/{projectId}/events`) and fixture listing diff --git a/contracts/cloud-api-supplements.json b/contracts/cloud-api-supplements.json new file mode 100644 index 00000000..01b1e4fb --- /dev/null +++ b/contracts/cloud-api-supplements.json @@ -0,0 +1,25 @@ +{ + "repository": "polymorfa/polymorfa", + "note": "Pending API dependencies for new SDK consumers. Full coverage snapshots remain at contracts/source.json until these branches merge into one verified API revision. OA11 also defines MessageAckPayload.pricing in src/lib/webhook-schemas.ts. No SDK package or availability claim.", + "sources": [ + { + "pullRequest": "https://github.com/polymorfa/polymorfa/pull/344", + "commit": "8fb8c12c1ea61032f46cb7465958a79f4a4e42bf", + "sourcePath": "apps/api/docs/openapi.json", + "sha256": "fd226ba3a09271f19ba27249e5e960a9530b32d131702c8d2f2dd5c7110747c6", + "operationIds": ["getCustomerServiceWindow", "getMetaPricingSummary"], + "merged": false + }, + { + "pullRequest": "https://github.com/polymorfa/polymorfa/pull/345", + "commit": "2cbb0226ae90c853ccdfaa29711de7b908fea3ad", + "sourcePath": "apps/api/docs/openapi.json", + "sha256": "09eb10379e449e8e0acc76c8899e4a454d4c02c77595ae3cf8085f3055488185", + "operationIds": [ + "getCloudCredentialHealth", + "reauthorizeCloudCredentials" + ], + "merged": false + } + ] +} diff --git a/contracts/coverage.json b/contracts/coverage.json index 5575a850..b963907a 100644 --- a/contracts/coverage.json +++ b/contracts/coverage.json @@ -1,6 +1,6 @@ { "schemaVersion": 1, - "sourceCommit": "6839976e7ad54e044c4d789fd296edc44772a907", + "sourceCommit": "d25ddad0c48c3444f235bc2d6ce5c23e736d3318", "excludedContracts": [ { "family": "graph", @@ -107,6 +107,17 @@ "method": "MessagingClient.profile.deletePicture" } }, + { + "family": "messaging", + "method": "DELETE", + "path": "/messaging/{session}/templates/{name}", + "operationId": "deleteTemplate", + "fingerprint": "fd81558b70fcd861a0005d048a021c156d125961dbd9e568b20fce3dfae8b024", + "typescript": { + "status": "covered", + "method": "MessagingClient.cloudTemplates.delete" + } + }, { "family": "messaging", "method": "DELETE", @@ -635,6 +646,28 @@ "method": "MessagingClient.banSafe.getSessionSafeMode" } }, + { + "family": "messaging", + "method": "GET", + "path": "/messaging/{session}/templates", + "operationId": "listTemplates", + "fingerprint": "0e4fb03a957b63354c8c50d6046529f444b6093d8a1a61857975e21ea49ab320", + "typescript": { + "status": "covered", + "method": "MessagingClient.cloudTemplates.list" + } + }, + { + "family": "messaging", + "method": "GET", + "path": "/messaging/{session}/templates/{name}", + "operationId": "getTemplate", + "fingerprint": "8d222acf68c34a5173c19b8869eb14595896d39f801031e1e5af913f908ecc0a", + "typescript": { + "status": "covered", + "method": "MessagingClient.cloudTemplates.retrieve" + } + }, { "family": "messaging", "method": "GET", @@ -1418,6 +1451,17 @@ "method": "MessagingClient.presence.subscribe" } }, + { + "family": "messaging", + "method": "POST", + "path": "/messaging/{session}/templates", + "operationId": "createTemplate", + "fingerprint": "03ae820a0ac6d1b178df82fe9e7c296a22e53ac258f19f3e8d76225269a89c5a", + "typescript": { + "status": "covered", + "method": "MessagingClient.cloudTemplates.create" + } + }, { "family": "messaging", "method": "POST", @@ -4139,7 +4183,7 @@ "method": "PATCH", "path": "/platform/projects/{projectId}/webhooks/{webhookId}", "operationId": "updateProjectWebhook", - "fingerprint": "dd063cb877e87bdd9c02df55decca2c72e5eed162d7c91fcb6d1d4ca93566714", + "fingerprint": "97736d6e9e6a5bdd5106f8a8bfdfa8d93cc4871b1fa3361bcf850b2bd614ceaa", "typescript": { "status": "covered", "method": "Client.project(projectId).webhooks.update" @@ -4195,7 +4239,7 @@ "method": "PATCH", "path": "/platform/webhooks/{webhookId}", "operationId": "updateOrganizationWebhook", - "fingerprint": "c2202ed0f4068cee38ece298a963ece9fc10b8e68afd6d771a152d779285481b", + "fingerprint": "defd2bb51f7ae7e5162e2d5c19f3f8f9b8b5ad989dc0516f0f1860f9563a54cc", "typescript": { "status": "covered", "method": "Client.webhooks.update" @@ -5076,7 +5120,7 @@ "method": "POST", "path": "/platform/projects/{projectId}/webhooks", "operationId": "createProjectWebhook", - "fingerprint": "d1da775d2aba25802eb5573fa48e6555d161d717f83e23fee02605265da386a0", + "fingerprint": "776f24ac17854feac94ed6c5db42cd0aa7b8141dfac47245755f4571f35a5c74", "typescript": { "status": "covered", "method": "Client.project(projectId).webhooks.create" @@ -5334,7 +5378,7 @@ "method": "POST", "path": "/platform/webhooks", "operationId": "createOrganizationWebhook", - "fingerprint": "b20438679f669bb141b5e333afae3fe669b55a9e9363f945ccb684ee1bc677ac", + "fingerprint": "37a88593419abbb8bfc1355e709908ed2bbf601d8c67e6b6c5f834a6ce0756b7", "typescript": { "status": "covered", "method": "Client.webhooks.create" diff --git a/contracts/openapi.messaging.json b/contracts/openapi.messaging.json index 8d24e912..d24ca530 100644 --- a/contracts/openapi.messaging.json +++ b/contracts/openapi.messaging.json @@ -15442,6 +15442,174 @@ "data" ] }, + "WaTemplate": { + "type": "object", + "properties": { + "id": { + "type": "string" + }, + "tenantId": { + "type": "string" + }, + "session": { + "type": "string" + }, + "wabaId": { + "type": "string" + }, + "name": { + "type": "string" + }, + "language": { + "type": "string" + }, + "category": { + "type": "string", + "enum": [ + "UTILITY", + "MARKETING", + "AUTHENTICATION" + ] + }, + "status": { + "type": "string", + "enum": [ + "PENDING", + "APPROVED", + "REJECTED", + "PAUSED", + "DISABLED", + "DELETED", + "ARCHIVED", + "IN_APPEAL", + "LIMIT_EXCEEDED", + "PENDING_DELETION" + ] + }, + "components": { + "type": "array", + "items": {} + }, + "metaTemplateId": { + "type": "string" + }, + "rejectionReason": { + "type": "string" + }, + "qualityScore": { + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "id", + "tenantId", + "session", + "wabaId", + "name", + "language", + "category", + "status", + "components", + "createdAt", + "updatedAt" + ] + }, + "ListTemplatesResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "type": "array", + "items": { + "$ref": "#/components/schemas/WaTemplate" + } + } + }, + "required": [ + "success", + "data" + ] + }, + "GetTemplateResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/WaTemplate" + } + }, + "required": [ + "success", + "data" + ] + }, + "CreateTemplateResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [ + true + ] + }, + "data": { + "$ref": "#/components/schemas/WaTemplate" + } + }, + "required": [ + "success", + "data" + ] + }, + "CreateTemplateRequest": { + "type": "object", + "properties": { + "name": { + "type": "string", + "minLength": 1, + "pattern": "^[a-z0-9_]+$" + }, + "language": { + "type": "string", + "minLength": 2 + }, + "category": { + "type": "string", + "enum": [ + "UTILITY", + "MARKETING", + "AUTHENTICATION" + ] + }, + "components": { + "type": "array", + "items": {}, + "minItems": 1 + } + }, + "required": [ + "name", + "language", + "category", + "components" + ] + }, "MessageTemplate": { "type": "object", "properties": { @@ -43697,6 +43865,778 @@ "description": "**Protocols:** Linked Device API\n\n**Required scope:** `media:manage`" } }, + "/messaging/{session}/templates": { + "get": { + "operationId": "listTemplates", + "tags": [ + "Templates" + ], + "summary": "List Meta templates for a Number", + "description": "**Protocols:** Meta Cloud API\n\n**Required scope:** `messages:write`\n\nReads the complete template catalog of the Number's WhatsApp Business Account from Meta, stores it, and returns it. Only a Number created on an official connection is supported; a Linked Devices Number, including one that later added an official connection, receives 400. If Meta does not return a complete catalog, the request fails with 502 and the stored templates are unchanged.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "messages:write", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "session", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Template list", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ListTemplatesResponse" + } + } + } + }, + "400": { + "description": "Bad request — request body or params failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "invalid_parameter", + "message": "Correct the invalid request field.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" + } + } + } + }, + "401": { + "description": "Missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "authentication_error", + "code": "invalid_credential", + "message": "Supply a valid credential.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-credential" + } + } + } + }, + "403": { + "description": "Authenticated but not permitted (missing scope or rule violation)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_not_found", + "message": "The resource is unavailable to this caller.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-not-found" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "api_error", + "code": "internal_error", + "message": "An unexpected service failure occurred. Keep the request ID for support.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#internal-error" + } + } + } + }, + "502": { + "description": "Meta did not return a complete template catalog; the stored templates are unchanged", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "upstream_error", + "code": "upstream_failure", + "message": "The messaging provider could not complete the request.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#upstream-failure" + } + } + } + }, + "503": { + "description": "The Number's WhatsApp Business Account is not available", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "api_error", + "code": "service_unavailable", + "message": "A required service is unavailable. Check completion before repeating a write.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#service-unavailable" + } + } + } + } + }, + "x-mint": { + "content": "Meta Cloud API messages:write" + }, + "x-protocols": [ + "Meta Cloud API" + ] + }, + "post": { + "operationId": "createTemplate", + "tags": [ + "Templates" + ], + "summary": "Submit a Meta template for approval", + "description": "**Protocols:** Meta Cloud API\n\n**Required scope:** `messages:write`\n\nSubmits the template to Meta for the Number's WhatsApp Business Account and stores Meta's receipt. The returned status is the one in Meta's response, or `PENDING` when Meta returns none. Later approval, rejection and quality changes arrive as `template.status` webhook events and appear in the next list. Only a Number created on an official connection is supported; a Linked Devices Number, including one that later added an official connection, receives 400.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "messages:write", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "session", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTemplateRequest" + } + } + } + }, + "responses": { + "201": { + "description": "Template submitted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateTemplateResponse" + } + } + } + }, + "400": { + "description": "Bad request — request body or params failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "invalid_parameter", + "message": "Correct the invalid request field.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" + } + } + } + }, + "401": { + "description": "Missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "authentication_error", + "code": "invalid_credential", + "message": "Supply a valid credential.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-credential" + } + } + } + }, + "403": { + "description": "Authenticated but not permitted (missing scope or rule violation)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_not_found", + "message": "The resource is unavailable to this caller.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-not-found" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "api_error", + "code": "internal_error", + "message": "An unexpected service failure occurred. Keep the request ID for support.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#internal-error" + } + } + } + }, + "502": { + "description": "Meta did not complete the request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "upstream_error", + "code": "upstream_failure", + "message": "The messaging provider could not complete the request.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#upstream-failure" + } + } + } + }, + "503": { + "description": "The Number's WhatsApp Business Account is not available", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "api_error", + "code": "service_unavailable", + "message": "A required service is unavailable. Check completion before repeating a write.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#service-unavailable" + } + } + } + } + }, + "x-mint": { + "content": "Meta Cloud API messages:write" + }, + "x-protocols": [ + "Meta Cloud API" + ] + } + }, + "/messaging/{session}/templates/{name}": { + "get": { + "operationId": "getTemplate", + "tags": [ + "Templates" + ], + "summary": "Get a Meta template by name", + "description": "**Protocols:** Meta Cloud API\n\n**Required scope:** `messages:write`\n\nRefreshes the Number's template catalog from Meta and returns the template with this name and language. `language` defaults to `en_US`. Fails with 502 if Meta does not return a complete catalog. Only a Number created on an official connection is supported; a Linked Devices Number, including one that later added an official connection, receives 400.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "messages:write", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "session", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "name", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": false, + "name": "language", + "in": "query" + } + ], + "responses": { + "200": { + "description": "Template detail", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetTemplateResponse" + } + } + } + }, + "400": { + "description": "Bad request — request body or params failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "invalid_parameter", + "message": "Correct the invalid request field.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" + } + } + } + }, + "401": { + "description": "Missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "authentication_error", + "code": "invalid_credential", + "message": "Supply a valid credential.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-credential" + } + } + } + }, + "403": { + "description": "Authenticated but not permitted (missing scope or rule violation)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_not_found", + "message": "The resource is unavailable to this caller.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-not-found" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "api_error", + "code": "internal_error", + "message": "An unexpected service failure occurred. Keep the request ID for support.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#internal-error" + } + } + } + }, + "502": { + "description": "Meta did not return a complete template catalog; the stored templates are unchanged", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "upstream_error", + "code": "upstream_failure", + "message": "The messaging provider could not complete the request.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#upstream-failure" + } + } + } + }, + "503": { + "description": "The Number's WhatsApp Business Account is not available", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "api_error", + "code": "service_unavailable", + "message": "A required service is unavailable. Check completion before repeating a write.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#service-unavailable" + } + } + } + } + }, + "x-mint": { + "content": "Meta Cloud API messages:write" + }, + "x-protocols": [ + "Meta Cloud API" + ] + }, + "delete": { + "operationId": "deleteTemplate", + "tags": [ + "Templates" + ], + "summary": "Delete a Meta template", + "description": "**Protocols:** Meta Cloud API\n\n**Required scope:** `messages:write`\n\nDeletes every language of the named template in Meta, then removes the stored copies. If Meta rejects the deletion or its result is unknown, the stored templates are kept. Only a Number created on an official connection is supported; a Linked Devices Number, including one that later added an official connection, receives 400.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "messages:write", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "session", + "in": "path" + }, + { + "schema": { + "type": "string" + }, + "required": true, + "name": "name", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Template deleted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SuccessResponse" + } + } + } + }, + "400": { + "description": "Bad request — request body or params failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "invalid_parameter", + "message": "Correct the invalid request field.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-parameter" + } + } + } + }, + "401": { + "description": "Missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "authentication_error", + "code": "invalid_credential", + "message": "Supply a valid credential.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#invalid-credential" + } + } + } + }, + "403": { + "description": "Authenticated but not permitted (missing scope or rule violation)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "permission_error", + "code": "permission_denied", + "message": "The credential does not have access to this resource.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#permission-denied" + } + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_not_found", + "message": "The resource is unavailable to this caller.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-not-found" + } + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "api_error", + "code": "internal_error", + "message": "An unexpected service failure occurred. Keep the request ID for support.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#internal-error" + } + } + } + }, + "502": { + "description": "Meta did not complete the request", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "upstream_error", + "code": "upstream_failure", + "message": "The messaging provider could not complete the request.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#upstream-failure" + } + } + } + } + }, + "x-mint": { + "content": "Meta Cloud API messages:write" + }, + "x-protocols": [ + "Meta Cloud API" + ] + } + }, "/messaging/projects/{projectSlug}/templates": { "get": { "operationId": "listProjectTemplates", diff --git a/contracts/openapi.platform.json b/contracts/openapi.platform.json index 226f3ee3..b1db4d92 100644 --- a/contracts/openapi.platform.json +++ b/contracts/openapi.platform.json @@ -40666,7 +40666,7 @@ "value": { "type": "string", "maxLength": 4096, - "pattern": "^(?![\\s\\S]*[\\^@-\\^H\\u000A-\\^_\\u007F])[\\s\\S]*$", + "pattern": "^(?![\\s\\S]*[\\u0000-\\u0008\\u000A-\\u001F\\u007F])[\\s\\S]*$", "x-polymorfa-max-utf8-bytes": 4096, "writeOnly": true, "description": "Encrypted write-only HTTP field value. Limited to 4096 UTF-8 bytes. Line breaks, NUL, and other control characters except horizontal tab are rejected." diff --git a/contracts/source.json b/contracts/source.json index 0fa69b96..b036b980 100644 --- a/contracts/source.json +++ b/contracts/source.json @@ -1,19 +1,19 @@ { "repository": "polymorfa/polymorfa", - "commit": "6839976e7ad54e044c4d789fd296edc44772a907", - "branch": "dev", - "published": true, - "note": "Merged API source commit retiring three unproduced BanSafe webhook names and test fixtures; Messaging and Platform snapshots are byte-identical to the authoritative OpenAPI files. SDK publication and deployment are separate.", + "commit": "d25ddad0c48c3444f235bc2d6ce5c23e736d3318", + "branch": "codex/oa-01-cloud-templates", + "published": false, + "note": "Coordinated API dependency PR310 adds four Number-scoped Cloud template operations. Snapshots are byte-identical to this exact API commit. Merge, SDK publication and deployed availability remain separate.", "contracts": { "messaging": { "sourcePath": "apps/api/docs/openapi.json", "snapshotPath": "contracts/openapi.messaging.json", - "sha256": "79543bf719017598c3e2497004d761056e919b3200abdaae1560893542816cc7" + "sha256": "1a1edd1d9623bba5939dc9473446a7d56b7448b84a0b5016645218a95c3d1d0c" }, "platform": { "sourcePath": "apps/api/docs/openapi.management.json", "snapshotPath": "contracts/openapi.platform.json", - "sha256": "7eca4148c2f1fbda47c26a67c42e2da6b74e47e5edf38d749e30a5fc3f1d7fe8" + "sha256": "4e5aa9b8890b8963d704df22efbbce7c6bd9ac09b9253f5c8215bb3966b2f2cc" } } } diff --git a/packages/typescript/README.md b/packages/typescript/README.md index ca9bdcb6..c66e4af6 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -310,6 +310,53 @@ await messaging.templates.preview("support", created.data.data.id, { Keep this client on the server. Browser builders use an application-owned route, such as `createTemplateBuilderRoute` from `@polymorfa/nextjs`. +## Official API Numbers + +These server methods require the matching API deployment and the team's beta +access. An SDK method does not enroll a team. Keep server credentials out of +browser code. + +`messaging.cloudTemplates` lists, retrieves, creates and deletes Meta templates +for a Number. It is separate from `messaging.templates`, which manages project +drafts. The native template API requires a Number created with an Official API +connection. A Number created with Linked Devices that later added an Official +API connection is rejected with `400` by this API revision. Retrieval accepts +an optional language; the API defaults to `en_US`. +Deleting a name deletes all its languages. Create and delete make one attempt, +even if an idempotency key or a retry override is supplied. Reconcile an +uncertain result before submitting another write. + +```ts +const catalog = await messaging.cloudTemplates.list("support"); +const template = await messaging.cloudTemplates.retrieve( + "support", + "order_update", + { + language: "pt_BR", + }, +); +``` + +`messaging.chats.getServiceWindow(number, conversation)` reads `open`, `closed` +or `unknown` with observation timestamps. It requires `chats:read` and service +window beta enrollment, without requiring HMS. `unknown` does not establish +permission to send; Meta still decides. + +`messaging.sessions.getMetaPricing(number, { since, until })` returns counts +grouped by Meta's reported pricing classification. It requires `sessions:read` +and the same beta access. Dates are ISO 8601, the default period is 30 days and +the maximum is 93 days. Counts contain no invoice amounts or Polymorfa charges. +Official API `message.ack` events can include `pricing`, typed as +`MetaPricingReport`, preserving Meta's field names and optional values. + +`messaging.sessions.getCloudCredentialHealth(number)` returns redacted token, +permission, registration and subscription checks with `sessions:read`. +`messaging.sessions.reauthorizeCloudCredentials(number)` creates a QuickLink +for the same Number and phone with `quicklink:manage`. The Number must already +be stopped or disconnected and use a standalone Official API connection; +the method never stops it. Reauthorization makes one attempt and does not +automatically open or share the returned URL. + ## Contacts `MessagingClient.contacts` exposes the complete Linked Device contact surface. diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts index cec01b9b..a4842e55 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -70,6 +70,15 @@ export { type BridgeSignal, } from "./bridge.js"; export { ChatsResource } from "./messaging/chats.js"; +export type { + CustomerServiceWindow, + MetaPricingParams, + MetaPricingGroup, + MetaPricingSummary, + CloudCredentialFailureCode, + CloudCredentialHealth, + CloudReauthorization, +} from "./messaging/cloud-types.js"; export type { DeleteMessageOptions } from "./messaging/chats.js"; export type { HistoryChat, @@ -243,6 +252,15 @@ export { } from "./messaging/quicklinks.js"; export { SessionsResource } from "./messaging/sessions.js"; export { TemplatesResource } from "./messaging/templates.js"; +export { + CloudTemplatesResource, + type CloudTemplate, + type CloudTemplateStatus, + type CreateCloudTemplateRequest, + type RetrieveCloudTemplateParams, + type CloudTemplateResponse, + type ListCloudTemplatesResponse, +} from "./messaging/cloud-templates.js"; export { UsersResource } from "./messaging/users.js"; export type { AddressMessageContent, @@ -993,6 +1011,7 @@ export { type LinkedDeviceMessageType, type LinkedHistorySyncPayload, type MessageAckPayload, + type MetaPricingReport, type MessageDeletePayload, type MessageEchoPayload, type MessagePayload, diff --git a/packages/typescript/src/messaging/chats.ts b/packages/typescript/src/messaging/chats.ts index a8c25545..00c64db1 100644 --- a/packages/typescript/src/messaging/chats.ts +++ b/packages/typescript/src/messaging/chats.ts @@ -1,4 +1,5 @@ import type { MessagingCredential } from "../credentials.js"; +import type { CustomerServiceWindow } from "./cloud-types.js"; import { PolymorfaConfigurationError } from "../errors.js"; import { HttpTransport } from "../transport/http.js"; import { withIdempotencyKey } from "../transport/idempotency.js"; @@ -27,6 +28,29 @@ export class ChatsResource { private readonly credentialType: MessagingCredential["type"], ) {} + /** Official API beta. Requires chats:read and team enrollment; no HMS requirement. */ + getServiceWindow( + session: string, + conversation: string, + options: RequestOptions = {}, + ): Promise< + ApiResponse<{ + readonly success: true; + readonly data: CustomerServiceWindow; + }> + > { + if (this.credentialType === "clientToken") { + throw new PolymorfaConfigurationError( + "Service windows require an organization API key or project token.", + ); + } + return this.transport.request({ + method: "GET", + path: `${chatPath(session, conversation)}/service-window`, + ...options, + }); + } + /** Lists one page of stored conversations. Requires `chats:read` and enrolled HMS history access. */ list( session: string, diff --git a/packages/typescript/src/messaging/client.ts b/packages/typescript/src/messaging/client.ts index b0a1773e..fa8181e4 100644 --- a/packages/typescript/src/messaging/client.ts +++ b/packages/typescript/src/messaging/client.ts @@ -28,6 +28,7 @@ import { QuickRepliesResource } from "./quick-replies.js"; import { QuickLinksResource } from "./quicklinks.js"; import { SessionsResource } from "./sessions.js"; import { TemplatesResource } from "./templates.js"; +import { CloudTemplatesResource } from "./cloud-templates.js"; import { UsersResource } from "./users.js"; import { VoipResource } from "./voip.js"; import { WebhooksResource } from "./webhooks.js"; @@ -57,6 +58,7 @@ export class MessagingClient { readonly testing: TestingResource; readonly quickLinks: QuickLinksResource; readonly templates: TemplatesResource; + readonly cloudTemplates: CloudTemplatesResource; readonly users: UsersResource; readonly voip: VoipResource; readonly webhooks: WebhooksResource; @@ -102,6 +104,7 @@ export class MessagingClient { this.testing = new TestingResource(transport, credential.type); this.quickLinks = new QuickLinksResource(transport, credential.type); this.templates = new TemplatesResource(transport); + this.cloudTemplates = new CloudTemplatesResource(transport); this.users = new UsersResource(transport); this.voip = new VoipResource(transport, credential.type); this.webhooks = new WebhooksResource(transport); diff --git a/packages/typescript/src/messaging/cloud-templates.ts b/packages/typescript/src/messaging/cloud-templates.ts new file mode 100644 index 00000000..4426d05e --- /dev/null +++ b/packages/typescript/src/messaging/cloud-templates.ts @@ -0,0 +1,121 @@ +import { HttpTransport } from "../transport/http.js"; +import type { ApiResponse, RequestOptions } from "../transport/types.js"; +import type { + SuccessEnvelope, + SuccessResponse, + TemplateCategory, +} from "./types.js"; + +export type CloudTemplateStatus = + | "PENDING" + | "APPROVED" + | "REJECTED" + | "PAUSED" + | "DISABLED" + | "DELETED" + | "ARCHIVED" + | "IN_APPEAL" + | "LIMIT_EXCEEDED" + | "PENDING_DELETION"; + +export interface CloudTemplate { + readonly id: string; + readonly tenantId: string; + readonly session: string; + readonly wabaId: string; + readonly name: string; + readonly language: string; + readonly category: TemplateCategory; + readonly status: CloudTemplateStatus; + readonly components: readonly unknown[]; + readonly metaTemplateId?: string; + readonly rejectionReason?: string; + readonly qualityScore?: string; + readonly createdAt: string; + readonly updatedAt: string; +} + +export interface CreateCloudTemplateRequest { + readonly name: string; + readonly language: string; + readonly category: TemplateCategory; + readonly components: readonly unknown[]; +} + +export interface RetrieveCloudTemplateParams { + /** Defaults to en_US at the API when omitted. */ + readonly language?: string; +} + +export type CloudTemplateResponse = SuccessEnvelope; +export type ListCloudTemplatesResponse = SuccessEnvelope< + readonly CloudTemplate[] +>; + +/** Meta templates for a Number created on an Official API connection, separate from project drafts. */ +export class CloudTemplatesResource { + constructor(private readonly transport: HttpTransport) {} + + /** Refresh the complete Meta catalog before returning the saved templates. */ + list( + session: string, + options: RequestOptions = {}, + ): Promise> { + return this.transport.request({ + method: "GET", + path: templatesPath(session), + ...options, + }); + } + + retrieve( + session: string, + name: string, + params: RetrieveCloudTemplateParams = {}, + options: RequestOptions = {}, + ): Promise> { + return this.transport.request({ + method: "GET", + path: templatePath(session, name), + query: { language: params.language }, + ...options, + }); + } + + /** Submit once. An Idempotency-Key does not make this provider write replayable. */ + create( + session: string, + body: CreateCloudTemplateRequest, + options: RequestOptions = {}, + ): Promise> { + return this.transport.request({ + method: "POST", + path: templatesPath(session), + body, + ...options, + maxNetworkRetries: 0, + }); + } + + /** Delete every language of this name once; reconcile an uncertain outcome before retrying. */ + delete( + session: string, + name: string, + options: RequestOptions = {}, + ): Promise> { + return this.transport.request({ + method: "DELETE", + path: templatePath(session, name), + ...options, + maxNetworkRetries: 0, + }); + } +} + +function templatesPath(session: string): string { + return `/messaging/${encodeURIComponent(session)}/templates`; +} + +function templatePath(session: string, name: string): string { + return `${templatesPath(session)}/${encodeURIComponent(name)}`; +} diff --git a/packages/typescript/src/messaging/cloud-types.ts b/packages/typescript/src/messaging/cloud-types.ts new file mode 100644 index 00000000..0fdee9c9 --- /dev/null +++ b/packages/typescript/src/messaging/cloud-types.ts @@ -0,0 +1,72 @@ +/** Observed service-window state; unknown is never proof that a send is permitted. */ +export interface CustomerServiceWindow { + readonly state: "open" | "closed" | "unknown"; + readonly reason: + | "not_tracked" + | "tracking_started" + | "notifications_interrupted" + | "identity_unlinked" + | null; + readonly openedAt: string | null; + readonly expiresAt: string | null; + readonly checkedAt: string; +} + +export interface MetaPricingParams { + /** Inclusive ISO 8601 time. Defaults to 30 days before until. */ + readonly since?: string; + /** Exclusive ISO 8601 time. Defaults to now; maximum period is 93 days. */ + readonly until?: string; +} + +/** Counts from Meta status notifications, without prices or invoice amounts. */ +export interface MetaPricingGroup { + readonly category: string; + readonly pricingModel: string; + readonly pricingType: string | null; + readonly billable: boolean | null; + readonly messages: number; +} + +export interface MetaPricingSummary { + readonly source: "meta"; + readonly since: string; + readonly until: string; + readonly messages: number; + readonly groups: readonly MetaPricingGroup[]; +} + +export type CloudCredentialFailureCode = + | "app_credentials_rejected" + | "token_expired" + | "token_invalid" + | "token_app_mismatch" + | "permission_missing" + | "phone_not_registered" + | "webhook_not_subscribed" + | "credentials_unreadable" + | "meta_unavailable" + | "meta_response_invalid"; + +export interface CloudCredentialHealth { + readonly status: "pending" | "healthy" | "action_required" | "unknown"; + readonly checkedAt: string | null; + readonly nextCheckAt: string | null; + readonly token: { + readonly status: "valid" | "expired" | "invalid" | "unknown"; + readonly expiresAt: string | null; + }; + readonly missingPermissions: readonly ( + "whatsapp_business_messaging" | "whatsapp_business_management" + )[]; + readonly phoneRegistration: "registered" | "not_registered" | "unknown"; + readonly webhookSubscription: "subscribed" | "not_subscribed" | "unknown"; + readonly failureCode: CloudCredentialFailureCode | null; +} + +export interface CloudReauthorization { + readonly quicklinkId: string; + /** Share only with the person who manages this Number's Meta assets. */ + readonly url: string; + readonly session: string; +} diff --git a/packages/typescript/src/messaging/sessions.ts b/packages/typescript/src/messaging/sessions.ts index 8cd9fab5..af76e607 100644 --- a/packages/typescript/src/messaging/sessions.ts +++ b/packages/typescript/src/messaging/sessions.ts @@ -1,4 +1,10 @@ import type { MessagingCredential } from "../credentials.js"; +import type { + CloudCredentialHealth, + CloudReauthorization, + MetaPricingParams, + MetaPricingSummary, +} from "./cloud-types.js"; import { PolymorfaConfigurationError } from "../errors.js"; import { HttpTransport } from "../transport/http.js"; import type { ApiResponse, RequestOptions } from "../transport/types.js"; @@ -9,6 +15,7 @@ import type { OperationAccepted, PairCodeRequest, RequestPairCodeResponse, + SuccessEnvelope, UpdateSessionRequest, UpdateSessionResponse, } from "./types.js"; @@ -27,6 +34,48 @@ export class SessionsResource { private readonly credentialType: MessagingCredential["type"], ) {} + /** Official API beta. Requires sessions:read; counts carry no price or charge. */ + getMetaPricing( + session: string, + params: MetaPricingParams = {}, + options: RequestOptions = {}, + ): Promise>> { + this.assertServerCredential(); + return this.transport.request({ + method: "GET", + path: `/messaging/${encodeURIComponent(session)}/meta-pricing`, + query: { ...params }, + ...options, + }); + } + + /** Latest redacted provider checks; a healthy token does not grant beta access. */ + getCloudCredentialHealth( + session: string, + options: RequestOptions = {}, + ): Promise>> { + this.assertServerCredential(); + return this.transport.request({ + method: "GET", + path: `/messaging/${encodeURIComponent(session)}/cloud-credentials`, + ...options, + }); + } + + /** Create one reauthorization link for a stopped standalone Official API Number. Never stops it. */ + reauthorizeCloudCredentials( + session: string, + options: RequestOptions = {}, + ): Promise>> { + this.assertServerCredential(); + return this.transport.request({ + method: "POST", + path: `/messaging/${encodeURIComponent(session)}/cloud-credentials/reauthorize`, + ...options, + maxNetworkRetries: 0, + }); + } + list( options: RequestOptions = {}, ): Promise>> { diff --git a/packages/typescript/src/webhooks/events.ts b/packages/typescript/src/webhooks/events.ts index 3ee2b5e5..9b8d8729 100644 --- a/packages/typescript/src/webhooks/events.ts +++ b/packages/typescript/src/webhooks/events.ts @@ -205,6 +205,14 @@ export interface MessageSentPayload { readonly timestamp: number; } +/** Meta classification copied from a status notification; contains no price. */ +export interface MetaPricingReport { + readonly billable?: boolean; + readonly pricing_model?: string; + readonly category?: string; + readonly type?: string; +} + export interface MessageAckPayload { readonly messages: readonly { readonly id: string; @@ -217,6 +225,7 @@ export interface MessageAckPayload { readonly sender?: IdentityReference; readonly type: string; readonly timestamp: number; + readonly pricing?: MetaPricingReport; } export interface MessageDeletePayload { diff --git a/packages/typescript/src/webhooks/index.ts b/packages/typescript/src/webhooks/index.ts index f91dd33f..f5203fb5 100644 --- a/packages/typescript/src/webhooks/index.ts +++ b/packages/typescript/src/webhooks/index.ts @@ -79,6 +79,7 @@ export { type LinkedDeviceMessageType, type LinkedHistorySyncPayload, type MessageAckPayload, + type MetaPricingReport, type MessageDeletePayload, type MessageEchoPayload, type MessageFailedPayload, diff --git a/packages/typescript/test/cloud-operations.test.ts b/packages/typescript/test/cloud-operations.test.ts new file mode 100644 index 00000000..57f19b6f --- /dev/null +++ b/packages/typescript/test/cloud-operations.test.ts @@ -0,0 +1,171 @@ +import { afterEach, describe, expect, expectTypeOf, it } from "vitest"; +import { + MessagingClient, + type MessageAckPayload, + type MetaPricingReport, +} from "../src/index.js"; +import { ORGANIZATION_API_KEY, PROJECT_TOKEN } from "./support/credentials.js"; +import { startTestServer, type TestServer } from "./support/http-server.js"; + +const servers: TestServer[] = []; +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())); +}); + +function client( + server: TestServer, + credential: "apiKey" | "projectToken" | "clientToken" = "apiKey", +) { + return new MessagingClient({ + credential: { + type: credential, + value: + credential === "apiKey" + ? ORGANIZATION_API_KEY + : credential === "projectToken" + ? PROJECT_TOKEN + : `pmfa_ct_${"A".repeat(94)}`, + }, + baseUrl: server.url, + maxNetworkRetries: 2, + }); +} + +describe("Official API operations", () => { + it.each(["apiKey", "projectToken"] as const)( + "preserves unknown observations and uses Messaging routes with %s", + async (credential) => { + const state = { + state: "unknown", + reason: "notifications_interrupted", + openedAt: null, + expiresAt: null, + checkedAt: "2026-09-26T00:00:00Z", + }; + const pricing = { + source: "meta", + since: "2026-09-01T00:00:00Z", + until: "2026-09-26T00:00:00Z", + messages: 1, + groups: [ + { + category: "utility", + pricingModel: "PMP", + pricingType: null, + billable: null, + messages: 1, + }, + ], + }; + const health = { + status: "unknown", + checkedAt: null, + nextCheckAt: null, + token: { status: "unknown", expiresAt: null }, + missingPermissions: [], + phoneRegistration: "unknown", + webhookSubscription: "unknown", + failureCode: "meta_unavailable", + }; + const link = { + quicklinkId: "ql_123", + url: "https://pair.example/secret-link", + session: "support/eu", + }; + const data = [state, pricing, health, link]; + let index = 0; + const server = await startTestServer(() => ({ + body: JSON.stringify({ success: true, data: data[index++] }), + })); + servers.push(server); + const sdk = client(server, credential); + expect( + (await sdk.chats.getServiceWindow("support/eu", "+5511999999999")).data + .data, + ).toEqual(state); + expect( + ( + await sdk.sessions.getMetaPricing("support/eu", { + since: pricing.since, + until: pricing.until, + }) + ).data.data, + ).toEqual(pricing); + expect( + (await sdk.sessions.getCloudCredentialHealth("support/eu")).data.data, + ).toEqual(health); + expect( + (await sdk.sessions.reauthorizeCloudCredentials("support/eu")).data + .data, + ).toEqual(link); + expect(server.requests.map((r) => `${r.method} ${r.path}`)).toEqual([ + "GET /messaging/support%2Feu/chats/%2B5511999999999/service-window", + "GET /messaging/support%2Feu/meta-pricing?since=2026-09-01T00%3A00%3A00Z&until=2026-09-26T00%3A00%3A00Z", + "GET /messaging/support%2Feu/cloud-credentials", + "POST /messaging/support%2Feu/cloud-credentials/reauthorize", + ]); + }, + ); + + it("rejects client tokens before disclosing any request", async () => { + const server = await startTestServer(() => ({ body: "{}" })); + servers.push(server); + const sdk = client(server, "clientToken"); + for (const request of [ + () => sdk.chats.getServiceWindow("support", "+5511999999999"), + () => sdk.sessions.getMetaPricing("support"), + () => sdk.sessions.getCloudCredentialHealth("support"), + () => sdk.sessions.reauthorizeCloudCredentials("support"), + ]) + expect(request).toThrow(); + expect(server.requests).toHaveLength(0); + }); + + it("does not replay link creation with an idempotency key or retry override", async () => { + const server = await startTestServer(() => ({ + status: 502, + body: JSON.stringify({ + error: { code: "upstream_failure", message: "Outcome unknown" }, + }), + })); + servers.push(server); + await expect( + client(server).sessions.reauthorizeCloudCredentials("support", { + idempotencyKey: "same-key", + maxNetworkRetries: 4, + }), + ).rejects.toMatchObject({ status: 502 }); + expect(server.requests).toHaveLength(1); + }); + + it.each([403, 409, 503])( + "preserves %s errors without synthesizing health or window state", + async (status) => { + const server = await startTestServer(() => ({ + status, + body: JSON.stringify({ + error: { code: "feature_unavailable", message: "Unavailable" }, + }), + })); + servers.push(server); + await expect( + client(server).chats.getServiceWindow("support", "+5511999999999", { + maxNetworkRetries: 0, + }), + ).rejects.toMatchObject({ status }); + }, + ); + + it("types provider pricing as optional classification rather than a charge", () => { + expectTypeOf().toEqualTypeOf< + MetaPricingReport | undefined + >(); + const report: MetaPricingReport = { + category: "utility", + pricing_model: "PMP", + type: "free_customer_service", + billable: false, + }; + expect(report.billable).toBe(false); + }); +}); diff --git a/packages/typescript/test/cloud-templates.test.ts b/packages/typescript/test/cloud-templates.test.ts new file mode 100644 index 00000000..f13b9f6d --- /dev/null +++ b/packages/typescript/test/cloud-templates.test.ts @@ -0,0 +1,117 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { MessagingClient } from "../src/index.js"; +import { ORGANIZATION_API_KEY } from "./support/credentials.js"; +import { startTestServer, type TestServer } from "./support/http-server.js"; + +const servers: TestServer[] = []; +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())); +}); + +function client(server: TestServer) { + return new MessagingClient({ + credential: { type: "apiKey", value: ORGANIZATION_API_KEY }, + baseUrl: server.url, + maxNetworkRetries: 2, + }); +} + +describe("Cloud templates", () => { + it("preserves receipt fields and encodes Number, template name and language separately", async () => { + const template = { + id: "tpl_1", + tenantId: "org_1", + session: "support/eu", + wabaId: "12345678901234567890", + name: "delivery/name", + language: "pt_BR", + category: "UTILITY", + status: "IN_APPEAL", + components: [{ type: "BODY", text: "Your delivery is ready" }], + metaTemplateId: "98765432109876543210", + rejectionReason: "FORMAT", + qualityScore: "GREEN", + createdAt: "2026-09-26T00:00:00Z", + updatedAt: "2026-09-26T00:00:00Z", + }; + const server = await startTestServer(() => ({ + body: JSON.stringify({ success: true, data: template }), + })); + servers.push(server); + const resource = client(server).cloudTemplates; + expect( + ( + await resource.retrieve("support/eu", "delivery/name", { + language: "pt_BR", + }) + ).data.data, + ).toEqual(template); + await resource.retrieve("support/eu", "delivery/name"); + await resource.list("support/eu"); + const body = { + name: "delivery", + language: "pt_BR", + category: "UTILITY" as const, + components: template.components, + }; + await resource.create("support/eu", body); + await resource.delete("support/eu", "delivery/name"); + expect(server.requests.map((r) => `${r.method} ${r.path}`)).toEqual([ + "GET /messaging/support%2Feu/templates/delivery%2Fname?language=pt_BR", + "GET /messaging/support%2Feu/templates/delivery%2Fname", + "GET /messaging/support%2Feu/templates", + "POST /messaging/support%2Feu/templates", + "DELETE /messaging/support%2Feu/templates/delivery%2Fname", + ]); + expect(JSON.parse(server.requests[3]!.body)).toEqual(body); + expect(server.requests[0]!.headers.authorization).toBe( + `Bearer ${ORGANIZATION_API_KEY}`, + ); + }); + + it.each(["create", "delete"] as const)( + "does not replay an uncertain %s, even with a key and retry override", + async (method) => { + const server = await startTestServer(() => ({ + status: 502, + body: JSON.stringify({ + error: { + code: "upstream_failure", + message: "Meta outcome is unknown", + }, + }), + })); + servers.push(server); + const resource = client(server).cloudTemplates; + const options = { idempotencyKey: "customer-key", maxNetworkRetries: 3 }; + const request = + method === "create" + ? resource.create( + "support", + { + name: "delivery", + language: "en_US", + category: "UTILITY", + components: [{}], + }, + options, + ) + : resource.delete("support", "delivery", options); + await expect(request).rejects.toMatchObject({ status: 502 }); + expect(server.requests).toHaveLength(1); + }, + ); + + it("returns a catalog failure instead of an empty successful list", async () => { + const server = await startTestServer(() => ({ + status: 502, + body: JSON.stringify({ + error: { code: "upstream_failure", message: "Catalog unavailable" }, + }), + })); + servers.push(server); + await expect( + client(server).cloudTemplates.list("support", { maxNetworkRetries: 0 }), + ).rejects.toMatchObject({ status: 502 }); + }); +}); diff --git a/packages/typescript/test/coverage-reconciliation.test.ts b/packages/typescript/test/coverage-reconciliation.test.ts index 42753f74..e84caa82 100644 --- a/packages/typescript/test/coverage-reconciliation.test.ts +++ b/packages/typescript/test/coverage-reconciliation.test.ts @@ -196,7 +196,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("6839976e7ad54e044c4d789fd296edc44772a907"); + expect(source.commit).toBe("d25ddad0c48c3444f235bc2d6ce5c23e736d3318"); 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 11fef1dc..7f575a85 100644 --- a/packages/typescript/test/coverage.test.ts +++ b/packages/typescript/test/coverage.test.ts @@ -173,9 +173,9 @@ describe("coverage checker", () => { const result = runRepositoryChecker(); expect(result.status, result.stderr).toBe(0); expect(result.report).toMatchObject({ - sourceCommit: "6839976e7ad54e044c4d789fd296edc44772a907", - total: 509, - covered: 379, + sourceCommit: "d25ddad0c48c3444f235bc2d6ce5c23e736d3318", + total: 513, + covered: 383, partial: 0, missing: 0, excluded: 130, From 8814d419d1e54d9cff88a06aa4b4ff4a816409b6 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 12:49:36 +0200 Subject: [PATCH 2/9] feat: add Official API lifecycle and Flow consumers --- README.md | 16 +- contracts/README.md | 10 +- contracts/cloud-api-supplements.json | 86 ++++++- contracts/coverage.json | 8 +- contracts/openapi.messaging.json | 42 ++++ contracts/source.json | 4 +- .../full-platform/ui/pages/connections.tsx | 2 +- examples/full-platform/ui/pages/templates.tsx | 9 +- examples/full-platform/ui/shell.tsx | 2 +- packages/store/README.md | 4 + packages/store/src/reducers.ts | 32 ++- packages/store/src/types.ts | 8 + packages/store/test/store.test.ts | 62 +++++ packages/typescript/README.md | 74 +++++- packages/typescript/src/client.ts | 4 + packages/typescript/src/index.ts | 31 +++ packages/typescript/src/messaging/client.ts | 3 + .../src/messaging/cloud-catalogs.ts | 54 +++++ .../src/messaging/cloud-templates.ts | 28 +++ packages/typescript/src/messaging/messages.ts | 1 + .../typescript/src/messaging/quicklinks.ts | 33 ++- packages/typescript/src/messaging/types.ts | 2 + packages/typescript/src/platform/flows.ts | 224 ++++++++++++++++++ packages/typescript/src/webhooks/events.ts | 36 ++- packages/typescript/src/webhooks/index.ts | 4 + .../typescript/test/cloud-catalogs.test.ts | 69 ++++++ .../typescript/test/cloud-templates.test.ts | 35 ++- .../test/coverage-reconciliation.test.ts | 2 +- packages/typescript/test/coverage.test.ts | 2 +- packages/typescript/test/flows.test.ts | 135 +++++++++++ packages/typescript/test/messages.test.ts | 13 + packages/typescript/test/quicklinks.test.ts | 32 +++ .../typescript/test/webhook-contract.test.ts | 9 +- 33 files changed, 1031 insertions(+), 45 deletions(-) create mode 100644 packages/typescript/src/messaging/cloud-catalogs.ts create mode 100644 packages/typescript/src/platform/flows.ts create mode 100644 packages/typescript/test/cloud-catalogs.test.ts create mode 100644 packages/typescript/test/flows.test.ts diff --git a/README.md b/README.md index 62ec3350..4f941ee7 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,12 @@ Handwritten API clients, UI packages, and developer tooling for Polymorfa. 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 from API source commit -`2dd0c1563b1fc4e6525f708afc12e0685110cfe0`, now merged into API `dev` -by PR #260. Graph-compatible -APIs are outside this SDK's initial scope. +Next.js server helpers, and a production-gated developer assistant. The exact Messaging and Platform sources are pinned in +`contracts/source.json`; pending Official API, Flows and catalog dependencies +are recorded in `contracts/cloud-api-supplements.json`. Full Graph API parity +is outside this SDK's scope; `cloudCatalogs.list` provides one typed read. -The same API revision adds an enrolled hosted message history beta. +The API includes an enrolled hosted message history beta. `MessagingClient.chats` has typed conversation and message reads for server credentials. These methods do not make the beta available before enrollment, HMS enablement, deployment, and SDK publication. @@ -135,8 +134,9 @@ The handwritten Messaging resources in this milestone are: - `quickLinks`: create, retrieve, and cancel hosted QuickLink pairing sessions - `cloudOnboarding`: continue an issued Meta Cloud API QuickLink from a trusted server -- `cloudTemplates`: list, retrieve, create and delete Meta templates for a +- `cloudTemplates`: list, retrieve, create, edit and delete Meta templates for a Number with an Official API connection; separate from project template drafts +- `cloudCatalogs`: read WABA product-catalog links through the Graph facade - `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 @@ -881,7 +881,7 @@ and readiness. Native send/reaction requests and edits accept `transport: "auto" | "linked_devices" | "official_api"`. `chats.deleteMessage` accepts the choice in its options. Explicit choices never fall back. Raw Graph-compatible requests can -use `graphTransportHeaders(transport)`; Graph remains outside handwritten method +use `graphTransportHeaders(transport)`; Other Graph operations remain outside handwritten method coverage. Routing details appear in response `metadata.transport`, `metadata.routingReason`, and `metadata.operationId` when supplied by the API. diff --git a/contracts/README.md b/contracts/README.md index 89b34ff4..fe5bb333 100644 --- a/contracts/README.md +++ b/contracts/README.md @@ -42,15 +42,16 @@ published SDK package before updating their pinned dependency. ## Full snapshots `cloud-api-supplements.json` records the exact pending API sources for service -windows, pricing counts, acknowledgement pricing, and credential health and -reauthorization. Their SDK consumers are implemented, but the full snapshots +windows, pricing counts, acknowledgement pricing, credential health, +reauthorization, lifecycle webhooks, QuickLink sync receipts, typing, Flow lifecycle, +Hybrid template authority and WABA catalog discovery. Their SDK consumers are implemented, but the full snapshots below do not yet include those separate branches. Consolidating them onto one merged API revision remains a publication gate. These methods do not establish deployed access or package availability. The snapshots are byte-identical copies of the Messaging and Platform OpenAPI files at `polymorfa/polymorfa` API source commit -`d25ddad0c48c3444f235bc2d6ce5c23e736d3318` in API PR #310. This is an +`45e6a08c65821b484585f7b4f091a93b3a47ff43` in API PR #310. This is an unmerged API dependency. `MessagingClient.cloudTemplates` covers its four Number-scoped Meta template operations. Project template drafts remain under `MessagingClient.templates`. Template create and delete make one attempt even @@ -329,3 +330,6 @@ organization enabled for Functions. The SDK never retries Function mutations or invocations automatically. Browser/client-token SDKs do not expose this server control plane. A local implementation or installed method does not establish hosted availability. + +The Graph coverage exclusion applies to the full Graph API, not the separately +implemented `cloudCatalogs.list` subset recorded in cloud-api-supplements.json. diff --git a/contracts/cloud-api-supplements.json b/contracts/cloud-api-supplements.json index 01b1e4fb..a7acfe6a 100644 --- a/contracts/cloud-api-supplements.json +++ b/contracts/cloud-api-supplements.json @@ -4,7 +4,7 @@ "sources": [ { "pullRequest": "https://github.com/polymorfa/polymorfa/pull/344", - "commit": "8fb8c12c1ea61032f46cb7465958a79f4a4e42bf", + "commit": "1aa0c9b45e2cdcd15bdc6443524bdb9329ae1a6d", "sourcePath": "apps/api/docs/openapi.json", "sha256": "fd226ba3a09271f19ba27249e5e960a9530b32d131702c8d2f2dd5c7110747c6", "operationIds": ["getCustomerServiceWindow", "getMetaPricingSummary"], @@ -12,14 +12,94 @@ }, { "pullRequest": "https://github.com/polymorfa/polymorfa/pull/345", - "commit": "2cbb0226ae90c853ccdfaa29711de7b908fea3ad", + "commit": "041f58465035ba49649430b59b118d98fef2dbc4", "sourcePath": "apps/api/docs/openapi.json", - "sha256": "09eb10379e449e8e0acc76c8899e4a454d4c02c77595ae3cf8085f3055488185", + "sha256": "aefe5f3b39d5fb8f504d6f7c9694934189e4264893c238d7f6d53dfc01bfe8a4", "operationIds": [ "getCloudCredentialHealth", "reauthorizeCloudCredentials" ], "merged": false + }, + { + "pullRequest": "https://github.com/polymorfa/polymorfa/pull/347", + "commit": "e3d2767d29262dd8f4e0a215b495be9041a5f484", + "sourcePath": "apps/api/docs/openapi.json", + "sha256": "697209c49d97f2bc3230f17c3c40268cc60cefea5c7d7acb217fe53e261a2ca3", + "operationIds": ["editTemplate"], + "merged": false + }, + { + "pullRequest": "https://github.com/polymorfa/polymorfa/pull/348", + "commit": "d22930d2453c3ae428fac807f545277bb748594c", + "sourcePath": "apps/api/docs/openapi.json", + "sha256": "3d7c6662fd6033328498199b84b72b71f67217b9a3a3b668e84b62e62d9ea385", + "operationIds": ["getQuickLink"], + "merged": false + }, + { + "pullRequest": "https://github.com/polymorfa/polymorfa/pull/313", + "commit": "73965b10e25cbac9cf1d7bed0ceffd450f313da9", + "sourcePath": "apps/api/docs/openapi.json", + "sha256": "1f11869d6ca6b66a61b645d1e106d849a1cc6f6fc504e64d460e6445281a6c27", + "operationIds": [], + "merged": false, + "schemas": [ + "SessionStatusPayload", + "CloudAccountStatusPayload", + "TemplateStatusPayload", + "CloudTemplateStatusPayload" + ] + }, + { + "pullRequest": "https://github.com/polymorfa/polymorfa/pull/315", + "commit": "cf124d15f31a917d92f25c34d9b2d7713b6ba8dd", + "sourcePath": "apps/api/docs/openapi.json", + "sha256": "d697ef59cfedc1d4dca9c721c198dbad084705a051afd68eee7aee236fc80a37", + "operationIds": ["setTyping"], + "merged": false + }, + { + "pullRequest": "https://github.com/polymorfa/polymorfa/pull/336", + "commit": "19ac219ce4676a2e54e8cc7a123ff4a66c453117", + "sourcePath": "apps/api/docs/openapi.management.json", + "sha256": "dba69d5cd570dd986083864b74769360d4a0c5eeeecf9fa7311cad0d189fa056", + "operationIds": [ + "listFlows", + "createFlow", + "getFlow", + "updateFlow", + "deleteFlow", + "uploadFlow", + "publishFlow", + "deprecateFlow", + "discardFlow", + "syncFlow", + "listFlowOperations" + ], + "merged": false + }, + { + "pullRequest": "https://github.com/polymorfa/polymorfa/pull/349", + "commit": "ac3805dfa30d7efddf6e2f3a839c9fcdb7c27bfa", + "sourcePath": "apps/api/docs/openapi.json", + "sha256": "feafdba56b7014322be88e06180cae1b5d45da467b208ce7fd78832f90867196", + "operationIds": [ + "listTemplates", + "createTemplate", + "getTemplate", + "editTemplate", + "deleteTemplate" + ], + "merged": false + }, + { + "pullRequest": "https://github.com/polymorfa/polymorfa/pull/354", + "commit": "22d38f144d4b7ab0fb9953a0aacd521b63a496bb", + "sourcePath": "docs/api-reference/openapi.graph.json", + "sha256": "a52432d517b67e733abd18fadd44fcf4efd5918a506ef2f036a4bb2c34bfdeb5", + "operationIds": ["graphListProductCatalogs"], + "merged": false } ] } diff --git a/contracts/coverage.json b/contracts/coverage.json index b963907a..48ad58f6 100644 --- a/contracts/coverage.json +++ b/contracts/coverage.json @@ -1,10 +1,10 @@ { "schemaVersion": 1, - "sourceCommit": "d25ddad0c48c3444f235bc2d6ce5c23e736d3318", + "sourceCommit": "45e6a08c65821b484585f7b4f091a93b3a47ff43", "excludedContracts": [ { "family": "graph", - "reason": "Graph-compatible APIs are outside the initial SDK scope." + "reason": "Full Graph API coverage is outside the initial ledger; cloudCatalogs.list is separately implemented and pinned in cloud-api-supplements.json." } ], "operations": [ @@ -112,7 +112,7 @@ "method": "DELETE", "path": "/messaging/{session}/templates/{name}", "operationId": "deleteTemplate", - "fingerprint": "fd81558b70fcd861a0005d048a021c156d125961dbd9e568b20fce3dfae8b024", + "fingerprint": "c264de07bacb913a72c33898982627d9dbda9cf998eee7cde38e1a202fbbf531", "typescript": { "status": "covered", "method": "MessagingClient.cloudTemplates.delete" @@ -1456,7 +1456,7 @@ "method": "POST", "path": "/messaging/{session}/templates", "operationId": "createTemplate", - "fingerprint": "03ae820a0ac6d1b178df82fe9e7c296a22e53ac258f19f3e8d76225269a89c5a", + "fingerprint": "b0e120e3ae0f2741a1073415af2894d9fb0d458deb596b01faf855e1e2c2e977", "typescript": { "status": "covered", "method": "MessagingClient.cloudTemplates.create" diff --git a/contracts/openapi.messaging.json b/contracts/openapi.messaging.json index d24ca530..090c0847 100644 --- a/contracts/openapi.messaging.json +++ b/contracts/openapi.messaging.json @@ -44244,6 +44244,27 @@ } } } + }, + "default": { + "description": "Meta returned a 400-599 error status; the mutation may have an unknown outcome", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "api_error", + "code": "internal_error", + "message": "An unexpected service failure occurred. Keep the request ID for support.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#internal-error" + } + } + } } }, "x-mint": { @@ -44627,6 +44648,27 @@ } } } + }, + "default": { + "description": "Meta returned a 400-599 error status; the mutation may have an unknown outcome", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "api_error", + "code": "internal_error", + "message": "An unexpected service failure occurred. Keep the request ID for support.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#internal-error" + } + } + } } }, "x-mint": { diff --git a/contracts/source.json b/contracts/source.json index b036b980..2d16836c 100644 --- a/contracts/source.json +++ b/contracts/source.json @@ -1,6 +1,6 @@ { "repository": "polymorfa/polymorfa", - "commit": "d25ddad0c48c3444f235bc2d6ce5c23e736d3318", + "commit": "45e6a08c65821b484585f7b4f091a93b3a47ff43", "branch": "codex/oa-01-cloud-templates", "published": false, "note": "Coordinated API dependency PR310 adds four Number-scoped Cloud template operations. Snapshots are byte-identical to this exact API commit. Merge, SDK publication and deployed availability remain separate.", @@ -8,7 +8,7 @@ "messaging": { "sourcePath": "apps/api/docs/openapi.json", "snapshotPath": "contracts/openapi.messaging.json", - "sha256": "1a1edd1d9623bba5939dc9473446a7d56b7448b84a0b5016645218a95c3d1d0c" + "sha256": "9267d9de742077395e774a688b52cbbdbc71b19bf1c71fc053f89164c7e0f157" }, "platform": { "sourcePath": "apps/api/docs/openapi.management.json", diff --git a/examples/full-platform/ui/pages/connections.tsx b/examples/full-platform/ui/pages/connections.tsx index bfd623d7..4cd575f4 100644 --- a/examples/full-platform/ui/pages/connections.tsx +++ b/examples/full-platform/ui/pages/connections.tsx @@ -202,7 +202,7 @@ export function ConnectionsPage() { "session.status": (event) => setData((current) => current?.map((connection) => - connection.id === event.session + connection.id === event.session && "status" in event.payload ? { ...connection, status: event.payload.status } : connection, ), diff --git a/examples/full-platform/ui/pages/templates.tsx b/examples/full-platform/ui/pages/templates.tsx index 4c455d25..ae9f5cb6 100644 --- a/examples/full-platform/ui/pages/templates.tsx +++ b/examples/full-platform/ui/pages/templates.tsx @@ -79,9 +79,12 @@ export function TemplatesPage() { // Meta's review result arrives as a `template.status` webhook. useLiveEvents({ "template.status": ({ payload }) => { - toast( - `Template ${payload.templateName} is now ${payload.status.toLowerCase()}`, - ); + const status = "kind" in payload ? payload.event : payload.status; + const quality = "kind" in payload ? payload.newQualityScore : undefined; + const name = payload.templateName ?? payload.templateId ?? "notification"; + if (status) toast(`Template ${name} is now ${status.toLowerCase()}`); + else if (quality) + toast(`Template ${name} quality is now ${quality.toLowerCase()}`); templates.reload(); }, }); diff --git a/examples/full-platform/ui/shell.tsx b/examples/full-platform/ui/shell.tsx index 10a0269d..55991a3f 100644 --- a/examples/full-platform/ui/shell.tsx +++ b/examples/full-platform/ui/shell.tsx @@ -108,7 +108,7 @@ function ConnectionHealth() { setBootstrap((current) => ({ ...current, connections: current.connections.map((connection) => - connection.id === event.session + connection.id === event.session && "status" in event.payload ? { ...connection, status: event.payload.status } : connection, ), diff --git a/packages/store/README.md b/packages/store/README.md index 19dc4dae..526c6e37 100644 --- a/packages/store/README.md +++ b/packages/store/README.md @@ -43,6 +43,10 @@ Each `ingest()` call: 5. Writes the whole batch in one transaction, then notifies subscribers in this tab and, through `BroadcastChannel`, in other tabs. +Meta account notices are stored in `sessions.cloudAccountNotification`; they +do not overwrite connection status. Cloud template status and quality updates +set the matching template fields and preserve language and WABA references. + Reducers never replace newer state with older state. Message status only moves forward (`pending`, `sent`, `delivered`, `read`, `played`); other fields keep the time of the event that set them and ignore older updates. A deleted diff --git a/packages/store/src/reducers.ts b/packages/store/src/reducers.ts index 407bb9be..7a00f08e 100644 --- a/packages/store/src/reducers.ts +++ b/packages/store/src/reducers.ts @@ -884,10 +884,20 @@ const templateStatus: StoreReducer = async (event, context) => { existing, pick({ templateName: str(payload.templateName), - status: str(payload.status), + status: str( + payload.kind === "message_template_status_update" + ? payload.event + : payload.status, + ), category: str(payload.category), reason: str(payload.reason), - qualityRating: str(payload.qualityRating), + qualityRating: str( + payload.kind === "message_template_quality_update" + ? payload.newQualityScore + : payload.qualityRating, + ), + language: str(payload.language), + wabaId: str(payload.wabaId), }), context.time, ), @@ -938,10 +948,20 @@ export const BUILT_IN_REDUCERS: Readonly> = { "call.participant_left": callParticipant, "call.telemetry": callTelemetry, "labels.update": labelsUpdate, - "session.status": sessionEvent((payload) => ({ - status: str(payload.status), - statusReason: str(payload.statusReason), - })), + "session.status": sessionEvent((payload) => + payload.source === "meta" + ? { + cloudAccountNotification: { + kind: str(payload.kind), + wabaId: str(payload.wabaId), + value: payload.value, + }, + } + : { + status: str(payload.status), + statusReason: str(payload.statusReason), + }, + ), "session.connected": sessionEvent((payload) => ({ status: "connected", phoneNumber: str(payload.phoneNumber), diff --git a/packages/store/src/types.ts b/packages/store/src/types.ts index 4692fd7c..1bf889a9 100644 --- a/packages/store/src/types.ts +++ b/packages/store/src/types.ts @@ -192,6 +192,12 @@ export interface StoredLabel extends StoredRow { } export interface StoredSession extends StoredRow { + /** Latest Meta account notice, independent of the runtime connection state. */ + readonly cloudAccountNotification?: { + readonly kind?: string; + readonly wabaId?: string; + readonly value: unknown; + }; readonly status?: string; readonly statusReason?: string; readonly phoneNumber?: string; @@ -209,6 +215,8 @@ export interface StoredSession extends StoredRow { } export interface StoredTemplate extends StoredRow { + readonly language?: string; + readonly wabaId?: string; readonly templateName?: string; readonly status?: string; readonly category?: string; diff --git a/packages/store/test/store.test.ts b/packages/store/test/store.test.ts index 8d7354f1..9427066f 100644 --- a/packages/store/test/store.test.ts +++ b/packages/store/test/store.test.ts @@ -9,6 +9,68 @@ import { import { BASE_TIME, chat, event, openStore, received } from "./helpers.js"; describe("createPolymorfaStore routing", () => { + it("keeps Meta account notices separate and applies provider template status and quality", async () => { + const store = await openStore(); + await store.ingest([ + event("session.status", { status: "connected" }, { at: 1000 }), + event( + "session.status", + { + source: "meta", + kind: "account_alerts", + wabaId: "waba_1", + value: { alert_type: "info" }, + }, + { at: 2000 }, + ), + event( + "template.status", + { + kind: "message_template_status_update", + templateId: "98765432109876543210", + templateName: "welcome", + event: "APPROVED", + language: "en_US", + wabaId: "waba_1", + }, + { at: 2000 }, + ), + event( + "template.status", + { + kind: "message_template_quality_update", + templateId: "98765432109876543210", + newQualityScore: "GREEN", + }, + { at: 3000 }, + ), + event( + "template.status", + { + kind: "message_template_quality_update", + templateId: "98765432109876543210", + newQualityScore: "RED", + }, + { at: 2500 }, + ), + ]); + expect(await store.sessions.get("support")).toMatchObject({ + status: "connected", + cloudAccountNotification: { + kind: "account_alerts", + wabaId: "waba_1", + value: { alert_type: "info" }, + }, + }); + expect(await store.templates.get("98765432109876543210")).toMatchObject({ + status: "APPROVED", + qualityRating: "GREEN", + language: "en_US", + wabaId: "waba_1", + }); + store.close(); + }); + it("files messages and updates the conversation summary", async () => { const store = await openStore(); expect(store.mode).toBe("indexeddb"); diff --git a/packages/typescript/README.md b/packages/typescript/README.md index c66e4af6..11f29120 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -310,19 +310,54 @@ await messaging.templates.preview("support", created.data.data.id, { Keep this client on the server. Browser builders use an application-owned route, such as `createTemplateBuilderRoute` from `@polymorfa/nextjs`. +## Flow drafts and provider lifecycle + +Use `client.project(projectId).flows` for project Flow drafts. A client created +with a project token exposes the same resource for its configured project. +`list`, `retrieve`, `create`, `update`, and `delete` manage local drafts. Retrieval +returns `null` when the API has no matching draft; updates require the observed +`expectedUpdatedAt` value. Draft methods and provider methods remain distinct. + +```ts +const flows = client.project(projectId).flows; +const draft = await flows.create({ + name: "Booking", + definition: flowJson, +}); +const uploaded = await flows.upload(draft.data.id, { + sessionId: "support", + categories: ["APPOINTMENT_BOOKING"], + requestId: crypto.randomUUID(), +}); +console.log(uploaded.data.operation?.state); +``` + +`upload`, `publish`, `deprecate`, `discard`, and `sync` operate on a Number in the +same project; `receipts` reads its recorded operations. Reads require +`sessions:read`; mutations require `sessions:manage`. Provider actions require +the API's beta access and a compatible Number. Publishing uses the validated +upload. Discard removes a provider draft; delete removes the local draft. + +Writes make one transport attempt even with retry options or an Idempotency-Key. +The optional body `requestId` identifies one provider operation. A response can +contain an `uncertain` receipt with HTTP 200; inspect its state. Use `sync` to +reconcile provider state, preserving the original request identity. Do not repeat +an uncertain write with a new ID. No provider lifecycle method enrolls a project +or establishes deployed availability. + ## Official API Numbers These server methods require the matching API deployment and the team's beta access. An SDK method does not enroll a team. Keep server credentials out of browser code. -`messaging.cloudTemplates` lists, retrieves, creates and deletes Meta templates +`messaging.cloudTemplates` lists, retrieves, creates, edits and deletes Meta templates for a Number. It is separate from `messaging.templates`, which manages project -drafts. The native template API requires a Number created with an Official API -connection. A Number created with Linked Devices that later added an Official -API connection is rejected with `400` by this API revision. Retrieval accepts +drafts. The native template API requires an enabled Official API connection. For a +Hybrid Number it uses that exact connection and its WABA; a retired or disabled +Official connection cannot supply authority. Retrieval accepts an optional language; the API defaults to `en_US`. -Deleting a name deletes all its languages. Create and delete make one attempt, +Deleting a name deletes all its languages. Create, edit and delete make one attempt, even if an idempotency key or a retry override is supplied. Reconcile an uncertain result before submitting another write. @@ -337,6 +372,35 @@ const template = await messaging.cloudTemplates.retrieve( ); ``` +`messaging.cloudCatalogs.list(wabaId, { version: "v26.0", limit: 25, after })` +reads catalog IDs and names visible to the connected WABA credentials. It +requires `sessions:read` and Graph access. The response retains opaque cursors; +it does not expose upstream pagination URLs or grant merchant ownership. Use +an organization API key or project token, with the latter confined to its project. + +`messaging.cloudTemplates.update(number, name, { components }, { language })` +submits an edit to one template language. A `202` response contains +`{ accepted: true, name, language }`; it does not establish approval to send. +Read the template again to inspect its status. + +`messaging.messages.setTyping(number, { conversation, state: "typing", id })` +requires an inbound message ID on Official Numbers. It marks that message read +and displays typing until a reply is sent or 25 seconds pass. Official Numbers +reject `recording` and `paused`; Linked Device Numbers retain those states and +do not require `id`. + +`messaging.quickLinks.retrieve(id)` preserves `onboarding.sync` request receipts +and history-delivery observations. A connected Number does not establish that +contacts or history were delivered. Accepted requests and `unknown` outcomes +remain distinct; do not repeat a one-time sync request based on an unknown state. + +Narrow `session.status` payloads by `source: "meta"` before reading account +notifications. They have `kind`, optional `wabaId`, and `value`, without a runtime +`status`. Cloud `template.status` payloads have `kind` and optional provider +fields such as `event`, `language`, `previousQualityScore`, and `newQualityScore`. +Runtime notifications retain their own typed payloads. Webhook envelope IDs +identify an occurrence; retain them when deduplicating a delivery retry. + `messaging.chats.getServiceWindow(number, conversation)` reads `open`, `closed` or `unknown` with observation timestamps. It requires `chats:read` and service window beta enrollment, without requiring HMS. `unknown` does not establish diff --git a/packages/typescript/src/client.ts b/packages/typescript/src/client.ts index 477e8ebd..2422f447 100644 --- a/packages/typescript/src/client.ts +++ b/packages/typescript/src/client.ts @@ -1,3 +1,4 @@ +import { FlowsResource } from "./platform/flows.js"; import { FunctionsResource } from "./platform/functions.js"; import { SessionConfigurationResource } from "./platform/session-configuration.js"; import { @@ -100,6 +101,7 @@ export interface OrganizationControlPlaneResources { } export interface ProjectControlPlaneResources { + readonly flows: FlowsResource; readonly functions: FunctionsResource; } @@ -116,6 +118,7 @@ export interface ClientConstructor { class ClientImplementation implements ClientBase { /** Installed only for project instances; the public conditional type reflects that. */ declare readonly functions: FunctionsResource; + declare readonly flows: FlowsResource; readonly owner: ClientOwner; readonly projectId: string | null; readonly events: EventsResource; @@ -201,6 +204,7 @@ class ClientImplementation implements ClientBase { if (projectId !== null) Object.assign(this, { functions: new FunctionsResource(this.#transport, projectId), + flows: new FlowsResource(this.#transport, projectId), }); if (this.owner === "organization") { diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts index a4842e55..b99fa81f 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -60,6 +60,12 @@ export { type PolymorfaRateLimitReason, } from "./errors.js"; export { MessagingClient } from "./messaging/client.js"; +export { CloudCatalogsResource } from "./messaging/cloud-catalogs.js"; +export type { + CloudProductCatalog, + ListCloudCatalogsParams, + ListCloudCatalogsResponse, +} from "./messaging/cloud-catalogs.js"; export { BridgeClient, BridgeRoutesResource, @@ -242,6 +248,9 @@ export { type QuickLink, type QuickLinkStatus, type QuickLinkStatusValue, + type CloudSyncStatus, + type CloudSyncRequest, + type CloudSyncDelivery, type QuickLinkPurpose, type QuickLinkConnectionGoal, type QuickLinkConnectionKind, @@ -257,6 +266,8 @@ export { type CloudTemplate, type CloudTemplateStatus, type CreateCloudTemplateRequest, + type EditCloudTemplateRequest, + type EditCloudTemplateResponse, type RetrieveCloudTemplateParams, type CloudTemplateResponse, type ListCloudTemplatesResponse, @@ -1030,6 +1041,8 @@ export { type SessionRestrictionUpdatedPayload, type SessionPhoneOfflinePayload, type SessionStatusPayload, + type RuntimeSessionStatusPayload, + type CloudAccountStatusPayload, type UnknownWebhookEvent, type WebhookBody, type WebhookFixture, @@ -1081,6 +1094,8 @@ export { type MessageFailedPayload, type MessageFailedReason, type TemplateStatusPayload, + type RuntimeTemplateStatusPayload, + type CloudTemplateStatusPayload, type UsageRecordedPayload, type VoiceAssetEventPayload, type VoiceAssetFailedPayload, @@ -1114,3 +1129,19 @@ export { } from "./messaging/hybrid-link.js"; export * from "./platform/functions.js"; + +export { + FlowsResource, + type FlowCategory, + type FlowDraftStatus, + type FlowValidationPointer, + type FlowValidationIssue, + type FlowNumberLink, + type FlowSummary, + type FlowDraft, + type CreateFlowRequest, + type UpdateFlowRequest, + type FlowProviderRequest, + type FlowProviderOperation, + type FlowProviderResult, +} from "./platform/flows.js"; diff --git a/packages/typescript/src/messaging/client.ts b/packages/typescript/src/messaging/client.ts index fa8181e4..6c43e5e9 100644 --- a/packages/typescript/src/messaging/client.ts +++ b/packages/typescript/src/messaging/client.ts @@ -29,6 +29,7 @@ import { QuickLinksResource } from "./quicklinks.js"; import { SessionsResource } from "./sessions.js"; import { TemplatesResource } from "./templates.js"; import { CloudTemplatesResource } from "./cloud-templates.js"; +import { CloudCatalogsResource } from "./cloud-catalogs.js"; import { UsersResource } from "./users.js"; import { VoipResource } from "./voip.js"; import { WebhooksResource } from "./webhooks.js"; @@ -59,6 +60,7 @@ export class MessagingClient { readonly quickLinks: QuickLinksResource; readonly templates: TemplatesResource; readonly cloudTemplates: CloudTemplatesResource; + readonly cloudCatalogs: CloudCatalogsResource; readonly users: UsersResource; readonly voip: VoipResource; readonly webhooks: WebhooksResource; @@ -105,6 +107,7 @@ export class MessagingClient { this.quickLinks = new QuickLinksResource(transport, credential.type); this.templates = new TemplatesResource(transport); this.cloudTemplates = new CloudTemplatesResource(transport); + this.cloudCatalogs = new CloudCatalogsResource(transport, credential.type); this.users = new UsersResource(transport); this.voip = new VoipResource(transport, credential.type); this.webhooks = new WebhooksResource(transport); diff --git a/packages/typescript/src/messaging/cloud-catalogs.ts b/packages/typescript/src/messaging/cloud-catalogs.ts new file mode 100644 index 00000000..dd9e0c33 --- /dev/null +++ b/packages/typescript/src/messaging/cloud-catalogs.ts @@ -0,0 +1,54 @@ +import type { MessagingCredential } from "../credentials.js"; +import { PolymorfaConfigurationError } from "../errors.js"; +import { HttpTransport } from "../transport/http.js"; +import type { ApiResponse, RequestOptions } from "../transport/types.js"; + +export interface CloudProductCatalog { + readonly id: string; + readonly name?: string; +} +export interface ListCloudCatalogsParams { + /** Meta Graph version, for example v26.0. */ + readonly version: string; + /** Page size from 1 to 100. The API defaults to 25. */ + readonly limit?: number; + readonly after?: string; +} +export interface ListCloudCatalogsResponse { + readonly data: readonly CloudProductCatalog[]; + readonly paging?: { + readonly cursors: { readonly before?: string; readonly after?: string }; + }; +} + +/** Reads catalog links visible to the authenticated WABA. Requires sessions:read. */ +export class CloudCatalogsResource { + constructor( + private readonly transport: HttpTransport, + private readonly credentialType: MessagingCredential["type"], + ) {} + + list( + wabaId: string, + params: ListCloudCatalogsParams, + options: RequestOptions = {}, + ): Promise> { + if (this.credentialType === "clientToken") { + throw new PolymorfaConfigurationError( + "Official API catalogs require an organization API key or project token.", + "credential", + ); + } + if (!wabaId.trim() || !params.version.trim()) { + throw new PolymorfaConfigurationError( + "Provide a WABA ID and Graph version.", + ); + } + return this.transport.request({ + method: "GET", + path: `/graph/whatsapp/${encodeURIComponent(params.version)}/${encodeURIComponent(wabaId)}/product_catalogs`, + query: { limit: params.limit, after: params.after }, + ...options, + }); + } +} diff --git a/packages/typescript/src/messaging/cloud-templates.ts b/packages/typescript/src/messaging/cloud-templates.ts index 4426d05e..2049d736 100644 --- a/packages/typescript/src/messaging/cloud-templates.ts +++ b/packages/typescript/src/messaging/cloud-templates.ts @@ -42,6 +42,16 @@ export interface CreateCloudTemplateRequest { readonly components: readonly unknown[]; } +export interface EditCloudTemplateRequest { + readonly components: readonly unknown[]; +} + +export type EditCloudTemplateResponse = SuccessEnvelope<{ + readonly accepted: true; + readonly name: string; + readonly language: string; +}>; + export interface RetrieveCloudTemplateParams { /** Defaults to en_US at the API when omitted. */ readonly language?: string; @@ -97,6 +107,24 @@ export class CloudTemplatesResource { }); } + /** Acceptance is not approval. Read the template before retrying an uncertain edit. */ + update( + session: string, + name: string, + body: EditCloudTemplateRequest, + params: RetrieveCloudTemplateParams = {}, + options: RequestOptions = {}, + ): Promise> { + return this.transport.request({ + method: "PATCH", + path: templatePath(session, name), + query: { language: params.language }, + body, + ...options, + maxNetworkRetries: 0, + }); + } + /** Delete every language of this name once; reconcile an uncertain outcome before retrying. */ delete( session: string, diff --git a/packages/typescript/src/messaging/messages.ts b/packages/typescript/src/messaging/messages.ts index 673bb0f6..305484d7 100644 --- a/packages/typescript/src/messaging/messages.ts +++ b/packages/typescript/src/messaging/messages.ts @@ -56,6 +56,7 @@ export class MessagesResource { return this.post(session, "seen", body, options); } + /** Official Numbers require an inbound id and state typing; marks it read for an indicator lasting at most 25 seconds. */ setTyping( session: string, body: TypingRequest, diff --git a/packages/typescript/src/messaging/quicklinks.ts b/packages/typescript/src/messaging/quicklinks.ts index 741139b0..fa52ea92 100644 --- a/packages/typescript/src/messaging/quicklinks.ts +++ b/packages/typescript/src/messaging/quicklinks.ts @@ -27,7 +27,7 @@ export interface QuickLinkConfiguration extends SessionConfigurationOverrides { }; } -export type QuickLinkPurpose = "initial" | "add_connection"; +export type QuickLinkPurpose = "initial" | "add_connection" | "reauthorization"; export type QuickLinkConnectionGoal = "single" | "hybrid"; export type QuickLinkConnectionKind = "linked_devices" | "official_api"; export type QuickLinkHybridPhase = @@ -61,6 +61,36 @@ export interface QuickLink { export type QuickLinkStatusValue = "pending" | "opened" | "linked" | "connected" | "failed" | "cancelled"; +export type CloudSyncRequest = + | "not_applicable" + | "not_requested" + | "pending" + | "requesting" + | "accepted" + | "declined" + | "unknown"; +export type CloudSyncDelivery = + | "not_applicable" + | "not_requested" + | "unconfirmed" + | "partial" + | "complete" + | "declined"; + +export interface CloudSyncStatus { + readonly contacts: { + /** An accepted request receipt does not prove all contacts were delivered. */ + readonly request: CloudSyncRequest; + readonly receiptRecorded: boolean; + }; + readonly history: { + readonly request: CloudSyncRequest; + readonly receiptRecorded: boolean; + /** Meta-reported delivery progress, independent of connection readiness. */ + readonly delivery: CloudSyncDelivery; + }; +} + export interface QuickLinkStatus { readonly purpose: QuickLinkPurpose; readonly connectionGoal: QuickLinkConnectionGoal; @@ -73,6 +103,7 @@ export interface QuickLinkStatus { readonly contactsSync: string; readonly historySync: string; readonly historyProgress: number; + readonly sync: CloudSyncStatus; readonly errorCode: string | null; } | null; readonly id: string; diff --git a/packages/typescript/src/messaging/types.ts b/packages/typescript/src/messaging/types.ts index 24a9fc92..57d61e8c 100644 --- a/packages/typescript/src/messaging/types.ts +++ b/packages/typescript/src/messaging/types.ts @@ -2250,6 +2250,8 @@ export interface SeenRequest { export interface TypingRequest { readonly conversation: ConversationReference; + /** Required on Official Numbers: an inbound message to mark read while showing typing. */ + readonly id?: string; readonly state: "typing" | "recording" | "paused"; } diff --git a/packages/typescript/src/platform/flows.ts b/packages/typescript/src/platform/flows.ts new file mode 100644 index 00000000..85493183 --- /dev/null +++ b/packages/typescript/src/platform/flows.ts @@ -0,0 +1,224 @@ +import { PolymorfaConfigurationError } from "../errors.js"; +import { HttpTransport } from "../transport/http.js"; +import type { + ApiResponse, + RawRequest, + RequestOptions, +} from "../transport/types.js"; +import { type DataEnvelope, unwrapResponse } from "./response.js"; + +export type FlowCategory = + | "SIGN_UP" + | "SIGN_IN" + | "APPOINTMENT_BOOKING" + | "LEAD_GENERATION" + | "CONTACT_US" + | "CUSTOMER_SUPPORT" + | "SURVEY" + | "OTHER"; +export type FlowDraftStatus = "draft" | "ready" | "archived"; +export interface FlowValidationPointer { + readonly path?: string; + readonly lineStart?: number; + readonly lineEnd?: number; + readonly columnStart?: number; + readonly columnEnd?: number; +} +export interface FlowValidationIssue extends Omit< + FlowValidationPointer, + "path" +> { + readonly error?: string; + readonly errorType?: string; + readonly message?: string; + readonly pointers?: readonly FlowValidationPointer[]; +} +export interface FlowNumberLink { + readonly session: string; + readonly wabaId: string; + readonly metaFlowId: string; + readonly status: string; + readonly categories: readonly string[]; + readonly validationErrors: readonly FlowValidationIssue[]; + readonly uploadState: + "stale" | "creating" | "uploading" | "valid" | "invalid" | "failed"; + readonly previewUrl?: string; + readonly previewExpiresAt?: number; + readonly lastSyncedAt: number; + readonly definitionDigest?: string; + readonly simulated?: boolean; +} +export interface FlowSummary { + readonly id: string; + readonly name: string; + readonly status: FlowDraftStatus; + readonly version: string; + readonly screenCount: number; + readonly metaLinks: readonly FlowNumberLink[]; + readonly createdAt: number; + readonly updatedAt: number; +} +export interface FlowDraft extends FlowSummary { + readonly definition: Readonly>; +} +export interface CreateFlowRequest { + readonly draftId?: string; + readonly name: string; + readonly definition: Readonly>; +} +export interface UpdateFlowRequest { + readonly expectedUpdatedAt: number; + readonly name?: string; + readonly status?: FlowDraftStatus; + readonly definition?: Readonly>; +} +export interface FlowProviderRequest { + readonly sessionId: string; + readonly categories?: readonly FlowCategory[]; + /** Reuse for the exact same operation; an uncertain operation is reconciled by sync. */ + readonly requestId?: string; +} +export interface FlowProviderOperation { + readonly id: string; + readonly requestId: string | null; + readonly flowId: string; + readonly flowName: string; + readonly sessionId: string; + readonly session: string; + readonly action: "create" | "upload" | "publish" | "deprecate" | "delete"; + readonly state: + "pending" | "succeeded" | "rejected" | "uncertain" | "superseded"; + readonly resolution: "response" | "reconciled" | "superseded" | null; + readonly wabaId: string | null; + readonly metaFlowId: string | null; + readonly definitionDigest: string | null; + readonly providerStatus: string | null; + readonly errorCode: string | null; + readonly providerCode: number | null; + readonly providerSubcode: number | null; + readonly createdAt: number; + readonly updatedAt: number; + readonly completedAt: number | null; +} +export interface FlowProviderResult { + readonly operation: FlowProviderOperation | null; + readonly flow: FlowDraft; +} + +/** Project Flow drafts and their Number-bound provider lifecycle. */ +export class FlowsResource { + constructor( + private readonly transport: HttpTransport, + private readonly projectId: string, + ) {} + + list( + options: RequestOptions = {}, + ): Promise> { + return this.request("GET", "", undefined, options); + } + create( + body: CreateFlowRequest, + options: RequestOptions = {}, + ): Promise> { + return this.request("POST", "", body, options); + } + retrieve( + flowId: string, + options: RequestOptions = {}, + ): Promise> { + return this.request("GET", flowPath(flowId), undefined, options); + } + update( + flowId: string, + body: UpdateFlowRequest, + options: RequestOptions = {}, + ): Promise> { + return this.request("PATCH", flowPath(flowId), body, options); + } + delete( + flowId: string, + options: RequestOptions = {}, + ): Promise> { + return this.request("DELETE", flowPath(flowId), undefined, options); + } + upload( + flowId: string, + body: FlowProviderRequest, + options: RequestOptions = {}, + ): Promise> { + return this.request("POST", `${flowPath(flowId)}/upload`, body, options); + } + publish( + flowId: string, + body: FlowProviderRequest, + options: RequestOptions = {}, + ): Promise> { + return this.request("POST", `${flowPath(flowId)}/publish`, body, options); + } + deprecate( + flowId: string, + body: FlowProviderRequest, + options: RequestOptions = {}, + ): Promise> { + return this.request("POST", `${flowPath(flowId)}/deprecate`, body, options); + } + discard( + flowId: string, + body: FlowProviderRequest, + options: RequestOptions = {}, + ): Promise> { + return this.request("POST", `${flowPath(flowId)}/discard`, body, options); + } + /** Reconcile with a provider read. This does not repeat an uncertain mutation. */ + sync( + flowId: string, + body: FlowProviderRequest, + options: RequestOptions = {}, + ): Promise> { + return this.request("POST", `${flowPath(flowId)}/sync`, body, options); + } + receipts( + flowId: string, + options: RequestOptions = {}, + ): Promise> { + return this.request( + "GET", + `${flowPath(flowId)}/receipts`, + undefined, + options, + ); + } + private request( + method: RawRequest["method"], + path: string, + input: object | undefined, + options: RequestOptions, + ): Promise> { + if ( + input && + (Object.hasOwn(input, "projectId") || Object.hasOwn(input, "flowId")) + ) + throw new PolymorfaConfigurationError( + "Flow input cannot override its project or path identity.", + "input", + ); + const value = { ...input, projectId: this.projectId }; + return this.transport + .request>({ + ...options, + method, + path: `/platform/flows${path}`, + ...(method === "GET" || method === "DELETE" + ? { query: value } + : { body: value }), + ...(method === "GET" ? {} : { maxNetworkRetries: 0 }), + }) + .then(unwrapResponse); + } +} +function flowPath(id: string): string { + if (!id.trim()) + throw new PolymorfaConfigurationError("Flow ID is required.", "flowId"); + return `/${encodeURIComponent(id)}`; +} diff --git a/packages/typescript/src/webhooks/events.ts b/packages/typescript/src/webhooks/events.ts index 9b8d8729..03070ff2 100644 --- a/packages/typescript/src/webhooks/events.ts +++ b/packages/typescript/src/webhooks/events.ts @@ -247,11 +247,27 @@ export interface PollVotePayload { readonly timestamp: number; } -export interface SessionStatusPayload { +export interface RuntimeSessionStatusPayload { readonly status: string; readonly statusReason?: string; + readonly banCode?: number; + readonly banReason?: string; + /** Unix seconds. */ + readonly banExpiresAt?: number; + readonly detail?: string; +} + +/** A Meta account notification, not a runtime connection-state transition. */ +export interface CloudAccountStatusPayload { + readonly source: "meta"; + readonly kind: "account_alerts" | "account_update"; + readonly wabaId?: string; + readonly value: Readonly>; } +export type SessionStatusPayload = + RuntimeSessionStatusPayload | CloudAccountStatusPayload; + export type SessionRestrictionType = "reachout_timelock"; export interface SessionRestrictionUpdatedPayload { @@ -804,7 +820,7 @@ export interface MessageFailedPayload { readonly timestamp: number; } -export interface TemplateStatusPayload { +export interface RuntimeTemplateStatusPayload { readonly templateName: string; readonly templateId: string; readonly status: string; @@ -813,6 +829,22 @@ export interface TemplateStatusPayload { readonly qualityRating: string; } +export interface CloudTemplateStatusPayload { + readonly kind: + "message_template_status_update" | "message_template_quality_update"; + readonly event?: string; + readonly templateId?: string; + readonly templateName?: string; + readonly language?: string; + readonly reason?: string; + readonly previousQualityScore?: string; + readonly newQualityScore?: string; + readonly wabaId?: string; +} + +export type TemplateStatusPayload = + RuntimeTemplateStatusPayload | CloudTemplateStatusPayload; + export interface CampaignLaunchedPayload { readonly campaignId: string; readonly name: string; diff --git a/packages/typescript/src/webhooks/index.ts b/packages/typescript/src/webhooks/index.ts index f5203fb5..6be6b8b1 100644 --- a/packages/typescript/src/webhooks/index.ts +++ b/packages/typescript/src/webhooks/index.ts @@ -100,7 +100,11 @@ export { type SessionRestrictionUpdatedPayload, type SessionPhoneOfflinePayload, type SessionStatusPayload, + type RuntimeSessionStatusPayload, + type CloudAccountStatusPayload, type TemplateStatusPayload, + type RuntimeTemplateStatusPayload, + type CloudTemplateStatusPayload, type UsageRecordedPayload, type UnknownWebhookEvent, type VoiceAssetEventPayload, diff --git a/packages/typescript/test/cloud-catalogs.test.ts b/packages/typescript/test/cloud-catalogs.test.ts new file mode 100644 index 00000000..18de3c9f --- /dev/null +++ b/packages/typescript/test/cloud-catalogs.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it } from "vitest"; +import { MessagingClient } from "../src/index.js"; +import { PROJECT_TOKEN } from "./support/credentials.js"; +import { startTestServer, type TestServer } from "./support/http-server.js"; +const servers: TestServer[] = []; +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())); +}); +describe("Official API catalog discovery", () => { + it("preserves opaque catalog IDs and cursors through the exact Graph route", async () => { + const payload = { + data: [{ id: "98765432109876543210", name: "Shop" }], + paging: { cursors: { after: "cursor+/=" } }, + }; + const server = await startTestServer(() => ({ + body: JSON.stringify(payload), + })); + servers.push(server); + const client = new MessagingClient({ + credential: { type: "projectToken", value: PROJECT_TOKEN }, + baseUrl: server.url, + }); + expect( + ( + await client.cloudCatalogs.list("12345678901234567890", { + version: "v26.0", + limit: 50, + after: "cursor+/=", + }) + ).data, + ).toEqual(payload); + expect(server.requests[0]!.path).toBe( + "/graph/whatsapp/v26.0/12345678901234567890/product_catalogs?limit=50&after=cursor%2B%2F%3D", + ); + expect(server.requests[0]!.headers.authorization).toBe( + `Bearer ${PROJECT_TOKEN}`, + ); + }); + it("preserves Meta permission errors without retrying forbidden reads", async () => { + const server = await startTestServer(() => ({ + status: 403, + body: JSON.stringify({ + error: { + message: "Permission required", + code: 200, + type: "OAuthException", + }, + }), + })); + servers.push(server); + const client = new MessagingClient({ + credential: { type: "projectToken", value: PROJECT_TOKEN }, + baseUrl: server.url, + maxNetworkRetries: 2, + }); + await expect( + client.cloudCatalogs.list("123", { version: "v26.0" }), + ).rejects.toMatchObject({ status: 403 }); + expect(server.requests).toHaveLength(1); + }); + it("rejects client credentials before dispatch", () => { + const client = new MessagingClient({ + credential: { type: "clientToken", value: `pmfa_ct_${"A".repeat(94)}` }, + }); + expect(() => + client.cloudCatalogs.list("123", { version: "v26.0" }), + ).toThrow("organization API key or project token"); + }); +}); diff --git a/packages/typescript/test/cloud-templates.test.ts b/packages/typescript/test/cloud-templates.test.ts index f13b9f6d..b560644c 100644 --- a/packages/typescript/test/cloud-templates.test.ts +++ b/packages/typescript/test/cloud-templates.test.ts @@ -69,7 +69,30 @@ describe("Cloud templates", () => { ); }); - it.each(["create", "delete"] as const)( + it("edits one language and preserves acceptance without claiming approval", async () => { + const data = { accepted: true, name: "order/update", language: "pt_BR" }; + const server = await startTestServer(() => ({ + status: 202, + body: JSON.stringify({ success: true, data }), + })); + servers.push(server); + const body = { components: [{ type: "BODY", text: "Ready" }] }; + const response = await client(server).cloudTemplates.update( + "support/eu", + "order/update", + body, + { language: "pt_BR" }, + ); + expect(response.data.data).toEqual(data); + expect(response.metadata.status).toBe(202); + expect(server.requests[0]).toMatchObject({ + method: "PATCH", + path: "/messaging/support%2Feu/templates/order%2Fupdate?language=pt_BR", + }); + expect(JSON.parse(server.requests[0]!.body)).toEqual(body); + }); + + it.each(["create", "update", "delete"] as const)( "does not replay an uncertain %s, even with a key and retry override", async (method) => { const server = await startTestServer(() => ({ @@ -96,7 +119,15 @@ describe("Cloud templates", () => { }, options, ) - : resource.delete("support", "delivery", options); + : method === "update" + ? resource.update( + "support", + "delivery", + { components: [{}] }, + { language: "en_US" }, + options, + ) + : resource.delete("support", "delivery", options); await expect(request).rejects.toMatchObject({ status: 502 }); expect(server.requests).toHaveLength(1); }, diff --git a/packages/typescript/test/coverage-reconciliation.test.ts b/packages/typescript/test/coverage-reconciliation.test.ts index e84caa82..30a2a387 100644 --- a/packages/typescript/test/coverage-reconciliation.test.ts +++ b/packages/typescript/test/coverage-reconciliation.test.ts @@ -196,7 +196,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("d25ddad0c48c3444f235bc2d6ce5c23e736d3318"); + expect(source.commit).toBe("45e6a08c65821b484585f7b4f091a93b3a47ff43"); 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 7f575a85..bf987743 100644 --- a/packages/typescript/test/coverage.test.ts +++ b/packages/typescript/test/coverage.test.ts @@ -173,7 +173,7 @@ describe("coverage checker", () => { const result = runRepositoryChecker(); expect(result.status, result.stderr).toBe(0); expect(result.report).toMatchObject({ - sourceCommit: "d25ddad0c48c3444f235bc2d6ce5c23e736d3318", + sourceCommit: "45e6a08c65821b484585f7b4f091a93b3a47ff43", total: 513, covered: 383, partial: 0, diff --git a/packages/typescript/test/flows.test.ts b/packages/typescript/test/flows.test.ts new file mode 100644 index 00000000..8fa8fb1f --- /dev/null +++ b/packages/typescript/test/flows.test.ts @@ -0,0 +1,135 @@ +import { afterEach, describe, expect, expectTypeOf, it } from "vitest"; +import { + Client, + type FlowProviderResult, + type CreateFlowRequest, + type FlowProviderRequest, +} from "../src/index.js"; +import { ORGANIZATION_API_KEY, PROJECT_TOKEN } from "./support/credentials.js"; +import { startTestServer, type TestServer } from "./support/http-server.js"; +const servers: TestServer[] = []; +afterEach(async () => { + await Promise.all(servers.splice(0).map((server) => server.close())); +}); +const projectId = "11111111-2222-4333-8444-555555555555"; +function client(server: TestServer) { + return new Client({ + credential: { type: "projectToken", value: PROJECT_TOKEN }, + projectId, + baseUrl: server.url, + maxNetworkRetries: 3, + }); +} + +describe("project Flow lifecycle", () => { + it("binds project identity on all draft and lifecycle paths", async () => { + const server = await startTestServer(() => ({ + body: JSON.stringify({ data: null }), + })); + servers.push(server); + const flows = client(server).flows; + await flows.list(); + expect((await flows.retrieve("flow/eu")).data).toBeNull(); + await flows.create({ name: "Booking", definition: { version: "7.1" } }); + await flows.update("flow/eu", { expectedUpdatedAt: 42, name: "Booking 2" }); + await flows.delete("flow/eu"); + for (const method of [ + "upload", + "publish", + "deprecate", + "discard", + "sync", + ] as const) { + await flows[method]("flow/eu", { + sessionId: "support", + categories: ["APPOINTMENT_BOOKING"], + requestId: "22222222-2222-4222-8222-222222222222", + }); + } + await flows.receipts("flow/eu"); + expect(server.requests.map((r) => `${r.method} ${r.path}`)).toEqual([ + `GET /platform/flows?projectId=${projectId}`, + `GET /platform/flows/flow%2Feu?projectId=${projectId}`, + "POST /platform/flows", + "PATCH /platform/flows/flow%2Feu", + `DELETE /platform/flows/flow%2Feu?projectId=${projectId}`, + ...["upload", "publish", "deprecate", "discard", "sync"].map( + (action) => `POST /platform/flows/flow%2Feu/${action}`, + ), + `GET /platform/flows/flow%2Feu/receipts?projectId=${projectId}`, + ]); + for (const request of server.requests.filter((r) => + ["POST", "PATCH"].includes(r.method), + )) + expect(JSON.parse(request.body)).toHaveProperty("projectId", projectId); + expect(JSON.parse(server.requests[5]!.body)).toMatchObject({ + requestId: "22222222-2222-4222-8222-222222222222", + }); + }); + it("exposes Flow authority only through a project and rejects input overrides", async () => { + const server = await startTestServer(() => ({ + body: JSON.stringify({ data: null }), + })); + servers.push(server); + const organization = new Client({ + credential: { type: "organizationApiKey", value: ORGANIZATION_API_KEY }, + baseUrl: server.url, + }); + expect(Object.hasOwn(organization, "flows")).toBe(false); + const flows = organization.project(projectId).flows; + expect(() => + flows.create({ + projectId: "another", + name: "Booking", + definition: {}, + } as CreateFlowRequest), + ).toThrow("override"); + expect(() => + flows.upload("flow", { + flowId: "another", + sessionId: "support", + } as FlowProviderRequest), + ).toThrow("override"); + expect(server.requests).toHaveLength(0); + }); + it.each(["upload", "publish", "deprecate", "discard", "sync"] as const)( + "does not repeat %s after an uncertain response", + async (action) => { + const server = await startTestServer(() => ({ + status: 503, + body: JSON.stringify({ + error: { code: "service_unavailable", message: "Unknown outcome" }, + }), + })); + servers.push(server); + await expect( + client(server).flows[action]( + "flow", + { sessionId: "support" }, + { idempotencyKey: "request", maxNetworkRetries: 4 }, + ), + ).rejects.toMatchObject({ status: 503 }); + expect(server.requests).toHaveLength(1); + }, + ); + it("preserves an uncertain receipt independently of a 200 response", async () => { + const data = { + operation: { + state: "uncertain", + flowName: "original_name", + errorCode: "provider_outcome_unknown", + }, + flow: { id: "flow" }, + }; + const server = await startTestServer(() => ({ + body: JSON.stringify({ data }), + })); + servers.push(server); + const response = await client(server).flows.upload("flow", { + sessionId: "support", + }); + expectTypeOf(response.data).toEqualTypeOf(); + expect(response.data).toEqual(data); + expectTypeOf(response.data.operation!.flowName).toEqualTypeOf(); + }); +}); diff --git a/packages/typescript/test/messages.test.ts b/packages/typescript/test/messages.test.ts index 0f1af4fe..1e5ea03f 100644 --- a/packages/typescript/test/messages.test.ts +++ b/packages/typescript/test/messages.test.ts @@ -242,6 +242,19 @@ describe("MessagingClient message routes", () => { expect(requests[0]?.headers["idempotency-key"]).toBe("send-text"); }); + it("forwards the Official typing inbound message ID without dropping it", async () => { + const { client, requests } = await messagesServer(); + const body = { + conversation: { id: "739182640518203" }, + id: "739182640518204", + state: "typing" as const, + }; + await client.messages.setTyping("support", body); + expect(requests).toHaveLength(1); + expect(JSON.parse(requests[0]!.body)).toEqual(body); + expect(requests[0]!.path).toBe("/messaging/support/messages/typing"); + }); + it("keeps actions on their exact message and chat routes", async () => { const { client, requests } = await messagesServer(); const options = { idempotencyKey: "message-action" } as const; diff --git a/packages/typescript/test/quicklinks.test.ts b/packages/typescript/test/quicklinks.test.ts index 4dca7d04..38390f58 100644 --- a/packages/typescript/test/quicklinks.test.ts +++ b/packages/typescript/test/quicklinks.test.ts @@ -20,6 +20,38 @@ afterEach(async () => { }); describe("MessagingClient.quickLinks", () => { + it("preserves a sync request receipt separately from unknown delivery", async () => { + const sync = { + contacts: { request: "accepted", receiptRecorded: true }, + history: { + request: "unknown", + receiptRecorded: false, + delivery: "unconfirmed", + }, + }; + const server = await startTestServer(() => ({ + body: JSON.stringify({ + success: true, + data: { + id: "ql_1", + status: "connected", + onboarding: { stage: "connected", sync }, + }, + }), + })); + servers.push(server); + const client = new MessagingClient({ + credential: { type: "apiKey", value: ORGANIZATION_API_KEY }, + baseUrl: server.url, + }); + const response = await client.quickLinks.retrieve("ql_1"); + expect(response.data.data.onboarding?.sync).toEqual(sync); + expectTypeOf( + response.data.data.onboarding!.sync.history.delivery, + ).toEqualTypeOf(); + expect(server.requests).toHaveLength(1); + }); + it("requires an existing session for supplementary setup at compile time", () => { const initial = {} satisfies CreateQuickLinkRequest; const supplement = { diff --git a/packages/typescript/test/webhook-contract.test.ts b/packages/typescript/test/webhook-contract.test.ts index 41a0107a..ac04f1e0 100644 --- a/packages/typescript/test/webhook-contract.test.ts +++ b/packages/typescript/test/webhook-contract.test.ts @@ -11,6 +11,7 @@ import { type ProjectWebhookDeliveryAttempt, type OrganizationWebhookDeliveryAttempt, type SessionLoggedOutPayload, + type RuntimeTemplateStatusPayload, type WebhookPayloadMap, } from "../src/index.js"; @@ -175,7 +176,11 @@ const campaign = { campaignId: "cmp_1" } as const; type P = WebhookPayloadMap; const PAYLOADS: { - readonly [K in Exclude]: Shape; + readonly [ + K in Exclude + ]: K extends "template.status" + ? Shape + : Shape; } = { "contact.opted_in": shape()( { @@ -527,7 +532,7 @@ const PAYLOADS: { "failureReason", ], ), - "template.status": shape()( + "template.status": shape()( { templateName: "order_update", templateId: "tpl_1", From bb4778d192dd7ebaf3870cc379b67164aad436fb Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 22:44:47 +0200 Subject: [PATCH 3/9] Add typed Flow encryption key client --- README.md | 4 ++- contracts/cloud-api-supplements.json | 8 ++++++ packages/typescript/README.md | 9 ++++++ packages/typescript/src/index.ts | 2 ++ packages/typescript/src/messaging/client.ts | 3 ++ .../src/messaging/flow-encryption.ts | 28 +++++++++++++++++++ 6 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 packages/typescript/src/messaging/flow-encryption.ts diff --git a/README.md b/README.md index 4f941ee7..6801b69c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,8 @@ browser runtime, shared UI contracts, Web Components, React bindings, thin Next.js server helpers, and a production-gated developer assistant. The exact Messaging and Platform sources are pinned in `contracts/source.json`; pending Official API, Flows and catalog dependencies are recorded in `contracts/cloud-api-supplements.json`. Full Graph API parity -is outside this SDK's scope; `cloudCatalogs.list` provides one typed read. +is outside this SDK's scope; `cloudCatalogs.list` and `flowEncryption` provide +typed clients for their respective Graph resources. The API includes an enrolled hosted message history beta. `MessagingClient.chats` has typed conversation and message reads for server @@ -137,6 +138,7 @@ The handwritten Messaging resources in this milestone are: - `cloudTemplates`: list, retrieve, create, edit and delete Meta templates for a Number with an Official API connection; separate from project template drafts - `cloudCatalogs`: read WABA product-catalog links through the Graph facade +- `flowEncryption`: read or register a public Flow encryption key for one Meta phone number - `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 diff --git a/contracts/cloud-api-supplements.json b/contracts/cloud-api-supplements.json index a7acfe6a..a2c6414e 100644 --- a/contracts/cloud-api-supplements.json +++ b/contracts/cloud-api-supplements.json @@ -100,6 +100,14 @@ "sha256": "a52432d517b67e733abd18fadd44fcf4efd5918a506ef2f036a4bb2c34bfdeb5", "operationIds": ["graphListProductCatalogs"], "merged": false + }, + { + "pullRequest": "https://github.com/polymorfa/polymorfa/pull/362", + "commit": "56a0d0eabc908200ed4afe6d4d9ad2ba27e8e5fa", + "sourcePath": "docs/api-reference/openapi.graph.json", + "sha256": "6e4c1fca51eab78b5a21527cacf8f39e7c27da4a2fd7e46bc5f06c2cc3bf3ba1", + "operationIds": ["graphGetFlowEncryptionKey", "graphRegisterFlowEncryptionKey"], + "merged": false } ] } diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 11f29120..4fa11eb6 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -378,6 +378,15 @@ requires `sessions:read` and Graph access. The response retains opaque cursors; it does not expose upstream pagination URLs or grant merchant ownership. Use an organization API key or project token, with the latter confined to its project. +`messaging.flowEncryption.retrieve(phoneNumberId, { version: "v26.0" })` reads +the registered public key and Meta signature status with `sessions:read`. +`messaging.flowEncryption.register(phoneNumberId, +{ businessPublicKey: publicKeyPem }, { version: "v26.0" })` replaces the key +with `sessions:manage` on an eligible Official API Number. It makes one +upstream attempt. Retain the matching private key on your endpoint and read the +registered key after an uncertain outcome. Registration affects every dynamic +Flow on the phone number; it does not enable dynamic Flow publishing. + `messaging.cloudTemplates.update(number, name, { components }, { language })` submits an edit to one template language. A `202` response contains `{ accepted: true, name, language }`; it does not establish approval to send. diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts index b99fa81f..9eebc71d 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -61,6 +61,8 @@ export { } from "./errors.js"; export { MessagingClient } from "./messaging/client.js"; export { CloudCatalogsResource } from "./messaging/cloud-catalogs.js"; +export { FlowEncryptionResource } from "./messaging/flow-encryption.js"; +export type { FlowEncryptionKey, FlowEncryptionParams, RegisterFlowEncryptionKeyRequest } from "./messaging/flow-encryption.js"; export type { CloudProductCatalog, ListCloudCatalogsParams, diff --git a/packages/typescript/src/messaging/client.ts b/packages/typescript/src/messaging/client.ts index 6c43e5e9..1b91c0da 100644 --- a/packages/typescript/src/messaging/client.ts +++ b/packages/typescript/src/messaging/client.ts @@ -30,6 +30,7 @@ import { SessionsResource } from "./sessions.js"; import { TemplatesResource } from "./templates.js"; import { CloudTemplatesResource } from "./cloud-templates.js"; import { CloudCatalogsResource } from "./cloud-catalogs.js"; +import { FlowEncryptionResource } from "./flow-encryption.js"; import { UsersResource } from "./users.js"; import { VoipResource } from "./voip.js"; import { WebhooksResource } from "./webhooks.js"; @@ -61,6 +62,7 @@ export class MessagingClient { readonly templates: TemplatesResource; readonly cloudTemplates: CloudTemplatesResource; readonly cloudCatalogs: CloudCatalogsResource; + readonly flowEncryption: FlowEncryptionResource; readonly users: UsersResource; readonly voip: VoipResource; readonly webhooks: WebhooksResource; @@ -108,6 +110,7 @@ export class MessagingClient { this.templates = new TemplatesResource(transport); this.cloudTemplates = new CloudTemplatesResource(transport); this.cloudCatalogs = new CloudCatalogsResource(transport, credential.type); + this.flowEncryption = new FlowEncryptionResource(transport, credential.type); this.users = new UsersResource(transport); this.voip = new VoipResource(transport, credential.type); this.webhooks = new WebhooksResource(transport); diff --git a/packages/typescript/src/messaging/flow-encryption.ts b/packages/typescript/src/messaging/flow-encryption.ts new file mode 100644 index 00000000..99e8b21a --- /dev/null +++ b/packages/typescript/src/messaging/flow-encryption.ts @@ -0,0 +1,28 @@ +import type { MessagingCredential } from "../credentials.js"; +import { PolymorfaConfigurationError } from "../errors.js"; +import { HttpTransport } from "../transport/http.js"; +import type { ApiResponse, RequestOptions } from "../transport/types.js"; + +export interface FlowEncryptionKey { + readonly business_public_key: string; + readonly business_public_key_signature_status: string; +} +export interface FlowEncryptionParams { readonly version: string; } +export interface RegisterFlowEncryptionKeyRequest { readonly businessPublicKey: string; } + +/** Public-key registration affects every dynamic Flow on this Meta phone number. */ +export class FlowEncryptionResource { + constructor(private readonly transport: HttpTransport, private readonly credentialType: MessagingCredential["type"]) {} + retrieve(phoneNumberId: string, params: FlowEncryptionParams, options: RequestOptions = {}): Promise> { + return this.transport.request({ method: "GET", path: this.path(phoneNumberId, params), ...options }); + } + /** Send once; read the registered key after an uncertain outcome before another write. */ + register(phoneNumberId: string, body: RegisterFlowEncryptionKeyRequest, params: FlowEncryptionParams, options: RequestOptions = {}): Promise> { + return this.transport.request({ method: "POST", path: this.path(phoneNumberId, params), body: { business_public_key: body.businessPublicKey }, ...options, maxNetworkRetries: 0 }); + } + private path(phoneNumberId: string, params: FlowEncryptionParams): string { + if (this.credentialType === "clientToken") throw new PolymorfaConfigurationError("Flow encryption keys require an organization API key or project token.", "credential"); + if (!phoneNumberId.trim() || !params.version.trim()) throw new PolymorfaConfigurationError("Provide a Meta phone number ID and Graph version."); + return `/graph/whatsapp/${encodeURIComponent(params.version)}/${encodeURIComponent(phoneNumberId)}/whatsapp_business_encryption`; + } +} From 440821d1370e5559dc1167a90391f66b04d7f501 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 22:45:55 +0200 Subject: [PATCH 4/9] Type inbound Official referral source --- contracts/cloud-api-supplements.json | 9 +++++++++ packages/typescript/README.md | 6 ++++++ packages/typescript/src/webhooks/events.ts | 7 +++++++ 3 files changed, 22 insertions(+) diff --git a/contracts/cloud-api-supplements.json b/contracts/cloud-api-supplements.json index a2c6414e..c17c3141 100644 --- a/contracts/cloud-api-supplements.json +++ b/contracts/cloud-api-supplements.json @@ -108,6 +108,15 @@ "sha256": "6e4c1fca51eab78b5a21527cacf8f39e7c27da4a2fd7e46bc5f06c2cc3bf3ba1", "operationIds": ["graphGetFlowEncryptionKey", "graphRegisterFlowEncryptionKey"], "merged": false + }, + { + "pullRequest": "https://github.com/polymorfa/polymorfa/pull/363", + "commit": "e6976f75cb62ef43ba8d04cfc95a513f0af1e152", + "sourcePath": "apps/api/docs/openapi.json", + "sha256": "ff0907be44882f78644797ec3700545843bf8e17b81c868c7ca12cfaa672882e", + "operationIds": [], + "schemas": ["CloudMessagePayload"], + "merged": false } ] } diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 4fa11eb6..6fe54706 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -1929,6 +1929,12 @@ 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: +For an inbound Official API `message.received` event, +`CloudMessagePayload.referral` contains Meta's optional `source_type`, +`source_id`, `source_url`, and `ctwa_clid` strings. These fields identify a +provider-reported referral source. They do not establish a conversion, order +payment, or revenue. + ```ts const event = await webhooks.verify({ body: rawBody, diff --git a/packages/typescript/src/webhooks/events.ts b/packages/typescript/src/webhooks/events.ts index 03070ff2..ce550180 100644 --- a/packages/typescript/src/webhooks/events.ts +++ b/packages/typescript/src/webhooks/events.ts @@ -189,6 +189,13 @@ export interface CloudMessagePayload { readonly senderName?: string; readonly nativeFlowResponse?: NativeFlowResponse; readonly interactive?: Readonly>; + /** Provider referral source only; it does not establish a conversion or payment. */ + readonly referral?: Readonly<{ + source_type?: string; + source_id?: string; + source_url?: string; + ctwa_clid?: string; + }>; readonly [key: string]: unknown; } From c8b387030c35a845d31ba08a45ce74b1b2a63ea9 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 22:50:55 +0200 Subject: [PATCH 5/9] Format Official API SDK additions --- contracts/cloud-api-supplements.json | 5 +- packages/typescript/src/index.ts | 6 ++- packages/typescript/src/messaging/client.ts | 5 +- .../src/messaging/flow-encryption.ts | 51 +++++++++++++++---- 4 files changed, 55 insertions(+), 12 deletions(-) diff --git a/contracts/cloud-api-supplements.json b/contracts/cloud-api-supplements.json index c17c3141..6d5b7907 100644 --- a/contracts/cloud-api-supplements.json +++ b/contracts/cloud-api-supplements.json @@ -106,7 +106,10 @@ "commit": "56a0d0eabc908200ed4afe6d4d9ad2ba27e8e5fa", "sourcePath": "docs/api-reference/openapi.graph.json", "sha256": "6e4c1fca51eab78b5a21527cacf8f39e7c27da4a2fd7e46bc5f06c2cc3bf3ba1", - "operationIds": ["graphGetFlowEncryptionKey", "graphRegisterFlowEncryptionKey"], + "operationIds": [ + "graphGetFlowEncryptionKey", + "graphRegisterFlowEncryptionKey" + ], "merged": false }, { diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts index 9eebc71d..617352d5 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -62,7 +62,11 @@ export { export { MessagingClient } from "./messaging/client.js"; export { CloudCatalogsResource } from "./messaging/cloud-catalogs.js"; export { FlowEncryptionResource } from "./messaging/flow-encryption.js"; -export type { FlowEncryptionKey, FlowEncryptionParams, RegisterFlowEncryptionKeyRequest } from "./messaging/flow-encryption.js"; +export type { + FlowEncryptionKey, + FlowEncryptionParams, + RegisterFlowEncryptionKeyRequest, +} from "./messaging/flow-encryption.js"; export type { CloudProductCatalog, ListCloudCatalogsParams, diff --git a/packages/typescript/src/messaging/client.ts b/packages/typescript/src/messaging/client.ts index 1b91c0da..2d8b3d7c 100644 --- a/packages/typescript/src/messaging/client.ts +++ b/packages/typescript/src/messaging/client.ts @@ -110,7 +110,10 @@ export class MessagingClient { this.templates = new TemplatesResource(transport); this.cloudTemplates = new CloudTemplatesResource(transport); this.cloudCatalogs = new CloudCatalogsResource(transport, credential.type); - this.flowEncryption = new FlowEncryptionResource(transport, credential.type); + this.flowEncryption = new FlowEncryptionResource( + transport, + credential.type, + ); this.users = new UsersResource(transport); this.voip = new VoipResource(transport, credential.type); this.webhooks = new WebhooksResource(transport); diff --git a/packages/typescript/src/messaging/flow-encryption.ts b/packages/typescript/src/messaging/flow-encryption.ts index 99e8b21a..3fb1c7d5 100644 --- a/packages/typescript/src/messaging/flow-encryption.ts +++ b/packages/typescript/src/messaging/flow-encryption.ts @@ -7,22 +7,55 @@ export interface FlowEncryptionKey { readonly business_public_key: string; readonly business_public_key_signature_status: string; } -export interface FlowEncryptionParams { readonly version: string; } -export interface RegisterFlowEncryptionKeyRequest { readonly businessPublicKey: string; } +export interface FlowEncryptionParams { + readonly version: string; +} +export interface RegisterFlowEncryptionKeyRequest { + readonly businessPublicKey: string; +} /** Public-key registration affects every dynamic Flow on this Meta phone number. */ export class FlowEncryptionResource { - constructor(private readonly transport: HttpTransport, private readonly credentialType: MessagingCredential["type"]) {} - retrieve(phoneNumberId: string, params: FlowEncryptionParams, options: RequestOptions = {}): Promise> { - return this.transport.request({ method: "GET", path: this.path(phoneNumberId, params), ...options }); + constructor( + private readonly transport: HttpTransport, + private readonly credentialType: MessagingCredential["type"], + ) {} + retrieve( + phoneNumberId: string, + params: FlowEncryptionParams, + options: RequestOptions = {}, + ): Promise> { + return this.transport.request({ + method: "GET", + path: this.path(phoneNumberId, params), + ...options, + }); } /** Send once; read the registered key after an uncertain outcome before another write. */ - register(phoneNumberId: string, body: RegisterFlowEncryptionKeyRequest, params: FlowEncryptionParams, options: RequestOptions = {}): Promise> { - return this.transport.request({ method: "POST", path: this.path(phoneNumberId, params), body: { business_public_key: body.businessPublicKey }, ...options, maxNetworkRetries: 0 }); + register( + phoneNumberId: string, + body: RegisterFlowEncryptionKeyRequest, + params: FlowEncryptionParams, + options: RequestOptions = {}, + ): Promise> { + return this.transport.request({ + method: "POST", + path: this.path(phoneNumberId, params), + body: { business_public_key: body.businessPublicKey }, + ...options, + maxNetworkRetries: 0, + }); } private path(phoneNumberId: string, params: FlowEncryptionParams): string { - if (this.credentialType === "clientToken") throw new PolymorfaConfigurationError("Flow encryption keys require an organization API key or project token.", "credential"); - if (!phoneNumberId.trim() || !params.version.trim()) throw new PolymorfaConfigurationError("Provide a Meta phone number ID and Graph version."); + if (this.credentialType === "clientToken") + throw new PolymorfaConfigurationError( + "Flow encryption keys require an organization API key or project token.", + "credential", + ); + if (!phoneNumberId.trim() || !params.version.trim()) + throw new PolymorfaConfigurationError( + "Provide a Meta phone number ID and Graph version.", + ); return `/graph/whatsapp/${encodeURIComponent(params.version)}/${encodeURIComponent(phoneNumberId)}/whatsapp_business_encryption`; } } From 78bf710de002859bc0884624f7bf46ebba04bd91 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 22:52:47 +0200 Subject: [PATCH 6/9] Type Meta phone-name review notices --- contracts/cloud-api-supplements.json | 9 +++++++++ packages/typescript/README.md | 5 +++++ packages/typescript/src/webhooks/events.ts | 3 ++- 3 files changed, 16 insertions(+), 1 deletion(-) diff --git a/contracts/cloud-api-supplements.json b/contracts/cloud-api-supplements.json index 6d5b7907..baa7fd3a 100644 --- a/contracts/cloud-api-supplements.json +++ b/contracts/cloud-api-supplements.json @@ -120,6 +120,15 @@ "operationIds": [], "schemas": ["CloudMessagePayload"], "merged": false + }, + { + "pullRequest": "https://github.com/polymorfa/polymorfa/pull/373", + "commit": "b6e820f612a63d54bc4c733fc670a06b6a4cc656", + "sourcePath": "apps/api/docs/openapi.json", + "sha256": "9aed1d4a5ecf7c17ba05701cf57c1a219fcc4521330faa1ef7a34fb9b6df7232", + "operationIds": [], + "schemas": ["CloudAccountStatusPayload"], + "merged": false } ] } diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 6fe54706..d4a3c4f2 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -1935,6 +1935,11 @@ For an inbound Official API `message.received` event, provider-reported referral source. They do not establish a conversion, order payment, or revenue. +`CloudAccountStatusPayload.kind` also distinguishes +`phone_number_name_update` from other Meta account notices. Its `value` retains +the provider's display phone, decision, requested name, and rejection reason; +the notice is not a runtime connection-state transition. + ```ts const event = await webhooks.verify({ body: rawBody, diff --git a/packages/typescript/src/webhooks/events.ts b/packages/typescript/src/webhooks/events.ts index ce550180..630c30e0 100644 --- a/packages/typescript/src/webhooks/events.ts +++ b/packages/typescript/src/webhooks/events.ts @@ -267,7 +267,8 @@ export interface RuntimeSessionStatusPayload { /** A Meta account notification, not a runtime connection-state transition. */ export interface CloudAccountStatusPayload { readonly source: "meta"; - readonly kind: "account_alerts" | "account_update"; + readonly kind: + "account_alerts" | "account_update" | "phone_number_name_update"; readonly wabaId?: string; readonly value: Readonly>; } From 1b12a95ef367a7197d10b9b6fa02b9bc08df0cc9 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 27 Sep 2026 01:29:48 +0200 Subject: [PATCH 7/9] Reject client tokens for Cloud template methods --- packages/typescript/README.md | 6 +++-- packages/typescript/src/messaging/client.ts | 5 +++- .../src/messaging/cloud-templates.ts | 21 ++++++++++++++- .../typescript/test/cloud-templates.test.ts | 26 +++++++++++++++++++ 4 files changed, 54 insertions(+), 4 deletions(-) diff --git a/packages/typescript/README.md b/packages/typescript/README.md index d183e79a..4981854b 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -353,8 +353,10 @@ browser code. `messaging.cloudTemplates` lists, retrieves, creates, edits and deletes Meta templates for a Number. It is separate from `messaging.templates`, which manages project -drafts. The native template API requires an enabled Official API connection. For a -Hybrid Number it uses that exact connection and its WABA; a retired or disabled +drafts. Use an organization API key or project token; the SDK rejects browser +client tokens before sending any of these requests. The native template API +requires an enabled Official API connection. For a Hybrid Number it uses that +exact connection and its WABA; a retired or disabled Official connection cannot supply authority. Retrieval accepts an optional language; the API defaults to `en_US`. Deleting a name deletes all its languages. Create, edit and delete make one attempt, diff --git a/packages/typescript/src/messaging/client.ts b/packages/typescript/src/messaging/client.ts index 2d8b3d7c..0ea465ef 100644 --- a/packages/typescript/src/messaging/client.ts +++ b/packages/typescript/src/messaging/client.ts @@ -108,7 +108,10 @@ export class MessagingClient { this.testing = new TestingResource(transport, credential.type); this.quickLinks = new QuickLinksResource(transport, credential.type); this.templates = new TemplatesResource(transport); - this.cloudTemplates = new CloudTemplatesResource(transport); + this.cloudTemplates = new CloudTemplatesResource( + transport, + credential.type, + ); this.cloudCatalogs = new CloudCatalogsResource(transport, credential.type); this.flowEncryption = new FlowEncryptionResource( transport, diff --git a/packages/typescript/src/messaging/cloud-templates.ts b/packages/typescript/src/messaging/cloud-templates.ts index 2049d736..adec6c77 100644 --- a/packages/typescript/src/messaging/cloud-templates.ts +++ b/packages/typescript/src/messaging/cloud-templates.ts @@ -1,3 +1,5 @@ +import type { MessagingCredential } from "../credentials.js"; +import { PolymorfaConfigurationError } from "../errors.js"; import { HttpTransport } from "../transport/http.js"; import type { ApiResponse, RequestOptions } from "../transport/types.js"; import type { @@ -64,13 +66,26 @@ export type ListCloudTemplatesResponse = SuccessEnvelope< /** Meta templates for a Number created on an Official API connection, separate from project drafts. */ export class CloudTemplatesResource { - constructor(private readonly transport: HttpTransport) {} + constructor( + private readonly transport: HttpTransport, + private readonly credentialType: MessagingCredential["type"], + ) {} + + private requireServerCredential(): void { + if (this.credentialType === "clientToken") { + throw new PolymorfaConfigurationError( + "Official API templates require an organization API key or project token.", + "credential", + ); + } + } /** Refresh the complete Meta catalog before returning the saved templates. */ list( session: string, options: RequestOptions = {}, ): Promise> { + this.requireServerCredential(); return this.transport.request({ method: "GET", path: templatesPath(session), @@ -84,6 +99,7 @@ export class CloudTemplatesResource { params: RetrieveCloudTemplateParams = {}, options: RequestOptions = {}, ): Promise> { + this.requireServerCredential(); return this.transport.request({ method: "GET", path: templatePath(session, name), @@ -98,6 +114,7 @@ export class CloudTemplatesResource { body: CreateCloudTemplateRequest, options: RequestOptions = {}, ): Promise> { + this.requireServerCredential(); return this.transport.request({ method: "POST", path: templatesPath(session), @@ -115,6 +132,7 @@ export class CloudTemplatesResource { params: RetrieveCloudTemplateParams = {}, options: RequestOptions = {}, ): Promise> { + this.requireServerCredential(); return this.transport.request({ method: "PATCH", path: templatePath(session, name), @@ -131,6 +149,7 @@ export class CloudTemplatesResource { name: string, options: RequestOptions = {}, ): Promise> { + this.requireServerCredential(); return this.transport.request({ method: "DELETE", path: templatePath(session, name), diff --git a/packages/typescript/test/cloud-templates.test.ts b/packages/typescript/test/cloud-templates.test.ts index b560644c..93631657 100644 --- a/packages/typescript/test/cloud-templates.test.ts +++ b/packages/typescript/test/cloud-templates.test.ts @@ -17,6 +17,32 @@ function client(server: TestServer) { } describe("Cloud templates", () => { + it("rejects client tokens before every template request", async () => { + const server = await startTestServer(() => ({ body: "{}" })); + servers.push(server); + const resource = new MessagingClient({ + credential: { type: "clientToken", value: "pmfa_ct_browser" }, + baseUrl: server.url, + }).cloudTemplates; + const body = { + name: "delivery", + language: "en_US", + category: "UTILITY" as const, + components: [{}], + }; + const requests = [ + () => resource.list("support"), + () => resource.retrieve("support", "delivery"), + () => resource.create("support", body), + () => resource.update("support", "delivery", { components: [{}] }), + () => resource.delete("support", "delivery"), + ]; + for (const request of requests) { + expect(request).toThrow(/organization API key or project token/); + } + expect(server.requests).toHaveLength(0); + }); + it("preserves receipt fields and encodes Number, template name and language separately", async () => { const template = { id: "tpl_1", From e0823435f28f353664ac4508f5b0897d204645c1 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 27 Sep 2026 01:33:07 +0200 Subject: [PATCH 8/9] Validate Flow path IDs and update revisions --- packages/typescript/src/platform/flows.ts | 10 +++++++++- packages/typescript/test/flows.test.ts | 20 ++++++++++++++++++++ 2 files changed, 29 insertions(+), 1 deletion(-) diff --git a/packages/typescript/src/platform/flows.ts b/packages/typescript/src/platform/flows.ts index 3499d6bb..a41f61f6 100644 --- a/packages/typescript/src/platform/flows.ts +++ b/packages/typescript/src/platform/flows.ts @@ -145,6 +145,14 @@ export class FlowsResource { body: UpdateFlowRequest, options: RequestOptions = {}, ): Promise> { + if ( + typeof body?.expectedUpdatedAt !== "number" || + !Number.isFinite(body.expectedUpdatedAt) + ) { + throw new PolymorfaValidationError( + "expectedUpdatedAt must be a finite number.", + ); + } return this.request("PATCH", flowPath(flowId), body, options); } delete( @@ -229,7 +237,7 @@ export class FlowsResource { } } function flowPath(id: string): string { - if (!id.trim()) + if (typeof id !== "string" || !id.trim()) throw new PolymorfaConfigurationError("Flow ID is required.", "flowId"); return `/${encodeURIComponent(id)}`; } diff --git a/packages/typescript/test/flows.test.ts b/packages/typescript/test/flows.test.ts index 3a09670d..ac6d27df 100644 --- a/packages/typescript/test/flows.test.ts +++ b/packages/typescript/test/flows.test.ts @@ -27,6 +27,26 @@ function client(server: TestServer) { } describe("project Flow lifecycle", () => { + it("rejects malformed Flow path IDs and update revisions before transport", async () => { + const server = await startTestServer(() => ({ body: "{}" })); + servers.push(server); + const flows = client(server).flows; + expect(() => flows.delete(undefined as unknown as string)).toThrow( + "Flow ID is required", + ); + expect(() => + flows.upload(null as unknown as string, { sessionId: "support" }), + ).toThrow("Flow ID is required"); + for (const expectedUpdatedAt of [undefined, NaN, Infinity, -Infinity]) { + expect(() => + flows.update("flow", { + expectedUpdatedAt: expectedUpdatedAt as number, + }), + ).toThrow("expectedUpdatedAt must be a finite number"); + } + expect(server.requests).toHaveLength(0); + }); + it("binds project identity on all draft and lifecycle paths", async () => { const server = await startTestServer(() => ({ body: JSON.stringify({ data: null }), From 7effb04e5c8d6aac579dfafee44d94e44b9708ab Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 1 Oct 2026 15:06:49 +0300 Subject: [PATCH 9/9] Document the Flow response-shape migration --- CHANGELOG.md | 12 ++++++++++++ packages/typescript/README.md | 5 +++++ 2 files changed, 17 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 449b4300..78359fe7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ ## Unreleased +- Official API consumers: Number-scoped Meta templates (`cloudTemplates`), + service windows, Meta pricing counts, Cloud credential health and + reauthorization, QuickLink sync receipts, typing with an inbound message ID, + the Flow provider lifecycle, linked catalogs and Flow encryption keys, and + the Meta lifecycle webhook payload variants. Provider writes make one + attempt. Contracts are pinned to API `dev` `29abb7b`. +- Campaign records type the stored `sendWindow`; create and update requests + accept `CampaignSendWindowRequest`. +- Breaking for Flow drafts: `flows.list` and `flows.retrieve` now return the + unwrapped record in `response.data`, like the other Flow methods. Replace + `response.data.data` with `response.data`. + - `message.received` payload types include `replyChoice` (`kind` `button` or `list` and the `id` you assigned) and `parentMessageId`, and the linked-device message `type` union adds `button_reply`, `list_reply`, and diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 10a52c9f..4fa4366f 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -328,6 +328,11 @@ with a project token exposes the same resource for its configured project. returns `null` when the API has no matching draft; updates require the observed `expectedUpdatedAt` value. Draft methods and provider methods remain distinct. +Every Flow method returns the unwrapped record in `response.data`. Prereleases +up to `0.1.0-dev.20260927174827` returned `list` and `retrieve` inside a +`{ data }` envelope; replace `response.data.data` with `response.data` when +upgrading. + ```ts const flows = client.project(projectId).flows; const draft = await flows.create({