From 71f5893673ac2d61a9ed9b8b3919100868eaf311 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 23 Oct 2026 15:48:22 +0300 Subject: [PATCH 01/21] Prepare response-only simultaneous-call exclusion end across SDKs --- CHANGELOG.md | 2 + ...alls-coverage-end.candidate.fragments.json | 611 ++++++++++++++++++ contracts/calls-coverage-end.candidate.json | 46 ++ .../calls-coverage-end.candidate.json | 33 + packages/dotnet/README.md | 18 + packages/dotnet/src/Polymorfa.Sdk/Calls.cs | 6 +- .../CallSettingsCoverageEndTests.cs | 30 + .../tests/Polymorfa.Sdk.Tests/CallsTests.cs | 1 + packages/go/README.md | 17 + packages/go/calls_coverage_end_test.go | 95 +++ packages/go/voip.go | 20 +- packages/php/README.md | 15 + packages/php/src/VoipModels.php | 2 +- packages/php/tests/voip.php | 18 + packages/python/README.md | 15 + packages/python/src/polymorfa/voip_models.py | 1 + .../python/tests/test_calls_coverage_end.py | 70 ++ packages/rust/README.md | 18 + packages/rust/src/voip.rs | 2 + packages/rust/tests/calls_coverage_end.rs | 69 ++ packages/typescript/README.md | 14 + packages/typescript/src/messaging/types.ts | 2 + .../test/calls-coverage-end.test.ts | 69 ++ 23 files changed, 1163 insertions(+), 11 deletions(-) create mode 100644 contracts/calls-coverage-end.candidate.fragments.json create mode 100644 contracts/calls-coverage-end.candidate.json create mode 100644 contracts/fixtures/calls-coverage-end.candidate.json create mode 100644 packages/dotnet/tests/Polymorfa.Sdk.Tests/CallSettingsCoverageEndTests.cs create mode 100644 packages/go/calls_coverage_end_test.go create mode 100644 packages/python/tests/test_calls_coverage_end.py create mode 100644 packages/rust/tests/calls_coverage_end.rs create mode 100644 packages/typescript/test/calls-coverage-end.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index f3e1dea4..51f7ae39 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Add the read-only nullable call-settings exclusion-end response field across all six SDKs. Rust response struct literals require the new optional field; C# positional construction remains compatible. Unreleased and dependent on the matching API contract. + - All six server SDKs expose optional automatic-pause `reason`/`code` and optional recipient-skip `messageIndex`/`followUpId` from accepted API source `9bb1e2ed`. Future reason/code strings and raw envelope fields are retained; diff --git a/contracts/calls-coverage-end.candidate.fragments.json b/contracts/calls-coverage-end.candidate.fragments.json new file mode 100644 index 00000000..68a4e671 --- /dev/null +++ b/contracts/calls-coverage-end.candidate.fragments.json @@ -0,0 +1,611 @@ +{ + "schemas": { + "PlatformAccessCallSettings": { + "type": "object", + "properties": { + "callsEnabled": { + "type": "boolean", + "description": "Whether the session can place, answer and receive calls. When false, placing, answering, joining and attaching media fail with `403 calls_disabled`, incoming calls are declined, and SIP trunks cannot place calls through the session. Calls in progress continue, and the participant that answered or placed one can reconnect to it. Defaults to true." + }, + "conferenceMode": { + "type": "boolean", + "description": "Conference mode. When true, every participant you connect to a call (including SIP trunk callers) hears the WhatsApp party and every other participant you connected. When false, each of them hears only the WhatsApp party. The WhatsApp party always hears all of your participants, and no participant hears their own audio. Defaults to true." + }, + "inboundRoute": { + "$ref": "#/components/schemas/PlatformAccessCallInboundRoute" + }, + "sipTrunkId": { + "type": "string", + "nullable": true, + "format": "uuid", + "description": "The SIP trunk that receives incoming calls when `inboundRoute` is `sip_trunk`; otherwise null." + }, + "sipClaim": { + "type": "boolean", + "description": "Whether an answer from the SIP trunk claims the call, which ends ringing on your other clients. Defaults to true." + }, + "hostCloudApiCalls": { + "type": "boolean", + "description": "Whether Polymorfa Calls answers the session's incoming calls when the session uses the Cloud API. When false (the default), your app answers them through the Graph API `/calls` endpoint and Polymorfa Calls does not host them. Sessions on a linked device ignore it." + }, + "simultaneousCalls": { + "type": "boolean", + "description": "Whether this Number may hold more than one call at a time, with no limit. Linked-device sessions only; Cloud API sessions ignore it. WhatsApp's own apps never hold two calls at once, so this behavior is unusual for WhatsApp, and it voids Ban Insurance: a ban whose 30-day window overlaps any period this setting was on is not refunded. Defaults to false." + }, + "simultaneousCallsCoverageExcludedUntil": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "Read-only. While `simultaneousCalls` is false, when Ban Insurance stops excluding this Number because of an earlier period with it on: 30 days after the latest period ended. A ban that starts before this time is not refunded; from this time on, that period no longer excludes it. Null while `simultaneousCalls` is true, because the exclusion has no end yet, and null when no period ended in the last 30 days. Null does not mean the Number is covered: the other Ban Insurance conditions still apply." + }, + "revision": { + "type": "integer", + "minimum": 0, + "description": "Increases on every change; 0 while the session uses the defaults. Send it as `expectedRevision` to refuse an update that would overwrite someone else's change." + }, + "updatedAt": { + "type": "string", + "nullable": true, + "format": "date-time", + "description": "When the settings were last changed, or null when the session uses the defaults." + } + }, + "required": [ + "callsEnabled", + "conferenceMode", + "inboundRoute", + "sipTrunkId", + "sipClaim", + "hostCloudApiCalls", + "simultaneousCalls", + "simultaneousCallsCoverageExcludedUntil", + "revision", + "updatedAt" + ], + "additionalProperties": false + }, + "PlatformAccessGetCallSettingsResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "data": { + "$ref": "#/components/schemas/PlatformAccessCallSettings" + } + }, + "required": ["success", "data"] + }, + "PlatformAccessUpdateCallSettingsResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "data": { + "$ref": "#/components/schemas/PlatformAccessCallSettings" + } + }, + "required": ["success", "data"] + }, + "PlatformAccessUpdateCallSettingsRequest": { + "type": "object", + "properties": { + "callsEnabled": { + "type": "boolean", + "description": "Set to false to refuse new and incoming calls on the session; true allows them again." + }, + "conferenceMode": { + "type": "boolean", + "description": "Set to true so that the participants you connect to a call hear each other as well as the WhatsApp party, or false so that each hears only the WhatsApp party." + }, + "inboundRoute": { + "allOf": [ + { + "$ref": "#/components/schemas/PlatformAccessCallInboundRoute" + }, + { + "description": "Where incoming WhatsApp calls ring." + } + ] + }, + "sipTrunkId": { + "type": "string", + "nullable": true, + "format": "uuid", + "description": "The SIP trunk for `sip_trunk`: a trunk in the session's project whose direction is `outbound` or `both`. Required when switching the route to `sip_trunk`; omit it to keep the stored trunk. With `clients` send `null` or omit it: a trunk ID is refused, and `clients` clears the stored trunk." + }, + "sipClaim": { + "type": "boolean", + "description": "Whether an answer from the SIP trunk claims the call." + }, + "hostCloudApiCalls": { + "type": "boolean", + "description": "Set to true to have Polymorfa Calls answer the session's Cloud API calls instead of your Graph API integration. Applies to calls that arrive after the change." + }, + "simultaneousCalls": { + "type": "boolean", + "description": "Set to true to let this Number hold more than one call at a time, with no limit. Turning it on requires `acknowledgeRisk: true` and a team API key; it voids Ban Insurance for any ban whose 30-day window overlaps a period it was on. Set to false to return to one call at a time. Applies to calls that start after the change." + }, + "acknowledgeRisk": { + "type": "boolean", + "description": "Required as true when the update turns `simultaneousCalls` on. Confirms that you accept the unusual-behavior risk and the loss of Ban Insurance for this Number." + }, + "expectedRevision": { + "type": "integer", + "minimum": 0, + "description": "Apply the update only if the settings still have this `revision` (0 for a session using the defaults). Otherwise the update fails with `409 state_conflict`." + } + }, + "additionalProperties": false + } + }, + "operations": { + "get": { + "operationId": "getCallSettings", + "tags": ["Calls"], + "summary": "Get call settings for a session", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "x-required-scope": "sessions:read", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "required": true, + "name": "session", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Call settings. Sessions without saved settings return the defaults.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformAccessGetCallSettingsResponse" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Bad request \u2014 request body or params failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformAccessPublicError" + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "description": "Missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformAccessPublicError" + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "403": { + "description": "Authenticated but not permitted (missing scope or rule violation)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformAccessPublicError" + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformAccessPublicError" + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformAccessPublicError" + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + } + }, + "x-mint": { + "content": "sessions:read" + }, + "description": "**Required scope:** `sessions:read`", + "x-audience": "public" + }, + "put": { + "operationId": "updateCallSettings", + "tags": ["Calls"], + "summary": "Update call settings for a session", + "description": "**Required scope:** `sessions:manage`\n\nChanges the settings you send; omitted settings keep their current values. Send `expectedRevision` to refuse the update when someone else changed the settings after you read them.", + "security": [ + { + "ApiKeyAuth": [] + } + ], + "x-required-scope": "sessions:manage", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1, + "maxLength": 128 + }, + "required": true, + "name": "session", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformAccessUpdateCallSettingsRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Call settings saved. Audio settings apply to a call the next time a participant answers or connects to it, including calls in progress. Routing settings apply to calls that arrive after the change.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformAccessUpdateCallSettingsResponse" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Bad request \u2014 request body or params failed validation", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformAccessPublicError" + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "401": { + "description": "Missing or invalid authentication token", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformAccessPublicError" + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "403": { + "description": "Authenticated but not permitted (missing scope or rule violation)", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformAccessPublicError" + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "404": { + "description": "Resource not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformAccessPublicError" + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "409": { + "description": "`state_conflict`: the settings changed after `expectedRevision`.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformAccessPublicError" + }, + "example": { + "error": { + "type": "conflict_error", + "code": "state_conflict", + "message": "The request conflicts with the resource state.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#state-conflict" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "500": { + "description": "Internal server error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PlatformAccessPublicError" + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + } + }, + "x-mint": { + "content": "sessions:manage" + }, + "x-audience": "public" + } + } +} diff --git a/contracts/calls-coverage-end.candidate.json b/contracts/calls-coverage-end.candidate.json new file mode 100644 index 00000000..ef02d77d --- /dev/null +++ b/contracts/calls-coverage-end.candidate.json @@ -0,0 +1,46 @@ +{ + "schemaVersion": 1, + "sourceAccepted": false, + "published": false, + "candidateCommit": "0b7b739493b089c386fc21f2cf76f05f60de9151", + "candidateTree": "1b5c55375b928e2a3f9068d180aa43bd5be61cb8", + "candidateParent": "4bf556ab46e23c6d87fa376151f1bd217f6f0812", + "apiVersion": "2026-09-22", + "sourceFiles": [ + { + "path": "apps/api/packages/api/src/routes/call-settings.ts", + "sha256": "9023f1ac740072a4a67f0afc007ad7a03d112412187e95a232351a22ac72bf18" + }, + { + "path": "apps/api/packages/api/src/db/calls/settings-repo.ts", + "sha256": "e72ac879bf6b685acf045039b58f8246a9dc068dcd1991ab7de510fbe8d7871e" + }, + { + "path": "apps/api/docs/openapi.management.json", + "sha256": "f5544f6034bebc978ae2892ef6c885c7a8508d9f349f741b409518240ef7c2c9" + }, + { + "path": "docs/api-reference/openapi.management.json", + "sha256": "9a8db97a52fbcf3efab47d7c342d9d65c187d5edcefabe19980e56a44ea65fe6" + }, + { + "path": "docs/openapi.json", + "sha256": "fa17c29c961cc085cf4f63f5ff4522ca3fa0c0b80f04409e9a53d0c86a6d1d49" + } + ], + "fragmentPath": "contracts/calls-coverage-end.candidate.fragments.json", + "fragmentSha256": "8553868e4cb332bb7e756b28d01b828ceab5a05866bb72710ce6372b7988837d", + "fixturePath": "contracts/fixtures/calls-coverage-end.candidate.json", + "fixtureSha256": "9e0a281012f6b94ad44ef38454da52d95fd4fca759c45e1070af861ab24d5cee", + "operations": [ + "getCallSettings", + "updateCallSettings" + ], + "field": "simultaneousCallsCoverageExcludedUntil", + "responseOnly": true, + "required": true, + "nullable": true, + "format": "date-time", + "acceptedContainingCommit": null, + "privateConsoleSchemaNote": "Candidate shared serializer adds the field, but SessionCallSettings private Console schema in management snapshot lacks it; producer-owned reconciliation required. No SDK private route." +} diff --git a/contracts/fixtures/calls-coverage-end.candidate.json b/contracts/fixtures/calls-coverage-end.candidate.json new file mode 100644 index 00000000..de036fb8 --- /dev/null +++ b/contracts/fixtures/calls-coverage-end.candidate.json @@ -0,0 +1,33 @@ +{ + "schemaVersion": 1, + "candidateSource": "0b7b739493b089c386fc21f2cf76f05f60de9151", + "sourceAccepted": false, + "settings": { + "callsEnabled": true, + "conferenceMode": false, + "inboundRoute": "clients", + "sipTrunkId": null, + "sipClaim": false, + "hostCloudApiCalls": false, + "simultaneousCalls": false, + "revision": 2, + "updatedAt": null + }, + "cases": [ + { + "name": "off-future", + "on": false, + "until": "2026-11-22T12:34:56.789Z" + }, + { + "name": "on-ongoing", + "on": true, + "until": null + }, + { + "name": "off-no-recent-period", + "on": false, + "until": null + } + ] +} diff --git a/packages/dotnet/README.md b/packages/dotnet/README.md index 16e16721..7d46bbf6 100644 --- a/packages/dotnet/README.md +++ b/packages/dotnet/README.md @@ -1033,3 +1033,21 @@ These are verified-event handling fragments, not complete receivers. Preserve whole-body signature verification before decoding and deduplicate batch members. See [the scoped source contract and compatibility notes](../../docs/campaign-webhook-metadata.md). Package publication and matching API environment acceptance remain separate gates. + + +### Call settings exclusion end (Unreleased) + +Call-settings GET and PUT responses include the read-only +`simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null. +While `simultaneousCalls` is on, null means the exclusion has no end yet. +When it is off, a future timestamp is 30 days after the latest period ended. +At that timestamp the earlier period no longer excludes a ban. Null while off +means no recent period was reported. Neither null nor an elapsed timestamp +guarantees coverage; all other Ban Insurance conditions still apply. + +The server calculates this value. Do not send it in an update or calculate it +from the current switch alone. This addition is unreleased; source preparation +does not establish API deployment or package publication. + +The existing nine-position constructor and Deconstruct remain unchanged. +The new nullable response value is an init-only property. diff --git a/packages/dotnet/src/Polymorfa.Sdk/Calls.cs b/packages/dotnet/src/Polymorfa.Sdk/Calls.cs index 7934269f..896d4b71 100644 --- a/packages/dotnet/src/Polymorfa.Sdk/Calls.cs +++ b/packages/dotnet/src/Polymorfa.Sdk/Calls.cs @@ -25,7 +25,11 @@ public sealed record CallPermissionActions(CallPermissionAction? RequestPermissi public record CallPermissionState(string Status, string? ExpiresAt, string? Source, string? UpdatedAt, string? CheckedAt, bool Fresh, CallPermissionActions? Actions); public sealed record CallPermission(string Status, string? ExpiresAt, string? Source, string? UpdatedAt, string? CheckedAt, bool Fresh, CallPermissionActions? Actions, ConversationReference Conversation) : CallPermissionState(Status, ExpiresAt, Source, UpdatedAt, CheckedAt, Fresh, Actions); public sealed record CallCheck(bool Allowed, string? Refusal, CallPermissionState? Permission); -public sealed record SessionCallSettings(bool CallsEnabled, bool ConferenceMode, string InboundRoute, string? SipTrunkId, bool SipClaim, bool HostCloudApiCalls, long Revision, string? UpdatedAt, bool SimultaneousCalls); +public sealed record SessionCallSettings(bool CallsEnabled, bool ConferenceMode, string InboundRoute, string? SipTrunkId, bool SipClaim, bool HostCloudApiCalls, long Revision, string? UpdatedAt, bool SimultaneousCalls) +{ + /// Read-only RFC3339 exclusion end. Null is not a coverage guarantee. + public string? SimultaneousCallsCoverageExcludedUntil { get; init; } +} public sealed record UpdateCallSettingsRequest(bool? CallsEnabled = null, bool? ConferenceMode = null, string? InboundRoute = null, [property: System.Text.Json.Serialization.JsonIgnore(Condition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingDefault)] PatchValue SipTrunkId = default, bool? SipClaim = null, bool? HostCloudApiCalls = null, long? ExpectedRevision = null, bool? SimultaneousCalls = null, bool? AcknowledgeRisk = null); public sealed record CallReportClient(string Sdk, string Version, string Platform); public sealed record CallQuality(int? RttMs = null, int? JitterMs = null, int? PacketsLost = null, int? PacketsReceived = null, int? Reconnects = null, string? AudioCodec = null, string? VideoCodec = null, string? CandidateType = null); diff --git a/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallSettingsCoverageEndTests.cs b/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallSettingsCoverageEndTests.cs new file mode 100644 index 00000000..7a08d41a --- /dev/null +++ b/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallSettingsCoverageEndTests.cs @@ -0,0 +1,30 @@ +using System.Text.Json; +using Polymorfa.Sdk; +using static ResourceTests; + +internal static class CallSettingsCoverageEndTests +{ + public static async Task RunAsync() + { + var old = new SessionCallSettings(true, false, "clients", null, false, false, 2, null, false); + var (enabled, conference, route, trunk, claim, hosted, revision, updated, simultaneous) = old; + Equal(revision, 2L); + Equal(old.SimultaneousCallsCoverageExcludedUntil, null); + foreach (var item in new (bool On, string? Until)[] { (false, "2026-11-22T12:34:56.789Z"), (true, null), (false, null) }) + foreach (var method in new[] { "GET", "PUT" }) + { + var until = item.Until; + var data = new { callsEnabled = true, conferenceMode = false, inboundRoute = "clients", sipTrunkId = (string?)null, sipClaim = false, hostCloudApiCalls = false, revision = 2, updatedAt = (string?)null, simultaneousCalls = item.On, simultaneousCallsCoverageExcludedUntil = until }; + var response = JsonSerializer.Serialize(new { success = true, data }); + await Messaging(method, "/platform/sessions/s/call-settings", method == "GET" ? null : "{\"callsEnabled\":false}", response, async c => + { + var value = method == "GET" ? await c.Calls.RetrieveCallSettingsAsync("s") : await c.Calls.UpdateCallSettingsAsync("s", new(CallsEnabled: false)); + Equal(value.Data.Data.SimultaneousCallsCoverageExcludedUntil, until); + Equal(value.Data.Data.Revision, 2L); + using var roundtrip = JsonDocument.Parse(JsonSerializer.Serialize(value.Data.Data, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); + Equal(roundtrip.RootElement.GetProperty("simultaneousCallsCoverageExcludedUntil").GetString(), until); + }); + } + True(typeof(UpdateCallSettingsRequest).GetProperty("SimultaneousCallsCoverageExcludedUntil") is null); + } +} diff --git a/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallsTests.cs b/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallsTests.cs index bc74783c..3e60e3e4 100644 --- a/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallsTests.cs +++ b/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallsTests.cs @@ -7,6 +7,7 @@ internal static class CallsTests private const string Settings = "{\"success\":true,\"data\":{\"callsEnabled\":true,\"conferenceMode\":false,\"inboundRoute\":\"clients\",\"sipTrunkId\":null,\"sipClaim\":false,\"hostCloudApiCalls\":false,\"revision\":2,\"updatedAt\":null}}"; public static async Task RunAsync() { + await CallSettingsCoverageEndTests.RunAsync(); await Messaging("PUT", "/platform/sessions/s/call-settings", "{\"simultaneousCalls\":true,\"acknowledgeRisk\":true}", Settings, async c => ResourceTests.Equal((await c.Calls.UpdateCallSettingsAsync("s", new(SimultaneousCalls: true, AcknowledgeRisk: true))).Data.Data.Revision, 2L)); await Messaging("PUT", "/platform/sessions/s/call-settings", "{\"simultaneousCalls\":false}", Settings, async c => ResourceTests.Equal((await c.Calls.UpdateCallSettingsAsync("s", new(SimultaneousCalls: false))).Data.Data.Revision, 2L)); await Messaging("PUT", "/platform/sessions/s/call-settings", "{\"simultaneousCalls\":true}", Settings, async c => ResourceTests.Equal((await c.Calls.UpdateCallSettingsAsync("s", new(SimultaneousCalls: true))).Data.Data.Revision, 2L)); diff --git a/packages/go/README.md b/packages/go/README.md index f54a772a..919b919d 100644 --- a/packages/go/README.md +++ b/packages/go/README.md @@ -834,3 +834,20 @@ These are verified-event handling fragments, not complete receivers. Preserve whole-body signature verification before decoding and deduplicate batch members. See [the scoped source contract and compatibility notes](../../docs/campaign-webhook-metadata.md). Package publication and matching API environment acceptance remain separate gates. + + +### Call settings exclusion end (Unreleased) + +Call-settings GET and PUT responses include the read-only +`simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null. +While `simultaneousCalls` is on, null means the exclusion has no end yet. +When it is off, a future timestamp is 30 days after the latest period ended. +At that timestamp the earlier period no longer excludes a ban. Null while off +means no recent period was reported. Neither null nor an elapsed timestamp +guarantees coverage; all other Ban Insurance conditions still apply. + +The server calculates this value. Do not send it in an update or calculate it +from the current switch alone. This addition is unreleased; source preparation +does not establish API deployment or package publication. + +Prefer keyed response struct literals when constructing synthetic settings. diff --git a/packages/go/calls_coverage_end_test.go b/packages/go/calls_coverage_end_test.go new file mode 100644 index 00000000..5df050bd --- /dev/null +++ b/packages/go/calls_coverage_end_test.go @@ -0,0 +1,95 @@ +package polymorfa + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "testing" +) + +func TestCallSettingsCoverageEndResponseOnly(t *testing.T) { + data, err := os.ReadFile("../../contracts/fixtures/calls-coverage-end.candidate.json") + if err != nil { + t.Fatal(err) + } + var fixture struct { + Settings map[string]any + Cases []struct { + Name string + On bool + Until *string + } + } + if err := json.Unmarshal(data, &fixture); err != nil { + t.Fatal(err) + } + for _, c := range fixture.Cases { + for _, method := range []string{"GET", "PUT"} { + t.Run(c.Name+method, func(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != method || r.URL.Path != "/platform/sessions/support/call-settings" { + t.Error(r.Method, r.URL) + } + if method == "PUT" { + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if len(body) != 1 || body["callsEnabled"] != false { + t.Error(body) + } + } + settings := map[string]any{} + for k, v := range fixture.Settings { + settings[k] = v + } + settings["simultaneousCalls"] = c.On + settings["simultaneousCallsCoverageExcludedUntil"] = c.Until + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": settings}) + })) + defer server.Close() + client := mustMessaging(t, server.URL) + var response Response[Envelope[SessionCallSettings]] + var err error + if method == "GET" { + response, err = client.VoIP().RetrieveCallSettings(context.Background(), "support") + } else { + response, err = client.VoIP().UpdateCallSettings(context.Background(), "support", UpdateCallSettingsRequest{CallsEnabled: callsBool(false)}) + } + if err != nil { + t.Fatal(err) + } + actual := response.Data.Data.SimultaneousCallsCoverageExcludedUntil + if (actual == nil) != (c.Until == nil) || actual != nil && *actual != *c.Until { + t.Fatal(actual, c.Until) + } + if response.Data.Data.SimultaneousCalls != c.On || response.Data.Data.Revision != 2 { + t.Fatal(response.Data.Data) + } + }) + } + } + var older SessionCallSettings + legacy, err := json.Marshal(fixture.Settings) + if err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(legacy, &older); err != nil || older.SimultaneousCallsCoverageExcludedUntil != nil { + t.Fatal(older, err) + } + encoded, err := json.Marshal(older) + if err != nil { + t.Fatal(err) + } + var roundtrip map[string]any + if err := json.Unmarshal(encoded, &roundtrip); err != nil { + t.Fatal(err) + } + if v, ok := roundtrip["simultaneousCallsCoverageExcludedUntil"]; !ok || v != nil { + t.Fatal(roundtrip) + } + +} diff --git a/packages/go/voip.go b/packages/go/voip.go index 6bfd0f2a..079262ab 100644 --- a/packages/go/voip.go +++ b/packages/go/voip.go @@ -109,15 +109,17 @@ type PreviewedCallLink struct { IsAdmin bool `json:"isAdmin"` } type SessionCallSettings struct { - SimultaneousCalls bool `json:"simultaneousCalls"` - CallsEnabled bool `json:"callsEnabled"` - ConferenceMode bool `json:"conferenceMode"` - InboundRoute string `json:"inboundRoute"` - SIPTrunkID *string `json:"sipTrunkId"` - SIPClaim bool `json:"sipClaim"` - HostCloudAPICalls bool `json:"hostCloudApiCalls"` - Revision int64 `json:"revision"` - UpdatedAt *string `json:"updatedAt"` + // Read-only server projection. Nil is not a coverage guarantee. + SimultaneousCallsCoverageExcludedUntil *string `json:"simultaneousCallsCoverageExcludedUntil"` + SimultaneousCalls bool `json:"simultaneousCalls"` + CallsEnabled bool `json:"callsEnabled"` + ConferenceMode bool `json:"conferenceMode"` + InboundRoute string `json:"inboundRoute"` + SIPTrunkID *string `json:"sipTrunkId"` + SIPClaim bool `json:"sipClaim"` + HostCloudAPICalls bool `json:"hostCloudApiCalls"` + Revision int64 `json:"revision"` + UpdatedAt *string `json:"updatedAt"` } // NullableString preserves the difference between omitted and explicitly null. diff --git a/packages/php/README.md b/packages/php/README.md index 27a06003..c43a6437 100644 --- a/packages/php/README.md +++ b/packages/php/README.md @@ -987,3 +987,18 @@ These are verified-event handling fragments, not complete receivers. Preserve whole-body signature verification before decoding and deduplicate batch members. See [the scoped source contract and compatibility notes](../../docs/campaign-webhook-metadata.md). Package publication and matching API environment acceptance remain separate gates. + + +### Call settings exclusion end (Unreleased) + +Call-settings GET and PUT responses include the read-only +`simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null. +While `simultaneousCalls` is on, null means the exclusion has no end yet. +When it is off, a future timestamp is 30 days after the latest period ended. +At that timestamp the earlier period no longer excludes a ban. Null while off +means no recent period was reported. Neither null nor an elapsed timestamp +guarantees coverage; all other Ban Insurance conditions still apply. + +The server calculates this value. Do not send it in an update or calculate it +from the current switch alone. This addition is unreleased; source preparation +does not establish API deployment or package publication. diff --git a/packages/php/src/VoipModels.php b/packages/php/src/VoipModels.php index 965df26e..cad734f6 100644 --- a/packages/php/src/VoipModels.php +++ b/packages/php/src/VoipModels.php @@ -11,7 +11,7 @@ * @phpstan-type CreatedCallLink array{session:string,token:string,url:string,video:bool} * @phpstan-type PreviewedCallLink array{session:string,video:bool,creator:Identity,approvalRequired:bool,isAdmin:bool} * @phpstan-type Participant array{id:string,audioMuted:bool,video:bool,state:'invited'|'ringing'|'connected'|'left',handRaised?:bool,phoneNumber?:string,bsuid?:string,username?:string} - * @phpstan-type CallSettings array{callsEnabled:bool,conferenceMode:bool,inboundRoute:'clients'|'sip_trunk',sipTrunkId:string|null,sipClaim:bool,hostCloudApiCalls:bool,simultaneousCalls:bool,revision:int,updatedAt:string|null} + * @phpstan-type CallSettings array{simultaneousCallsCoverageExcludedUntil:string|null,callsEnabled:bool,conferenceMode:bool,inboundRoute:'clients'|'sip_trunk',sipTrunkId:string|null,sipClaim:bool,hostCloudApiCalls:bool,simultaneousCalls:bool,revision:int,updatedAt:string|null} * @phpstan-type PermissionLimit array{period:string,maxAllowed:int,used:int,resetsAt:string|null} * @phpstan-type PermissionAction array{allowed:bool,limits:list} * @phpstan-type PermissionState array{status:'none'|'temporary'|'permanent'|'revoked',expiresAt:string|null,source:'user_action'|'automatic'|'sync'|'call_refused'|null,updatedAt:string|null,checkedAt:string|null,fresh:bool,actions:array{requestPermission:PermissionAction|null,startCall:PermissionAction|null}|null} diff --git a/packages/php/tests/voip.php b/packages/php/tests/voip.php index d83c0d0a..7cf1f21b 100644 --- a/packages/php/tests/voip.php +++ b/packages/php/tests/voip.php @@ -58,3 +58,21 @@ $browser = new Polymorfa\MessagingClient(Polymorfa\Credential::clientToken('pmfa_ct_Zml4dHVyZQ')); raises(fn () => $browser->voip->accept('call_1', ['participant' => 'agent_1']), Polymorfa\ConfigurationException::class); echo count($cases)." typed Calls REST fixtures and validation/retry boundaries passed.\n"; + +$coverage = json_decode(file_get_contents(__DIR__.'/../../../contracts/fixtures/calls-coverage-end.candidate.json'), true, flags: JSON_THROW_ON_ERROR); +foreach ($coverage['cases'] as $case) { + foreach (['GET', 'PUT'] as $method) { + $history = []; + $settings = array_replace($coverage['settings'], ['simultaneousCalls' => $case['on'], 'simultaneousCallsCoverageExcludedUntil' => $case['until']]); + $client = new Polymorfa\MessagingClient($credential, http: mocked([jsonResponse(['success' => true, 'data' => $settings])], $history)); + $result = $method === 'GET' ? $client->voip->retrieveCallSettings('support') : $client->voip->updateCallSettings('support', ['callsEnabled' => false]); + check($result->data['data'] === $settings, 'Coverage end exact response'); + check($result->data['data']['simultaneousCallsCoverageExcludedUntil'] === $case['until'], 'Coverage nullable RFC3339'); + check($history[0]['request']->getMethod() === $method && $history[0]['request']->getUri()->getPath() === '/platform/sessions/support/call-settings', 'Coverage route'); + if ($method === 'PUT') check(json_decode((string) $history[0]['request']->getBody(), true) === ['callsEnabled' => false], 'Coverage end not writable'); + } +} +$history = []; +$client = new Polymorfa\MessagingClient($credential, http: mocked([], $history)); +raises(fn () => $client->voip->updateCallSettings('support', ['simultaneousCallsCoverageExcludedUntil' => null]), Polymorfa\ValidationException::class); +check($history === [], 'Response-only field is not a call setting'); diff --git a/packages/python/README.md b/packages/python/README.md index d640b700..b094523f 100644 --- a/packages/python/README.md +++ b/packages/python/README.md @@ -854,3 +854,18 @@ These are verified-event handling fragments, not complete receivers. Preserve whole-body signature verification before decoding and deduplicate batch members. See [the scoped source contract and compatibility notes](../../docs/campaign-webhook-metadata.md). Package publication and matching API environment acceptance remain separate gates. + + +### Call settings exclusion end (Unreleased) + +Call-settings GET and PUT responses include the read-only +`simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null. +While `simultaneousCalls` is on, null means the exclusion has no end yet. +When it is off, a future timestamp is 30 days after the latest period ended. +At that timestamp the earlier period no longer excludes a ban. Null while off +means no recent period was reported. Neither null nor an elapsed timestamp +guarantees coverage; all other Ban Insurance conditions still apply. + +The server calculates this value. Do not send it in an update or calculate it +from the current switch alone. This addition is unreleased; source preparation +does not establish API deployment or package publication. diff --git a/packages/python/src/polymorfa/voip_models.py b/packages/python/src/polymorfa/voip_models.py index 80197ba2..0aac3ab1 100644 --- a/packages/python/src/polymorfa/voip_models.py +++ b/packages/python/src/polymorfa/voip_models.py @@ -43,6 +43,7 @@ class CallParticipant(TypedDict): class SessionCallSettings(TypedDict): + simultaneousCallsCoverageExcludedUntil: str | None simultaneousCalls: bool callsEnabled: bool conferenceMode: bool diff --git a/packages/python/tests/test_calls_coverage_end.py b/packages/python/tests/test_calls_coverage_end.py new file mode 100644 index 00000000..4ff39207 --- /dev/null +++ b/packages/python/tests/test_calls_coverage_end.py @@ -0,0 +1,70 @@ +import asyncio +import json +from pathlib import Path + +import httpx +import pytest + +from polymorfa import AsyncMessagingClient, Credential, ValidationError + +FIXTURE = json.loads( + (Path(__file__).parents[3] / "contracts/fixtures/calls-coverage-end.candidate.json").read_text() +) + + +def test_call_settings_coverage_end_response_only(): + asyncio.run(_coverage_end()) + + +async def _coverage_end(): + for case in FIXTURE["cases"]: + for method in ("GET", "PUT"): + settings = { + **FIXTURE["settings"], + "simultaneousCalls": case["on"], + "simultaneousCallsCoverageExcludedUntil": case["until"], + } + + def handler(request): + assert request.method == method + assert request.url.path == "/platform/sessions/support/call-settings" + if method == "PUT": + assert json.loads(request.content) == {"callsEnabled": False} + return httpx.Response(200, json={"success": True, "data": settings}) + + async with AsyncMessagingClient( + credential=Credential( + "organization_api_key", "pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k" + ), + http_transport=httpx.MockTransport(handler), + ) as client: + result = ( + await client.voip.retrieve_call_settings("support") + if method == "GET" + else await client.voip.update_call_settings("support", {"callsEnabled": False}) + ) + assert ( + result.data["data"]["simultaneousCallsCoverageExcludedUntil"] == case["until"] + ) + assert result.data["data"] == settings + with pytest.raises(ValidationError): + await client.voip.update_call_settings( + "support", {"simultaneousCallsCoverageExcludedUntil": case["until"]} + ) + + +def test_call_settings_older_response_absence(): + asyncio.run(_older_response()) + + +async def _older_response(): + async with AsyncMessagingClient( + credential=Credential("organization_api_key", "pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"), + http_transport=httpx.MockTransport( + lambda _: httpx.Response(200, json={"success": True, "data": FIXTURE["settings"]}) + ), + ) as client: + assert ( + "simultaneousCallsCoverageExcludedUntil" + not in (await client.voip.retrieve_call_settings("support")).data["data"] + ) diff --git a/packages/rust/README.md b/packages/rust/README.md index 00f3db91..aaaf9f08 100644 --- a/packages/rust/README.md +++ b/packages/rust/README.md @@ -928,3 +928,21 @@ These are verified-event handling fragments, not complete receivers. Preserve whole-body signature verification before decoding and deduplicate batch members. See [the scoped source contract and compatibility notes](../../docs/campaign-webhook-metadata.md). Package publication and matching API environment acceptance remain separate gates. + + +### Call settings exclusion end (Unreleased) + +Call-settings GET and PUT responses include the read-only +`simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null. +While `simultaneousCalls` is on, null means the exclusion has no end yet. +When it is off, a future timestamp is 30 days after the latest period ended. +At that timestamp the earlier period no longer excludes a ban. Null while off +means no recent period was reported. Neither null nor an elapsed timestamp +guarantees coverage; all other Ban Insurance conditions still apply. + +The server calculates this value. Do not send it in an update or calculate it +from the current switch alone. This addition is unreleased; source preparation +does not establish API deployment or package publication. + +Rust response struct literals must include +`simultaneous_calls_coverage_excluded_until: None` or a timestamp. diff --git a/packages/rust/src/voip.rs b/packages/rust/src/voip.rs index 7052aeac..990282bc 100644 --- a/packages/rust/src/voip.rs +++ b/packages/rust/src/voip.rs @@ -116,6 +116,8 @@ pub struct HandRaisedRequest { #[derive(Clone, Debug, Serialize, Deserialize)] #[serde(rename_all = "camelCase")] pub struct SessionCallSettings { + /// Read-only RFC3339 exclusion end. None is not a coverage guarantee. + pub simultaneous_calls_coverage_excluded_until: Option, pub calls_enabled: bool, pub conference_mode: bool, pub inbound_route: String, diff --git a/packages/rust/tests/calls_coverage_end.rs b/packages/rust/tests/calls_coverage_end.rs new file mode 100644 index 00000000..4f549883 --- /dev/null +++ b/packages/rust/tests/calls_coverage_end.rs @@ -0,0 +1,69 @@ +#[allow(dead_code)] +mod support; +use polymorfa_sdk::{ + voip::{SessionCallSettings, UpdateSessionCallSettingsRequest}, + RequestOptions, +}; +use serde_json::{json, Value}; + +#[tokio::test] +async fn coverage_end_is_a_nullable_response_only_projection() { + let fixture: Value = serde_json::from_slice(include_bytes!( + "../../../contracts/fixtures/calls-coverage-end.candidate.json" + )) + .unwrap(); + for case in fixture["cases"].as_array().unwrap() { + let mut settings = fixture["settings"].clone(); + settings["simultaneousCalls"] = case["on"].clone(); + settings["simultaneousCallsCoverageExcludedUntil"] = case["until"].clone(); + wire_messaging!( + client, + "GET", + "/platform/sessions/support/call-settings", + None, + json!({"success":true,"data":settings}), + client + .voip() + .retrieve_call_settings("support", RequestOptions::default()) + ); + wire_messaging!( + client, + "PUT", + "/platform/sessions/support/call-settings", + Some(json!({"callsEnabled":false})), + json!({"success":true,"data":settings}), + client.voip().update_call_settings( + "support", + &UpdateSessionCallSettingsRequest { + calls_enabled: Some(false), + ..Default::default() + }, + RequestOptions::default() + ) + ); + } + let old: SessionCallSettings = serde_json::from_value(fixture["settings"].clone()).unwrap(); + assert_eq!(old.simultaneous_calls_coverage_excluded_until, None); + let literal = SessionCallSettings { + calls_enabled: true, + conference_mode: false, + inbound_route: "clients".into(), + sip_trunk_id: None, + sip_claim: false, + host_cloud_api_calls: false, + simultaneous_calls: false, + simultaneous_calls_coverage_excluded_until: Some("2026-11-22T12:34:56.789Z".into()), + revision: 2, + updated_at: None, + }; + assert_eq!( + serde_json::to_value(literal).unwrap()["simultaneousCallsCoverageExcludedUntil"], + "2026-11-22T12:34:56.789Z" + ); + let write = serde_json::to_value(UpdateSessionCallSettingsRequest { + calls_enabled: Some(false), + ..Default::default() + }) + .unwrap(); + assert_eq!(write, json!({"callsEnabled":false})); +} diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 6f3807aa..81b9be8c 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -3458,3 +3458,17 @@ These are verified-event handling fragments, not complete receivers. Preserve whole-body signature verification before decoding and deduplicate batch members. See [the scoped source contract and compatibility notes](../../docs/campaign-webhook-metadata.md). Package publication and matching API environment acceptance remain separate gates. + +### Call settings exclusion end (Unreleased) + +Call-settings GET and PUT responses include the read-only +`simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null. +While `simultaneousCalls` is on, null means the exclusion has no end yet. +When it is off, a future timestamp is 30 days after the latest period ended. +At that timestamp the earlier period no longer excludes a ban. Null while off +means no recent period was reported. Neither null nor an elapsed timestamp +guarantees coverage; all other Ban Insurance conditions still apply. + +The server calculates this value. Do not send it in an update or calculate it +from the current switch alone. This addition is unreleased; source preparation +does not establish API deployment or package publication. diff --git a/packages/typescript/src/messaging/types.ts b/packages/typescript/src/messaging/types.ts index aa3dd23b..cac5a39e 100644 --- a/packages/typescript/src/messaging/types.ts +++ b/packages/typescript/src/messaging/types.ts @@ -2168,6 +2168,8 @@ export type VoipAddParticipantResponse = SuccessEnvelope; /** Call settings for one session (`/platform/sessions/{session}/call-settings`). */ export interface SessionCallSettings { + /** Read-only RFC3339 exclusion end, or null. Null is not a coverage guarantee. */ + readonly simultaneousCallsCoverageExcludedUntil: string | null; /** * Whether the session can place, answer and receive calls. While `false`, * those actions fail with `calls_disabled`, incoming calls are declined and diff --git a/packages/typescript/test/calls-coverage-end.test.ts b/packages/typescript/test/calls-coverage-end.test.ts new file mode 100644 index 00000000..9c61d0fd --- /dev/null +++ b/packages/typescript/test/calls-coverage-end.test.ts @@ -0,0 +1,69 @@ +import { readFileSync } from "node:fs"; +import { describe, expect, it } from "vitest"; +import { MessagingClient } from "../src/index.js"; +import { ORGANIZATION_API_KEY } from "./support/credentials.js"; + +const fixture = JSON.parse( + readFileSync( + new URL( + "../../../contracts/fixtures/calls-coverage-end.candidate.json", + import.meta.url, + ), + "utf8", + ), +); +describe("call settings coverage end is response-only", () => { + for (const value of fixture.cases) + for (const method of ["GET", "PUT"]) { + it(value.name + method, async () => { + const settings = { + ...fixture.settings, + simultaneousCalls: value.on, + simultaneousCallsCoverageExcludedUntil: value.until, + }; + const client = new MessagingClient({ + credential: { type: "apiKey", value: ORGANIZATION_API_KEY }, + baseUrl: "https://api.example", + maxNetworkRetries: 0, + fetch: async (input, init) => { + expect(String(input)).toBe( + "https://api.example/platform/sessions/support/call-settings", + ); + expect(init?.method).toBe(method); + if (method === "PUT") + expect(JSON.parse(String(init?.body))).toEqual({ + callsEnabled: false, + }); + return new Response( + JSON.stringify({ success: true, data: settings }), + { headers: { "content-type": "application/json" } }, + ); + }, + }); + const result = + method === "GET" + ? await client.voip.retrieveCallSettings("support") + : await client.voip.updateCallSettings("support", { + callsEnabled: false, + }); + expect(result.data.data.simultaneousCallsCoverageExcludedUntil).toBe( + value.until, + ); + expect(result.data.data).toEqual(settings); + }); + } + it("preserves older-server absence without converting it to a coverage decision", async () => { + const client = new MessagingClient({ + credential: { type: "apiKey", value: ORGANIZATION_API_KEY }, + fetch: async () => + new Response( + JSON.stringify({ success: true, data: fixture.settings }), + { headers: { "content-type": "application/json" } }, + ), + }); + const result = await client.voip.retrieveCallSettings("support"); + expect( + Object.hasOwn(result.data.data, "simultaneousCallsCoverageExcludedUntil"), + ).toBe(false); + }); +}); From 78f6ba324e0c138848ada0dabbcc3e4340e80966 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 23 Oct 2026 15:48:59 +0300 Subject: [PATCH 02/21] Bind snippets to candidate response-only coverage-end source --- contracts/doc-snippets.json | 4 ++-- scripts/doc-snippets/recipes.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/doc-snippets.json b/contracts/doc-snippets.json index 11c2d7f5..5a9b8547 100644 --- a/contracts/doc-snippets.json +++ b/contracts/doc-snippets.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "sourceCommit": "32c75bd12f7686ee4890099c29df415f1c8a9c3e", - "sourceFilesSha256": "f9eeb2b51d8d8ec656d587bdef2998dbed11cd204fd2c3343625819dd02b3281", + "sourceCommit": "71f5893673ac2d61a9ed9b8b3919100868eaf311", + "sourceFilesSha256": "2fc582a52d78f52f27a002f480c4bb4ceae0edebf34492b875a86a62e9ef833b", "apiVersion": "2026-09-22", "apiSourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99", "specs": { diff --git a/scripts/doc-snippets/recipes.mjs b/scripts/doc-snippets/recipes.mjs index 8a5da267..97ddd9ee 100644 --- a/scripts/doc-snippets/recipes.mjs +++ b/scripts/doc-snippets/recipes.mjs @@ -1,7 +1,7 @@ // Requests come from the pinned OpenAPI and existing native wire fixtures. // Calls are handwritten in the language modules. Never derive arguments from // contracts/coverage.json's display-only method names. -export const sourceCommit = "32c75bd12f7686ee4890099c29df415f1c8a9c3e"; +export const sourceCommit = "71f5893673ac2d61a9ed9b8b3919100868eaf311"; export const apiVersion = "2026-09-22"; export const languages = [ "typescript", From ce6c41d069aa0f2b6e3f44790b1dabd0fe208112 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 23 Oct 2026 16:50:09 +0300 Subject: [PATCH 03/21] feat: prepare direct server passkey SDK counterparts --- CHANGELOG.md | 2 + contracts/fixtures/passkeys.candidate.json | 55 + contracts/passkeys.candidate.fragments.json | 2400 +++++++++++++++++ contracts/passkeys.candidate.json | 163 ++ docs/passkeys.md | 44 + packages/dotnet/README.md | 37 + .../src/Polymorfa.Sdk/MessagingClient.cs | 3 +- packages/dotnet/src/Polymorfa.Sdk/Passkeys.cs | 58 + .../tests/Polymorfa.Sdk.Tests/PasskeyTests.cs | 84 + .../tests/Polymorfa.Sdk.Tests/Program.cs | 2 + packages/go/README.md | 44 + packages/go/passkeys.go | 167 ++ packages/go/passkeys_test.go | 236 ++ packages/php/README.md | 36 + packages/php/src/MessagingClient.php | 2 + packages/php/src/PasskeyModels.php | 19 + packages/php/src/Resources/Passkeys.php | 87 + packages/php/tests/passkeys.php | 41 + packages/php/tests/run.php | 2 + packages/python/README.md | 39 + packages/python/src/polymorfa/__init__.py | 24 + packages/python/src/polymorfa/client.py | 3 + packages/python/src/polymorfa/passkeys.py | 193 ++ packages/python/tests/test_passkeys.py | 180 ++ packages/rust/README.md | 44 + packages/rust/src/lib.rs | 1 + packages/rust/src/messaging.rs | 4 + packages/rust/src/passkeys.rs | 229 ++ packages/rust/tests/passkeys.rs | 336 +++ packages/typescript/README.md | 37 + packages/typescript/src/index.ts | 2 + packages/typescript/src/messaging/client.ts | 3 + packages/typescript/src/messaging/passkeys.ts | 196 ++ packages/typescript/test/passkeys.test.ts | 259 ++ scripts/passkeys-candidate.test.mjs | 86 + 35 files changed, 5117 insertions(+), 1 deletion(-) create mode 100644 contracts/fixtures/passkeys.candidate.json create mode 100644 contracts/passkeys.candidate.fragments.json create mode 100644 contracts/passkeys.candidate.json create mode 100644 docs/passkeys.md create mode 100644 packages/dotnet/src/Polymorfa.Sdk/Passkeys.cs create mode 100644 packages/dotnet/tests/Polymorfa.Sdk.Tests/PasskeyTests.cs create mode 100644 packages/go/passkeys.go create mode 100644 packages/go/passkeys_test.go create mode 100644 packages/php/src/PasskeyModels.php create mode 100644 packages/php/src/Resources/Passkeys.php create mode 100644 packages/php/tests/passkeys.php create mode 100644 packages/python/src/polymorfa/passkeys.py create mode 100644 packages/python/tests/test_passkeys.py create mode 100644 packages/rust/src/passkeys.rs create mode 100644 packages/rust/tests/passkeys.rs create mode 100644 packages/typescript/src/messaging/passkeys.ts create mode 100644 packages/typescript/test/passkeys.test.ts create mode 100644 scripts/passkeys-candidate.test.mjs diff --git a/CHANGELOG.md b/CHANGELOG.md index 51f7ae39..236ba9c1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Unreleased +- Prepare direct server passkey challenge, assertion and explicit confirmation methods in all six SDKs. Writes use one attempt; ceremony execution remains external. Unreleased and awaiting matching API/package acceptance. + - Add the read-only nullable call-settings exclusion-end response field across all six SDKs. Rust response struct literals require the new optional field; C# positional construction remains compatible. Unreleased and dependent on the matching API contract. - All six server SDKs expose optional automatic-pause `reason`/`code` and diff --git a/contracts/fixtures/passkeys.candidate.json b/contracts/fixtures/passkeys.candidate.json new file mode 100644 index 00000000..4c97301b --- /dev/null +++ b/contracts/fixtures/passkeys.candidate.json @@ -0,0 +1,55 @@ +{ + "schemaVersion": 1, + "candidateSource": "c9902b86e4a1b52092029486cc16c1699456d539", + "sourceAccepted": false, + "session": "desk/1", + "ticketId": "ticket/?", + "issue": { + "challengeB64Url": "AQIDBAUGBwgJCgsMDQ4PEA", + "ttlSeconds": 300 + }, + "assertion": { + "ticketId": "ticket/?", + "assertion": { + "id": "aWQ", + "rawIdB64Url": "aWQ", + "response": { + "authenticatorDataB64": "AQID", + "clientDataJSONB64": "e30", + "signatureB64": "AQID", + "userHandleB64": "dXNlcg" + } + } + }, + "confirm": { + "ticketId": "ticket/?", + "code": "ABCD-EF12" + }, + "ticket": { + "version": 2, + "ticket": "ticket/?", + "session": "desk/1", + "rpId": "whatsapp.com", + "challengeB64Url": "AQIDBAUGBwgJCgsMDQ4PEA", + "userVerification": "required", + "expiresAt": "2026-10-23T16:00:00.123Z", + "timeoutMs": 120000 + }, + "assertionResult": { + "ok": true, + "ticket": "ticket/?", + "session": "desk/1" + }, + "statuses": [ + "none", + "challenge_pending", + "assertion_sending", + "assertion_submitted", + "code_available", + "confirmation_sending", + "waiting_for_pairing", + "completed", + "failed" + ], + "errors": [400, 403, 404, 409, 410, 413, 422, 502, 503] +} diff --git a/contracts/passkeys.candidate.fragments.json b/contracts/passkeys.candidate.fragments.json new file mode 100644 index 00000000..6541c21d --- /dev/null +++ b/contracts/passkeys.candidate.fragments.json @@ -0,0 +1,2400 @@ +{ + "paths": { + "/messaging/{session}/passkey/challenge": { + "post": { + "operationId": "issuePasskeyChallenge", + "tags": ["Passkey"], + "summary": "Issue a single-use WebAuthn challenge ticket for a session", + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sessions:manage`\n\nYour team must have direct-pairing access for the target Number. Use [QuickLink](/integrations/quicklink) for standard connections. Binds a WhatsApp-originated passkey challenge to one session with a short TTL. The extension runs the ceremony and the assertion comes back to the redeem endpoint.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "sessions:manage", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "session", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssuePasskeyChallengeRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Challenge ticket issued", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/IssuePasskeyChallengeResponse" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Bad request \u2014 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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "409": { + "description": "No passkey challenge pending for this session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "conflict_error", + "code": "state_conflict", + "message": "The request conflicts with the resource state.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#state-conflict" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "410": { + "description": "Passkey ticket expired. Issue a new challenge.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_gone", + "message": "The requested retained resource has expired or was removed.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-gone" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "413": { + "description": "Passkey body exceeds 1048576 UTF-8 bytes before JSON parsing.", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "422": { + "description": "Assertion is not bound to the ticket challenge", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "502": { + "description": "Upstream WhatsApp request failed or its response could not be mapped", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "503": { + "description": "Passkey service is temporarily unavailable or the request upload exceeded nine seconds", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + } + }, + "x-mint": { + "content": "Linked Device API sessions:manage" + }, + "x-protocols": ["Linked Device API"] + } + }, + "/messaging/{session}/passkey/challenge/{ticketId}": { + "get": { + "operationId": "getPasskeyChallenge", + "tags": ["Passkey"], + "summary": "Read a live passkey challenge ticket", + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sessions:manage`\n\nYour team must have direct-pairing access for the target Number. Returns the ceremony options for a ticket that has not expired and has not been redeemed.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "sessions:manage", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "session", + "in": "path" + }, + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "ticketId", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Live challenge ticket", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetPasskeyChallengeResponse" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Bad request \u2014 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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "409": { + "description": "No passkey challenge pending for this session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "conflict_error", + "code": "state_conflict", + "message": "The request conflicts with the resource state.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#state-conflict" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "410": { + "description": "Passkey ticket expired. Issue a new challenge.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_gone", + "message": "The requested retained resource has expired or was removed.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-gone" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "422": { + "description": "Assertion is not bound to the ticket challenge", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "502": { + "description": "Upstream WhatsApp request failed or its response could not be mapped", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "503": { + "description": "Passkey service is temporarily unavailable or the request upload exceeded nine seconds", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + } + }, + "x-mint": { + "content": "Linked Device API sessions:manage" + }, + "x-protocols": ["Linked Device API"] + } + }, + "/messaging/{session}/passkey/assertion": { + "post": { + "operationId": "submitPasskeyAssertion", + "tags": ["Passkey"], + "summary": "Redeem a passkey ticket with the ceremony assertion", + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sessions:manage`\n\nThe complete HTTP body is limited to 1048576 UTF-8 bytes before JSON parsing. Credential IDs allow at most 1023 decoded bytes; user handles allow 64. Opaque assertion fields share the body budget without independent small caps. Your team must have direct-pairing access for the target Number. Single-use: verifies the assertion is bound to the ticket challenge, forwards it to the session runner, and consumes the ticket once acknowledged.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "sessions:manage", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "session", + "in": "path" + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitPasskeyAssertionRequest" + } + } + } + }, + "responses": { + "200": { + "description": "Assertion accepted", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SubmitPasskeyAssertionResponse" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Bad request \u2014 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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "409": { + "description": "No passkey challenge pending for this session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "conflict_error", + "code": "state_conflict", + "message": "The request conflicts with the resource state.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#state-conflict" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "410": { + "description": "Passkey ticket expired. Issue a new challenge.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_gone", + "message": "The requested retained resource has expired or was removed.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-gone" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "413": { + "description": "Passkey body exceeds 1048576 UTF-8 bytes before JSON parsing.", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "422": { + "description": "Assertion is not bound to the ticket challenge", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "502": { + "description": "Upstream WhatsApp request failed or its response could not be mapped", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "503": { + "description": "Passkey service is temporarily unavailable or the request upload exceeded nine seconds", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + } + }, + "x-mint": { + "content": "Linked Device API sessions:manage" + }, + "x-protocols": ["Linked Device API"] + } + }, + "/messaging/{session}/passkey/confirmation": { + "post": { + "operationId": "confirmPasskey", + "tags": ["Passkey"], + "summary": "Confirm the current passkey pairing code", + "security": [ + { + "BearerAuth": [] + } + ], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sessions:manage`\n\nConfirm only after comparing the displayed code with the phone. Omit code only when the current runner reports skipHandoffUX. An accepted confirmation still waits for pairing completion.", + "x-required-scope": "sessions:manage", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "session", + "in": "path" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "ticketId": { + "type": "string", + "minLength": 1 + }, + "code": { + "type": "string", + "pattern": "^[A-Z0-9]{4}-[A-Z0-9]{4}$" + } + }, + "required": ["ticketId"], + "additionalProperties": false + } + } + } + }, + "responses": { + "200": { + "description": "Confirmation accepted; consult pairing state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ConfirmPasskeyResponse" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Bad request \u2014 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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "409": { + "description": "No passkey challenge pending for this session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "conflict_error", + "code": "state_conflict", + "message": "The request conflicts with the resource state.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#state-conflict" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "410": { + "description": "Passkey ticket expired. Issue a new challenge.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_gone", + "message": "The requested retained resource has expired or was removed.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-gone" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "413": { + "description": "Passkey body exceeds 1048576 UTF-8 bytes before JSON parsing.", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "422": { + "description": "Assertion is not bound to the ticket challenge", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "502": { + "description": "Upstream WhatsApp request failed or its response could not be mapped", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "503": { + "description": "Passkey service is temporarily unavailable or the request upload exceeded nine seconds", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + } + }, + "x-mint": { + "content": "Linked Device API sessions:manage" + }, + "x-protocols": ["Linked Device API"] + }, + "get": { + "operationId": "getPasskeyConfirmation", + "tags": ["Passkey"], + "summary": "Read the passkey round-trip state for a session", + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `sessions:manage`\n\nYour team must have direct-pairing access for the target Number. Reports where the ceremony stands: no round-trip, challenge pending, assertion submitted, code available for comparison, confirmation sending, waiting for pairing, completed, or failed.", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "sessions:manage", + "parameters": [ + { + "schema": { + "type": "string", + "minLength": 1 + }, + "required": true, + "name": "session", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Round-trip state", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetPasskeyConfirmationResponse" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "400": { + "description": "Bad request \u2014 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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "409": { + "description": "No passkey challenge pending for this session", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "conflict_error", + "code": "state_conflict", + "message": "The request conflicts with the resource state.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#state-conflict" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "410": { + "description": "Passkey ticket expired. Issue a new challenge.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/PublicError" + }, + "example": { + "error": { + "type": "invalid_request_error", + "code": "resource_gone", + "message": "The requested retained resource has expired or was removed.", + "param": null, + "request_id": "5f0c2a8e-3b1d-4c6f-9e2a-7d4b1c8f6a30" + }, + "data": null, + "docs": "https://docs.polymorfa.com/api/errors#resource-gone" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "422": { + "description": "Assertion is not bound to the ticket challenge", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "502": { + "description": "Upstream WhatsApp request failed or its response could not be mapped", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + }, + "503": { + "description": "Passkey service is temporarily unavailable or the request upload exceeded nine seconds", + "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" + } + } + }, + "headers": { + "X-RateLimit-Limit": { + "$ref": "#/components/headers/X-RateLimit-Limit" + }, + "X-RateLimit-Remaining": { + "$ref": "#/components/headers/X-RateLimit-Remaining" + }, + "X-RateLimit-Reset": { + "$ref": "#/components/headers/X-RateLimit-Reset" + } + } + } + }, + "x-mint": { + "content": "Linked Device API sessions:manage" + }, + "x-protocols": ["Linked Device API"] + } + } + }, + "components": { + "schemas": { + "PublicError": { + "type": "object", + "properties": { + "error": { + "type": "object", + "properties": { + "type": { + "type": "string", + "enum": [ + "invalid_request_error", + "authentication_error", + "permission_error", + "conflict_error", + "rate_limit_error", + "api_error", + "upstream_error" + ] + }, + "code": { + "type": "string", + "enum": [ + "invalid_parameter", + "invalid_credential", + "entitlement_required", + "cloud_template_authority_restricted", + "gate_limit_reached", + "method_not_allowed", + "permission_denied", + "missing_scope", + "resource_not_found", + "hms_not_enabled", + "media_fetch_failed", + "resource_gone", + "feature_unavailable", + "stream_cursor_invalid", + "stream_cursor_expired", + "stream_connection_limit_reached", + "state_conflict", + "team_deletion_scheduled", + "hybrid_routing_paused", + "hybrid_choice_required", + "hybrid_transition_ineligible", + "hybrid_automation_unavailable", + "hybrid_group_linked_devices_unavailable", + "hybrid_transport_unavailable", + "hybrid_target_reference_unavailable", + "send_outcome_unknown", + "idempotency_conflict", + "idempotency_in_progress", + "idempotency_completed", + "idempotency_outcome_unknown", + "call_claimed", + "call_not_ringing", + "connection_limit", + "unsupported_for_connection", + "call_permission_required", + "invalid_sip_trunk", + "calls_disabled", + "call_recipient_opted_out", + "call_destination_blocked", + "call_permission_request_limited", + "call_permission_granted", + "order_status_transition_invalid", + "order_cancellation_failed", + "call_opt_out_limit", + "number_restricted", + "sip_trunk_in_use", + "sip_trunk_revision_conflict", + "sip_trunk_limit", + "sip_unavailable", + "voice_not_enabled", + "provider_credential_invalid", + "provider_unavailable", + "asset_not_ready", + "voice_asset_in_use", + "voice_asset_revision_conflict", + "voice_unavailable", + "rate_limit_exceeded", + "internal_error", + "operation_not_supported", + "upstream_failure", + "service_unavailable", + "credential_verification_unavailable", + "session_not_ready", + "command_dispatch_failed", + "result_unknown", + "parent_bsuid_not_supported", + "recipient_not_on_whatsapp", + "conversation_window_closed", + "template_not_approved", + "media_too_large", + "whatsapp_rate_limited", + "new_chat_limit_reached", + "marketing_disabled_on_cloud_api", + "whatsapp_account_restricted", + "whatsapp_authorization_required", + "whatsapp_payment_method_required", + "whatsapp_number_not_registered", + "whatsapp_groups_ineligible", + "whatsapp_group_creation_paused", + "whatsapp_group_limit_reached", + "whatsapp_group_full", + "whatsapp_group_suspended", + "whatsapp_group_has_no_participants", + "bansafe_suspended", + "bansafe_org_suspended", + "bansafe_cold_blocked", + "bansafe_cold_held", + "bansafe_throttled", + "bansafe_daily_allowance_reached", + "bansafe_accounting_unavailable", + "bansafe_send_outcome_unknown", + "campaigns_not_entitled", + "campaign_throughput_capped", + "campaign_approval_required", + "campaign_approval_stale", + "campaign_variables_missing" + ] + }, + "message": { + "type": "string" + }, + "param": { + "type": "string", + "nullable": true + }, + "request_id": { + "type": "string", + "description": "Matches the X-Request-Id response header. Include it when you contact support." + }, + "request_log_url": { + "type": "string", + "format": "uri", + "description": "Console page that shows this request. Returned only to organization and project credentials whose request was logged." + } + }, + "required": ["type", "code", "message", "param", "request_id"] + }, + "data": { + "type": "string", + "nullable": true, + "enum": [null], + "description": "Always null for a failed request." + }, + "docs": { + "type": "string", + "format": "uri" + } + }, + "required": ["error", "data", "docs"] + }, + "GetPasskeyConfirmationResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "data": { + "$ref": "#/components/schemas/PasskeyConfirmation" + } + }, + "required": ["success", "data"] + }, + "SubmitPasskeyAssertionResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "data": { + "$ref": "#/components/schemas/PasskeyAssertionResult" + } + }, + "required": ["success", "data"] + }, + "PasskeyAssertionResult": { + "type": "object", + "properties": { + "ok": { + "type": "boolean", + "enum": [true] + }, + "ticket": { + "type": "string" + }, + "session": { + "type": "string" + } + }, + "required": ["ok", "ticket", "session"] + }, + "ConfirmPasskeyResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "data": { + "$ref": "#/components/schemas/PasskeyConfirmation" + } + }, + "required": ["success", "data"] + }, + "SubmitPasskeyAssertionRequest": { + "type": "object", + "properties": { + "ticketId": { + "type": "string", + "minLength": 1 + }, + "assertion": { + "$ref": "#/components/schemas/PasskeyAssertion" + } + }, + "required": ["ticketId", "assertion"] + }, + "GetPasskeyChallengeResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "data": { + "$ref": "#/components/schemas/PasskeyTicket" + } + }, + "required": ["success", "data"] + }, + "PasskeyTicket": { + "type": "object", + "properties": { + "version": { + "type": "number", + "enum": [2] + }, + "ticket": { + "type": "string" + }, + "session": { + "type": "string" + }, + "rpId": { + "type": "string" + }, + "challengeB64Url": { + "type": "string" + }, + "userVerification": { + "type": "string", + "enum": ["preferred", "required", "discouraged"] + }, + "expiresAt": { + "type": "string" + }, + "timeoutMs": { + "type": "integer" + } + }, + "required": [ + "version", + "ticket", + "session", + "rpId", + "challengeB64Url", + "userVerification", + "expiresAt" + ] + }, + "IssuePasskeyChallengeRequest": { + "type": "object", + "properties": { + "challengeB64Url": { + "type": "string", + "minLength": 1, + "description": "Optional expected challenge. If supplied it must match the current runner snapshot; it never supplies ceremony options." + }, + "userVerification": { + "type": "string", + "enum": ["preferred", "required", "discouraged"], + "description": "Ignored. The current runner determines user verification.", + "deprecated": true + }, + "ttlSeconds": { + "type": "integer", + "description": "Requested ticket lifetime, independent of the WebAuthn timeout. Defaults to 300 seconds and is clamped to 255\u2013300 seconds, then capped by the current challenge deadline. No new ticket is issued with less than 255 seconds remaining. The budget includes one minute to enter the ticket, two 10-second verification calls, 30 seconds for explicit consent, 15 seconds for popup startup, a five-second minimum prompt, and a 120-second post-prompt reserve for final verification, one minute to copy the assertion, both direct server RPCs, access checks and transit. A short remaining window is withheld from new callers without retiring an advertised live ticket. Renewal waits for expiry; an explicitly shorter lifetime constraint may retire an unreserved ticket. In-flight forwarding blocks replacement. Current runner authority is revalidated throughout." + }, + "timeoutMs": { + "type": "integer", + "minimum": 0, + "exclusiveMinimum": true, + "description": "Ignored. The current runner determines ceremony timeout.", + "deprecated": true + } + } + }, + "IssuePasskeyChallengeResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "data": { + "$ref": "#/components/schemas/PasskeyTicket" + } + }, + "required": ["success", "data"] + }, + "PasskeyAssertion": { + "type": "object", + "properties": { + "id": { + "type": "string", + "minLength": 1, + "maxLength": 1364 + }, + "rawIdB64Url": { + "type": "string", + "minLength": 1, + "maxLength": 1364 + }, + "response": { + "$ref": "#/components/schemas/PasskeyAssertionResponse" + } + }, + "required": ["id", "rawIdB64Url", "response"] + }, + "PasskeyAssertionResponse": { + "type": "object", + "properties": { + "authenticatorDataB64": { + "type": "string", + "minLength": 1 + }, + "clientDataJSONB64": { + "type": "string", + "minLength": 1 + }, + "signatureB64": { + "type": "string", + "minLength": 1 + }, + "userHandleB64": { + "type": "string", + "maxLength": 86 + } + }, + "required": [ + "authenticatorDataB64", + "clientDataJSONB64", + "signatureB64" + ] + }, + "PasskeyConfirmation": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": [ + "none", + "challenge_pending", + "assertion_sending", + "assertion_submitted", + "code_available", + "confirmation_sending", + "waiting_for_pairing", + "completed", + "failed" + ] + }, + "ticketId": { + "type": "string" + }, + "code": { + "type": "string" + }, + "skipHandoffUX": { + "type": "boolean" + }, + "error": { + "type": "string" + } + }, + "required": ["status"] + } + } + } +} diff --git a/contracts/passkeys.candidate.json b/contracts/passkeys.candidate.json new file mode 100644 index 00000000..8f86a16a --- /dev/null +++ b/contracts/passkeys.candidate.json @@ -0,0 +1,163 @@ +{ + "schemaVersion": 1, + "sourceAccepted": false, + "published": false, + "candidateSource": { + "repository": "polymorfa/polymorfa", + "commit": "c9902b86e4a1b52092029486cc16c1699456d539", + "tree": "9925573ab3e03270e81c719a4fc828c86f2be74c", + "parent": "45f4a8511bd604bb4a40e70748b1b45a3d94cb9a", + "files": [ + { + "path": "apps/api/packages/api/src/routes/session-passkey.ts", + "sha256": "04fdd4beb424a83f2fb63f5090af26992b06c60a2123bb2f35a5dc31150bb890" + }, + { + "path": "apps/api/packages/api/src/lib/session-passkey-tickets.ts", + "sha256": "e5f0b4b941c506db0e0bb42236c02d7f36b134fd1b0f635678925dcdad1276c0" + }, + { + "path": "apps/api/packages/api/src/lib/passkey-assertion-limits.ts", + "sha256": "f442c6d9b79504263f807343838a2217c0c99149a196bce0318679783dfeed52" + }, + { + "path": "apps/api/packages/api/src/middleware/client-rules.ts", + "sha256": "96fdd17e89e50bf252c64521ba553b182d5aacce7e8bf9116e673b627c5d825d" + }, + { + "path": "apps/api/packages/api/src/lib/credential-capabilities.ts", + "sha256": "009b4093178524fb9627bf074951000bef98c6dfd1d3d8164e65072222ec8076" + }, + { + "path": "apps/api/packages/api/src/composition/messaging-native.ts", + "sha256": "368c86c010b641d39efdb984fffb5346b1e3e33cf3e64a18557b4f36cc1b31f4" + }, + { + "path": "apps/api/docs/openapi.json", + "sha256": "d753e0f97270509d068ebdc26ec10a8edf6c51021eeec65c60c6f5493d7504ec" + }, + { + "path": "docs/api-reference/openapi.json", + "sha256": "3167cbd926f5ca2de88d55852c65e6b840e5eb722e1d6cae0ffbf648dc1c2029" + }, + { + "path": "docs/openapi.json", + "sha256": "3810d4665f3fe4050e1ec0ca87da8362b8ce7a35734c7cefb0ee47a7d9de04cf" + } + ] + }, + "fragment": { + "path": "contracts/passkeys.candidate.fragments.json", + "sha256": "4099350294f54141d49aa25caa9ecb833823d98f7b563ba4d9478da024f58722" + }, + "operations": [ + { + "operationId": "issuePasskeyChallenge", + "method": "POST", + "path": "/messaging/{session}/passkey/challenge", + "audience": "customer server", + "requiredScope": "sessions:manage", + "serverCredentialsOnly": true, + "automaticRetries": 0, + "counterparts": { + "typescript": "passkeys.issueChallenge", + "go": "Passkeys().IssueChallenge", + "python": "passkeys.issue_challenge", + "php": "passkeys->issueChallenge", + "rust": "passkeys().issue_challenge", + "dotnet": "Passkeys.IssueChallengeAsync" + } + }, + { + "operationId": "getPasskeyChallenge", + "method": "GET", + "path": "/messaging/{session}/passkey/challenge/{ticketId}", + "audience": "customer server", + "requiredScope": "sessions:manage", + "serverCredentialsOnly": true, + "automaticRetries": "ordinary request policy", + "counterparts": { + "typescript": "passkeys.getChallenge", + "go": "Passkeys().GetChallenge", + "python": "passkeys.get_challenge", + "php": "passkeys->getChallenge", + "rust": "passkeys().get_challenge", + "dotnet": "Passkeys.GetChallengeAsync" + } + }, + { + "operationId": "submitPasskeyAssertion", + "method": "POST", + "path": "/messaging/{session}/passkey/assertion", + "audience": "customer server", + "requiredScope": "sessions:manage", + "serverCredentialsOnly": true, + "automaticRetries": 0, + "counterparts": { + "typescript": "passkeys.submitAssertion", + "go": "Passkeys().SubmitAssertion", + "python": "passkeys.submit_assertion", + "php": "passkeys->submitAssertion", + "rust": "passkeys().submit_assertion", + "dotnet": "Passkeys.SubmitAssertionAsync" + } + }, + { + "operationId": "getPasskeyConfirmation", + "method": "GET", + "path": "/messaging/{session}/passkey/confirmation", + "audience": "customer server", + "requiredScope": "sessions:manage", + "serverCredentialsOnly": true, + "automaticRetries": "ordinary request policy", + "counterparts": { + "typescript": "passkeys.getConfirmation", + "go": "Passkeys().GetConfirmation", + "python": "passkeys.get_confirmation", + "php": "passkeys->getConfirmation", + "rust": "passkeys().get_confirmation", + "dotnet": "Passkeys.GetConfirmationAsync" + } + }, + { + "operationId": "confirmPasskey", + "method": "POST", + "path": "/messaging/{session}/passkey/confirmation", + "audience": "customer server", + "requiredScope": "sessions:manage", + "serverCredentialsOnly": true, + "automaticRetries": 0, + "counterparts": { + "typescript": "passkeys.confirm", + "go": "Passkeys().Confirm", + "python": "passkeys.confirm", + "php": "passkeys->confirm", + "rust": "passkeys().confirm", + "dotnet": "Passkeys.ConfirmAsync" + } + } + ], + "excluded": [ + { + "operationId": "verifyExtensionPasskeyTicket", + "reason": "Extension/hosted participant ceremony relay, not machine SDK audience" + }, + { + "operationId": "submitQuickLinkPasskey", + "reason": "Extension/hosted participant ceremony relay, not machine SDK audience" + }, + { + "operationId": "confirmQuickLinkPasskey", + "reason": "Extension/hosted participant ceremony relay, not machine SDK audience" + } + ], + "coexistingCandidate": { + "commit": "0b7b739493b089c386fc21f2cf76f05f60de9151", + "laterTestOnlyHead": "fce83265c1c99b75de3678191e5b07aac6de2cf0", + "sourceAccepted": false, + "published": false, + "note": "Calls response-only candidate preserved in parent78f6; c990 does not contain it. Unified containing API acceptance and final specs remain required." + }, + "acceptedPinsUnchanged": true, + "coverageLedgerCredit": false +} diff --git a/docs/passkeys.md b/docs/passkeys.md new file mode 100644 index 00000000..aede159a --- /dev/null +++ b/docs/passkeys.md @@ -0,0 +1,44 @@ +# Direct server passkey relay (unreleased) + +Use server credentials with `sessions:manage` for every operation, including reads. +The API also checks live Number ownership, direct-pairing access, relay enrollment +and the current attempt. These methods accept ceremony data supplied by an +external participant. They do not run WebAuthn, store credentials, poll, or confirm +a pairing automatically. Hosted QuickLink and extension relay endpoints are excluded. + +Challenge TTL uses seconds; ceremony `timeoutMs` uses milliseconds. The server +owns ceremony options and ignores the deprecated `userVerification` and +`timeoutMs` request fields. An optional expected `challengeB64Url` must match the +current attempt. Keep `rawIdB64Url` and the named `*B64` assertion strings unchanged; +do not convert between base64 and base64url. Omit optional fields when absent. + +Challenge issuance, assertion submission and explicit confirmation each make one +attempt, even with caller retry options or an idempotency key. If the outcome is +uncertain, read current challenge/confirmation state and reconcile it before a new +write. Assertion acceptance is not pairing completion. Forwarded API failures keep +their status and open error code; the SDK does not invent passkey error codes. + +The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16 +units and optional user handles allow 86. Other opaque assertion fields share the +body limit. The API validates encodings, decoded bounds and challenge binding. + +This is source preparation. Matching API acceptance, package qualification and +publication are still required before using a released package. + +| Operation | TypeScript | Go | Python | PHP | Rust | .NET | +| ---------------------- | ------------------------ | -------------------------- | ------------------------- | ------------------------- | --------------------------- | ----------------------------- | +| issuePasskeyChallenge | passkeys.issueChallenge | Passkeys().IssueChallenge | passkeys.issue_challenge | passkeys->issueChallenge | passkeys().issue_challenge | Passkeys.IssueChallengeAsync | +| getPasskeyChallenge | passkeys.getChallenge | Passkeys().GetChallenge | passkeys.get_challenge | passkeys->getChallenge | passkeys().get_challenge | Passkeys.GetChallengeAsync | +| submitPasskeyAssertion | passkeys.submitAssertion | Passkeys().SubmitAssertion | passkeys.submit_assertion | passkeys->submitAssertion | passkeys().submit_assertion | Passkeys.SubmitAssertionAsync | +| getPasskeyConfirmation | passkeys.getConfirmation | Passkeys().GetConfirmation | passkeys.get_confirmation | passkeys->getConfirmation | passkeys().get_confirmation | Passkeys.GetConfirmationAsync | +| confirmPasskey | passkeys.confirm | Passkeys().Confirm | passkeys.confirm | passkeys->confirm | passkeys().confirm | Passkeys.ConfirmAsync | + +Confirmation states are `none`, `challenge_pending`, `assertion_sending`, +`assertion_submitted`, `code_available`, `confirmation_sending`, +`waiting_for_pairing`, `completed` and `failed`. Optional code, ticket, handoff and +error fields retain their presence. A missing code does not authorize confirmation. + +New Rust request structs use optional fields or `Default` where applicable; public +struct literals must name their fields. These additions do not change existing +Calls struct shapes. New .NET records leave existing constructors and Deconstruct +methods unchanged. diff --git a/packages/dotnet/README.md b/packages/dotnet/README.md index 7d46bbf6..6ebaca72 100644 --- a/packages/dotnet/README.md +++ b/packages/dotnet/README.md @@ -1051,3 +1051,40 @@ does not establish API deployment or package publication. The existing nine-position constructor and Deconstruct remain unchanged. The new nullable response value is an init-only property. + +## Direct passkey relay (unreleased) + +Use server credentials with `sessions:manage` for every operation, including reads. +The API also checks live Number ownership, direct-pairing access, relay enrollment +and the current attempt. These methods accept ceremony data supplied by an +external participant. They do not run WebAuthn, store credentials, poll, or confirm +a pairing automatically. Hosted QuickLink and extension relay endpoints are excluded. + +Challenge TTL uses seconds; ceremony `timeoutMs` uses milliseconds. The server +owns ceremony options and ignores the deprecated `userVerification` and +`timeoutMs` request fields. An optional expected `challengeB64Url` must match the +current attempt. Keep `rawIdB64Url` and the named `*B64` assertion strings unchanged; +do not convert between base64 and base64url. Omit optional fields when absent. + +Challenge issuance, assertion submission and explicit confirmation each make one +attempt, even with caller retry options or an idempotency key. If the outcome is +uncertain, read current challenge/confirmation state and reconcile it before a new +write. Assertion acceptance is not pairing completion. Forwarded API failures keep +their status and open error code; the SDK does not invent passkey error codes. + +The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16 +units and optional user handles allow 86. Other opaque assertion fields share the +body limit. The API validates encodings, decoded bounds and challenge binding. + +This is source preparation. Matching API acceptance, package qualification and +publication are still required before using a released package. + +```csharp +var ticket = await client.Passkeys.IssueChallengeAsync(session); +await client.Passkeys.GetChallengeAsync(session, ticket.Data.Data.Ticket); +await client.Passkeys.SubmitAssertionAsync(session, suppliedAssertion); +var state = await client.Passkeys.GetConfirmationAsync(session); +// Invoke only after an explicit caller decision based on the current state. +Task>> ConfirmExplicitly() => + client.Passkeys.ConfirmAsync(session, suppliedConfirmation); +``` diff --git a/packages/dotnet/src/Polymorfa.Sdk/MessagingClient.cs b/packages/dotnet/src/Polymorfa.Sdk/MessagingClient.cs index 3b2f11c9..8febc34f 100644 --- a/packages/dotnet/src/Polymorfa.Sdk/MessagingClient.cs +++ b/packages/dotnet/src/Polymorfa.Sdk/MessagingClient.cs @@ -5,6 +5,7 @@ namespace Polymorfa.Sdk; public sealed class MessagingClient : IDisposable { private readonly HttpTransport http; + public Passkeys Passkeys { get; } public MessagingSessions Sessions { get; } public Messages Messages { get; } public QuickLinks QuickLinks { get; } @@ -40,7 +41,7 @@ public sealed class MessagingClient : IDisposable public MessagingClient(Credential credential, ClientOptions? options = null) { if (credential.Kind != CredentialKind.ClientToken) credential.RequireServer(); - http = new(credential, options ?? new()); Sessions = new(http); Messages = new(http); QuickLinks = new(http); Webhooks = new(http); Calls = new(http); CloudOnboarding = new(http); Testing = new(http); ObservationPolicies = new(http); HybridLink = new(http); Templates = new(http); Campaigns = new(http); CloudGraph = new(http); CloudTemplates = new(http); Business = new(http); Channels = new(http); QuickReplies = new(http); BanSafe = new(http); Contacts = new(http); OfficialGroups = new(http); Catalogs = new(http); Groups = new(http); Chats = new(http); Labels = new(http); Presence = new(http); Profile = new(http); Privacy = new(http); ClientTokens = new(http); Identities = new(http); Users = new(http); Media = new(http); WhatsAppMedia = new(http); HistorySync = new(http); + http = new(credential, options ?? new()); Passkeys = new(http); Sessions = new(http); Messages = new(http); QuickLinks = new(http); Webhooks = new(http); Calls = new(http); CloudOnboarding = new(http); Testing = new(http); ObservationPolicies = new(http); HybridLink = new(http); Templates = new(http); Campaigns = new(http); CloudGraph = new(http); CloudTemplates = new(http); Business = new(http); Channels = new(http); QuickReplies = new(http); BanSafe = new(http); Contacts = new(http); OfficialGroups = new(http); Catalogs = new(http); Groups = new(http); Chats = new(http); Labels = new(http); Presence = new(http); Profile = new(http); Privacy = new(http); ClientTokens = new(http); Identities = new(http); Users = new(http); Media = new(http); WhatsAppMedia = new(http); HistorySync = new(http); } public Task> PaginateRawAsync(RawRequest request, RawPageDecoder decode, string cursorParameter = "cursor") => RawCursorPage.LoadAsync(http, request, decode, cursorParameter); public Task> RawAsync(HttpMethod method, string path, JsonElement? body = null, RequestOptions? options = null, IReadOnlyList>? query = null) => http.RequestAsync(method, path, body, options, query); diff --git a/packages/dotnet/src/Polymorfa.Sdk/Passkeys.cs b/packages/dotnet/src/Polymorfa.Sdk/Passkeys.cs new file mode 100644 index 00000000..269d3f0e --- /dev/null +++ b/packages/dotnet/src/Polymorfa.Sdk/Passkeys.cs @@ -0,0 +1,58 @@ +using System.Text.Json; +using System.Text.Json.Serialization; + +namespace Polymorfa.Sdk; + +/// Supplied current-challenge guard and TTL seconds. UserVerification and TimeoutMs are deprecated, ignored server inputs. +public sealed record IssuePasskeyChallenge(string? ChallengeB64Url = null, long? TtlSeconds = null, string? UserVerification = null, long? TimeoutMs = null); +public sealed record PasskeyTicket(int Version, string Ticket, string Session, string RpId, string ChallengeB64Url, string UserVerification, string ExpiresAt, long? TimeoutMs = null); +public sealed record PasskeyAssertionResponse(string AuthenticatorDataB64, [property: JsonPropertyName("clientDataJSONB64")] string ClientDataJSONB64, string SignatureB64, string? UserHandleB64 = null); +public sealed record PasskeyAssertion(string Id, string RawIdB64Url, PasskeyAssertionResponse Response); +public sealed record SubmitPasskeyAssertion(string TicketId, PasskeyAssertion Assertion); +public sealed record PasskeyAssertionResult(bool Ok, string Ticket, string Session); +public sealed record ConfirmPasskey(string TicketId, string? Code = null); +public sealed record PasskeyConfirmation(string Status, string? TicketId = null, string? Code = null, [property: JsonPropertyName("skipHandoffUX")] bool? SkipHandoffUX = null, string? Error = null); +/// Direct server operations. No WebAuthn ceremony, polling or automatic confirmation. +public sealed class Passkeys +{ + private readonly HttpTransport http; + internal Passkeys(HttpTransport http) => this.http = http; + public static IReadOnlyList Statuses { get; } = Array.AsReadOnly(new[] { "none", "challenge_pending", "assertion_sending", "assertion_submitted", "code_available", "confirmation_sending", "waiting_for_pairing", "completed", "failed" }); + private string Path(string session) + { + http.Credential.RequireServer(); + if (string.IsNullOrEmpty(session)) throw Invalid("Provide a Number ID."); + return $"/messaging/{Uri.EscapeDataString(session)}/passkey"; + } + private static PolymorfaValidationException Invalid(string message) => new(message, "invalid_parameter"); + private Task>> Write(string path, object body, RequestOptions? options) + { + if (JsonSerializer.SerializeToUtf8Bytes(body, HttpTransport.Json).Length > 1048576) throw Invalid("Passkey request exceeds 1 MiB."); + return http.RequestAsync>(HttpMethod.Post, path, body, (options ?? new()) with { MaxNetworkRetries = 0 }); + } + public Task>> IssueChallengeAsync(string session, IssuePasskeyChallenge? body = null, RequestOptions? options = null) + { + var path = Path(session); body ??= new(); + if (body.ChallengeB64Url is { Length: 0 } || body.TimeoutMs is <= 0 || body.UserVerification is not null and not ("required" or "preferred" or "discouraged")) throw Invalid("Invalid challenge options."); + return Write(path + "/challenge", body, options); + } + public Task>> GetChallengeAsync(string session, string ticketId, RequestOptions? options = null) + { + var path = Path(session); + if (string.IsNullOrEmpty(ticketId)) throw Invalid("Provide a ticket."); + return http.RequestAsync>(HttpMethod.Get, path + "/challenge/" + Uri.EscapeDataString(ticketId), null, options); + } + public Task>> SubmitAssertionAsync(string session, SubmitPasskeyAssertion body, RequestOptions? options = null) + { + var path = Path(session); var a = body.Assertion; var r = a?.Response; + if (string.IsNullOrEmpty(body.TicketId) || a is null || string.IsNullOrEmpty(a.Id) || a.Id.Length > 1364 || string.IsNullOrEmpty(a.RawIdB64Url) || a.RawIdB64Url.Length > 1364 || r is null || string.IsNullOrEmpty(r.AuthenticatorDataB64) || string.IsNullOrEmpty(r.ClientDataJSONB64) || string.IsNullOrEmpty(r.SignatureB64) || r.UserHandleB64?.Length > 86) throw Invalid("Provide the ticket and bounded assertion."); + return Write(path + "/assertion", body, options); + } + public Task>> GetConfirmationAsync(string session, RequestOptions? options = null) => http.RequestAsync>(HttpMethod.Get, Path(session) + "/confirmation", null, options); + public Task>> ConfirmAsync(string session, ConfirmPasskey body, RequestOptions? options = null) + { + var path = Path(session); var code = body.Code; + if (string.IsNullOrEmpty(body.TicketId) || code is not null && (code.Length != 9 || code[4] != '-' || code.Where((_, i) => i != 4).Any(c => c is not (>= 'A' and <= 'Z') and not (>= '0' and <= '9')))) throw Invalid("Provide the ticket and optional confirmation code."); + return Write(path + "/confirmation", body, options); + } +} diff --git a/packages/dotnet/tests/Polymorfa.Sdk.Tests/PasskeyTests.cs b/packages/dotnet/tests/Polymorfa.Sdk.Tests/PasskeyTests.cs new file mode 100644 index 00000000..b602af73 --- /dev/null +++ b/packages/dotnet/tests/Polymorfa.Sdk.Tests/PasskeyTests.cs @@ -0,0 +1,84 @@ +using System.Text.Json; +using Polymorfa.Sdk; + +internal static class PasskeyTests +{ + private sealed class BrokenHandler(bool nonJson) : HttpMessageHandler + { + internal int Count; + protected override async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + Count++; + if (request.Content is not null) await request.Content.ReadAsByteArrayAsync(cancellationToken); + if (!nonJson) throw new HttpRequestException("Connection lost after upload."); + return new HttpResponseMessage(System.Net.HttpStatusCode.BadGateway) { Content = new StringContent("unavailable", System.Text.Encoding.UTF8, "text/html") }; + } + } + public static async Task RunAsync() + { + var options = new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase }; + var fixturePath = Environment.GetEnvironmentVariable("PASSKEY_FIXTURE"); + if (fixturePath is null) + { + DirectoryInfo? root = new DirectoryInfo(Directory.GetCurrentDirectory()); + while (root is not null && !File.Exists(System.IO.Path.Combine(root.FullName, "contracts/fixtures/passkeys.candidate.json"))) root = root.Parent; + fixturePath = root is null ? throw new Exception("Passkey fixture root not found.") : System.IO.Path.Combine(root.FullName, "contracts/fixtures/passkeys.candidate.json"); + } + using var doc = JsonDocument.Parse(File.ReadAllText(fixturePath)); + var f = doc.RootElement; + var issue = JsonSerializer.Deserialize(f.GetProperty("issue"), options)!; + var assertion = JsonSerializer.Deserialize(f.GetProperty("assertion"), options)!; + var confirm = JsonSerializer.Deserialize(f.GetProperty("confirm"), options)!; + string Reply(string key) => "{\"success\":true,\"data\":" + f.GetProperty(key).GetRawText() + "}"; + await ResourceTests.Messaging("POST", "/messaging/desk%2F1/passkey/challenge", f.GetProperty("issue").GetRawText(), Reply("ticket"), async c => { var r = await c.Passkeys.IssueChallengeAsync("desk/1", issue); ResourceTests.Equal(r.Data.Data.ExpiresAt, "2026-10-23T16:00:00.123Z"); ResourceTests.Equal(r.Data.Data.TimeoutMs, 120000L); }); + await ResourceTests.Messaging("GET", "/messaging/desk%2F1/passkey/challenge/ticket%2F%3F", null, Reply("ticket"), async c => ResourceTests.Equal((await c.Passkeys.GetChallengeAsync("desk/1", "ticket/?")).Data.Data.Ticket, "ticket/?")); + await ResourceTests.Messaging("POST", "/messaging/desk%2F1/passkey/assertion", f.GetProperty("assertion").GetRawText(), Reply("assertionResult"), async c => ResourceTests.True((await c.Passkeys.SubmitAssertionAsync("desk/1", assertion)).Data.Data.Ok)); + foreach (var status in f.GetProperty("statuses").EnumerateArray()) + await ResourceTests.Messaging("GET", "/messaging/desk/passkey/confirmation", null, "{\"success\":true,\"data\":{\"status\":\"" + status.GetString() + "\",\"skipHandoffUX\":false}}", async c => { var r = await c.Passkeys.GetConfirmationAsync("desk"); ResourceTests.Equal(r.Data.Data.Status, status.GetString()); ResourceTests.Equal(r.Data.Data.SkipHandoffUX, false); ResourceTests.Equal(r.Data.Data.Code, null); }); + await ResourceTests.Messaging("POST", "/messaging/desk%2F1/passkey/confirmation", f.GetProperty("confirm").GetRawText(), "{\"success\":true,\"data\":{\"status\":\"waiting_for_pairing\"}}", async c => ResourceTests.Equal((await c.Passkeys.ConfirmAsync("desk/1", confirm)).Data.Data.Status, "waiting_for_pairing")); + for (var i = 0; i < 3; i++) + { + using var wire = new WireFixture { Authorization = "Bearer pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k" }; + string[] suffix = ["challenge", "assertion", "confirmation"]; + string[] key = ["issue", "assertion", "confirm"]; + var serving = wire.ServeAsync("POST", "/messaging/desk/passkey/" + suffix[i], f.GetProperty(key[i]).GetRawText(), "{\"error\":{\"code\":\"future_passkey\",\"message\":\"refused\"}}", 503); + using var c = new MessagingClient(Credential.OrganizationApiKey("pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"), new() { BaseUrl = wire.Url, MaxNetworkRetries = 3, Timeout = TimeSpan.FromSeconds(3) }); + var o = new RequestOptions { MaxNetworkRetries = 3, IdempotencyKey = "caller-key" }; + try { if (i == 0) await c.Passkeys.IssueChallengeAsync("desk", issue, o); else if (i == 1) await c.Passkeys.SubmitAssertionAsync("desk", assertion, o); else await c.Passkeys.ConfirmAsync("desk", confirm, o); throw new Exception("Expected server refusal."); } + catch (PolymorfaServerException e) { ResourceTests.Equal(e.Code, "future_passkey"); ResourceTests.Equal(e.Metadata?.Attempts, 1); } + await serving; ResourceTests.True(!wire.Pending); + } + foreach (var status in f.GetProperty("errors").EnumerateArray()) + { + using var wire = new WireFixture { Authorization = "Bearer pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k" }; + var serving = wire.ServeAsync("POST", "/messaging/desk/passkey/confirmation", f.GetProperty("confirm").GetRawText(), "{\"error\":{\"code\":\"future_passkey\",\"message\":\"refused\"}}", status.GetInt32()); + using var c = new MessagingClient(Credential.OrganizationApiKey("pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"), new() { BaseUrl = wire.Url, Timeout = TimeSpan.FromSeconds(3) }); + try { await c.Passkeys.ConfirmAsync("desk", confirm); throw new Exception("Expected refusal."); } + catch (PolymorfaException e) { ResourceTests.Equal(e.Code, "future_passkey"); ResourceTests.Equal(e.Status, status.GetInt32()); } + await serving; + } + foreach (var nonJson in new[] { false, true }) + for (var i = 0; i < 3; i++) + { + using var handler = new BrokenHandler(nonJson); + using var c = new MessagingClient(Credential.OrganizationApiKey("pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"), new() { BaseUrl = new("https://api.example"), TransportHandler = handler }); + var o = new RequestOptions { MaxNetworkRetries = 3, IdempotencyKey = "caller" }; + try { if (i == 0) await c.Passkeys.IssueChallengeAsync("desk", issue, o); else if (i == 1) await c.Passkeys.SubmitAssertionAsync("desk", assertion, o); else await c.Passkeys.ConfirmAsync("desk", confirm, o); throw new Exception("Expected unknown outcome."); } + catch (PolymorfaException) { ResourceTests.Equal(handler.Count, 1); } + } + using (var handler = new BrokenHandler(false)) + using (var c = new MessagingClient(Credential.OrganizationApiKey("pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"), new() { BaseUrl = new("https://api.example"), TransportHandler = handler })) + { + foreach (var bad in new[] { assertion with { Assertion = assertion.Assertion with { Id = new string('x', 1365) } }, assertion with { Assertion = assertion.Assertion with { Response = assertion.Assertion.Response with { UserHandleB64 = new string('x', 87) } } }, assertion with { Assertion = assertion.Assertion with { Response = assertion.Assertion.Response with { SignatureB64 = new string('x', 1048576) } } } }) + { + try { await c.Passkeys.SubmitAssertionAsync("s", bad); throw new Exception("Expected local bound refusal."); } + catch (PolymorfaValidationException) { ResourceTests.Equal(handler.Count, 0); } + } + } + await ResourceTests.Messaging("POST", "/messaging/desk/passkey/challenge", "{}", Reply("ticket"), async c => { await c.Passkeys.IssueChallengeAsync("desk"); }); + await ResourceTests.Messaging("POST", "/messaging/desk/passkey/confirmation", "{\"ticketId\":\"ticket\"}", "{\"success\":true,\"data\":{\"status\":\"none\"}}", async c => { await c.Passkeys.ConfirmAsync("desk", new("ticket")); }); + var maximum = assertion with { Assertion = assertion.Assertion with { Id = new string('x', 1364), RawIdB64Url = new string('x', 1364), Response = assertion.Assertion.Response with { UserHandleB64 = new string('x', 86) } } }; + await ResourceTests.Messaging("POST", "/messaging/desk/passkey/assertion", JsonSerializer.Serialize(maximum, options), Reply("assertionResult"), async c => { await c.Passkeys.SubmitAssertionAsync("desk", maximum); }); + Console.WriteLine("PASS passkeys five wire operations, nine outcomes, three single-attempt writes"); + } +} diff --git a/packages/dotnet/tests/Polymorfa.Sdk.Tests/Program.cs b/packages/dotnet/tests/Polymorfa.Sdk.Tests/Program.cs index 90bf3ee3..ec207347 100644 --- a/packages/dotnet/tests/Polymorfa.Sdk.Tests/Program.cs +++ b/packages/dotnet/tests/Polymorfa.Sdk.Tests/Program.cs @@ -238,3 +238,5 @@ await CloudOrderQuantityTests.RunAsync(); CampaignWebhookMetadataTests.Run(); + +await PasskeyTests.RunAsync(); diff --git a/packages/go/README.md b/packages/go/README.md index 919b919d..fd221cbb 100644 --- a/packages/go/README.md +++ b/packages/go/README.md @@ -851,3 +851,47 @@ from the current switch alone. This addition is unreleased; source preparation does not establish API deployment or package publication. Prefer keyed response struct literals when constructing synthetic settings. + +## Direct passkey relay (unreleased) + +Use server credentials with `sessions:manage` for every operation, including reads. +The API also checks live Number ownership, direct-pairing access, relay enrollment +and the current attempt. These methods accept ceremony data supplied by an +external participant. They do not run WebAuthn, store credentials, poll, or confirm +a pairing automatically. Hosted QuickLink and extension relay endpoints are excluded. + +Challenge TTL uses seconds; ceremony `timeoutMs` uses milliseconds. The server +owns ceremony options and ignores the deprecated `userVerification` and +`timeoutMs` request fields. An optional expected `challengeB64Url` must match the +current attempt. Keep `rawIdB64Url` and the named `*B64` assertion strings unchanged; +do not convert between base64 and base64url. Omit optional fields when absent. + +Challenge issuance, assertion submission and explicit confirmation each make one +attempt, even with caller retry options or an idempotency key. If the outcome is +uncertain, read current challenge/confirmation state and reconcile it before a new +write. Assertion acceptance is not pairing completion. Forwarded API failures keep +their status and open error code; the SDK does not invent passkey error codes. + +The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16 +units and optional user handles allow 86. Other opaque assertion fields share the +body limit. The API validates encodings, decoded bounds and challenge binding. + +This is source preparation. Matching API acceptance, package qualification and +publication are still required before using a released package. + +```go +ticket, err := client.Passkeys().IssueChallenge(ctx, session, polymorfa.IssuePasskeyChallengeRequest{}) +if err != nil { return err } +_, err = client.Passkeys().GetChallenge(ctx, session, ticket.Data.Data.Ticket) +if err != nil { return err } +_, err = client.Passkeys().SubmitAssertion(ctx, session, suppliedAssertion) +if err != nil { return err } +_, err = client.Passkeys().GetConfirmation(ctx, session) +if err != nil { return err } +// Call only after an explicit caller decision based on the current state. +confirmExplicitly := func() error { + _, err := client.Passkeys().Confirm(ctx, session, suppliedConfirmation) + return err +} +_ = confirmExplicitly +``` diff --git a/packages/go/passkeys.go b/packages/go/passkeys.go new file mode 100644 index 00000000..699da2a6 --- /dev/null +++ b/packages/go/passkeys.go @@ -0,0 +1,167 @@ +package polymorfa + +import ( + "context" + "encoding/json" + "strings" +) + +// Passkeys relays supplied assertions. It never runs a browser ceremony. +type Passkeys struct{ t *transport } + +func (c *MessagingClient) Passkeys() *Passkeys { return &Passkeys{c.t} } + +type IssuePasskeyChallengeRequest struct { + ChallengeB64URL *string `json:"challengeB64Url,omitempty"` + TTLSeconds *int64 `json:"ttlSeconds,omitempty"` + // Deprecated: ignored by the server; runner authority determines this value. + UserVerification *string `json:"userVerification,omitempty"` + // Deprecated: ignored by the server; runner authority determines this value. + TimeoutMS *int64 `json:"timeoutMs,omitempty"` +} +type PasskeyTicket struct { + Version int `json:"version"` + Ticket string `json:"ticket"` + Session string `json:"session"` + RPID string `json:"rpId"` + ChallengeB64URL string `json:"challengeB64Url"` + UserVerification string `json:"userVerification"` + ExpiresAt string `json:"expiresAt"` + TimeoutMS *int64 `json:"timeoutMs,omitempty"` +} +type PasskeyAssertionResponse struct { + AuthenticatorDataB64 string `json:"authenticatorDataB64"` + ClientDataJSONB64 string `json:"clientDataJSONB64"` + SignatureB64 string `json:"signatureB64"` + UserHandleB64 *string `json:"userHandleB64,omitempty"` +} +type PasskeyAssertion struct { + ID string `json:"id"` + RawIDB64URL string `json:"rawIdB64Url"` + Response PasskeyAssertionResponse `json:"response"` +} +type SubmitPasskeyAssertionRequest struct { + TicketID string `json:"ticketId"` + Assertion PasskeyAssertion `json:"assertion"` +} +type PasskeyAssertionResult struct { + OK bool `json:"ok"` + Ticket string `json:"ticket"` + Session string `json:"session"` +} +type ConfirmPasskeyRequest struct { + TicketID string `json:"ticketId"` + Code *string `json:"code,omitempty"` +} +type PasskeyConfirmation struct { + Status string `json:"status"` + TicketID *string `json:"ticketId,omitempty"` + Code *string `json:"code,omitempty"` + SkipHandoffUX *bool `json:"skipHandoffUX,omitempty"` + Error *string `json:"error,omitempty"` +} + +var PasskeyConfirmationStatuses = []string{"none", "challenge_pending", "assertion_sending", "assertion_submitted", "code_available", "confirmation_sending", "waiting_for_pairing", "completed", "failed"} + +func passkeyPath(s string) (string, error) { + if s == "" { + return "", validation("Provide a session.") + } + return messagingPath(s) + "/passkey", nil +} +func passkeyChars(s string) int { + n := 0 + for _, r := range s { + n++ + if r > 0xffff { + n++ + } + } + return n +} +func passkeyBudget(body any) error { + data, err := json.Marshal(body) + if err != nil || len(data) > 1048576 { + return validation("Passkey request exceeds the 1 MiB JSON body budget.") + } + return nil +} +func (r *Passkeys) IssueChallenge(ctx context.Context, s string, body IssuePasskeyChallengeRequest, o ...RequestOptions) (Response[Envelope[PasskeyTicket]], error) { + if err := serverOnly(r.t); err != nil { + return Response[Envelope[PasskeyTicket]]{}, err + } + p, err := passkeyPath(s) + if err != nil { + return Response[Envelope[PasskeyTicket]]{}, err + } + if body.ChallengeB64URL != nil && *body.ChallengeB64URL == "" || body.TimeoutMS != nil && *body.TimeoutMS <= 0 || body.UserVerification != nil && *body.UserVerification != "required" && *body.UserVerification != "preferred" && *body.UserVerification != "discouraged" { + return Response[Envelope[PasskeyTicket]]{}, validation("Invalid passkey challenge options.") + } + if err = passkeyBudget(body); err != nil { + return Response[Envelope[PasskeyTicket]]{}, err + } + return request[Envelope[PasskeyTicket]](ctx, r.t, "POST", p+"/challenge", nil, body, noRetry(options(o))) +} +func (r *Passkeys) GetChallenge(ctx context.Context, s, ticket string, o ...RequestOptions) (Response[Envelope[PasskeyTicket]], error) { + if err := serverOnly(r.t); err != nil { + return Response[Envelope[PasskeyTicket]]{}, err + } + p, err := passkeyPath(s) + if err != nil { + return Response[Envelope[PasskeyTicket]]{}, err + } + if ticket == "" { + return Response[Envelope[PasskeyTicket]]{}, validation("Provide a ticket ID.") + } + return request[Envelope[PasskeyTicket]](ctx, r.t, "GET", p+"/challenge/"+escaped(ticket), nil, nil, options(o)) +} +func (r *Passkeys) SubmitAssertion(ctx context.Context, s string, body SubmitPasskeyAssertionRequest, o ...RequestOptions) (Response[Envelope[PasskeyAssertionResult]], error) { + if err := serverOnly(r.t); err != nil { + return Response[Envelope[PasskeyAssertionResult]]{}, err + } + p, err := passkeyPath(s) + if err != nil { + return Response[Envelope[PasskeyAssertionResult]]{}, err + } + a := body.Assertion + v := a.Response + if body.TicketID == "" || a.ID == "" || passkeyChars(a.ID) > 1364 || a.RawIDB64URL == "" || passkeyChars(a.RawIDB64URL) > 1364 || v.AuthenticatorDataB64 == "" || v.ClientDataJSONB64 == "" || v.SignatureB64 == "" || v.UserHandleB64 != nil && passkeyChars(*v.UserHandleB64) > 86 { + return Response[Envelope[PasskeyAssertionResult]]{}, validation("Invalid passkey assertion shape.") + } + if err = passkeyBudget(body); err != nil { + return Response[Envelope[PasskeyAssertionResult]]{}, err + } + return request[Envelope[PasskeyAssertionResult]](ctx, r.t, "POST", p+"/assertion", nil, body, noRetry(options(o))) +} +func (r *Passkeys) GetConfirmation(ctx context.Context, s string, o ...RequestOptions) (Response[Envelope[PasskeyConfirmation]], error) { + if err := serverOnly(r.t); err != nil { + return Response[Envelope[PasskeyConfirmation]]{}, err + } + p, err := passkeyPath(s) + if err != nil { + return Response[Envelope[PasskeyConfirmation]]{}, err + } + return request[Envelope[PasskeyConfirmation]](ctx, r.t, "GET", p+"/confirmation", nil, nil, options(o)) +} +func (r *Passkeys) Confirm(ctx context.Context, s string, body ConfirmPasskeyRequest, o ...RequestOptions) (Response[Envelope[PasskeyConfirmation]], error) { + if err := serverOnly(r.t); err != nil { + return Response[Envelope[PasskeyConfirmation]]{}, err + } + p, err := passkeyPath(s) + if err != nil { + return Response[Envelope[PasskeyConfirmation]]{}, err + } + if body.TicketID == "" { + return Response[Envelope[PasskeyConfirmation]]{}, validation("Provide a ticket ID.") + } + if body.Code != nil { + v := *body.Code + if len(v) != 9 || v[4] != '-' || strings.Trim(v[:4]+v[5:], "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789") != "" { + return Response[Envelope[PasskeyConfirmation]]{}, validation("Invalid passkey confirmation code.") + } + } + if err = passkeyBudget(body); err != nil { + return Response[Envelope[PasskeyConfirmation]]{}, err + } + return request[Envelope[PasskeyConfirmation]](ctx, r.t, "POST", p+"/confirmation", nil, body, noRetry(options(o))) +} diff --git a/packages/go/passkeys_test.go b/packages/go/passkeys_test.go new file mode 100644 index 00000000..8fb64ab0 --- /dev/null +++ b/packages/go/passkeys_test.go @@ -0,0 +1,236 @@ +package polymorfa + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync/atomic" + "testing" +) + +func TestPasskeyFiveWire(t *testing.T) { + data, err := os.ReadFile("../../contracts/fixtures/passkeys.candidate.json") + if err != nil { + t.Fatal(err) + } + var f struct { + Issue IssuePasskeyChallengeRequest + Assertion SubmitPasskeyAssertionRequest + Confirm ConfirmPasskeyRequest + Ticket PasskeyTicket + AssertionResult PasskeyAssertionResult + Statuses []string + } + if err := json.Unmarshal(data, &f); err != nil { + t.Fatal(err) + } + for i := 0; i < 5; i++ { + method, path := "GET", "/messaging/desk%2F1/passkey/confirmation" + var body, reply any + switch i { + case 0: + method = "POST" + path = "/messaging/desk%2F1/passkey/challenge" + body = f.Issue + reply = f.Ticket + case 1: + path = "/messaging/desk%2F1/passkey/challenge/ticket%2F%3F" + reply = f.Ticket + case 2: + method = "POST" + path = "/messaging/desk%2F1/passkey/assertion" + body = f.Assertion + reply = f.AssertionResult + case 3: + reply = PasskeyConfirmation{Status: "code_available", TicketID: &f.Confirm.TicketID, Code: f.Confirm.Code} + case 4: + method = "POST" + body = f.Confirm + reply = PasskeyConfirmation{Status: "waiting_for_pairing"} + } + count := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count++ + if r.Method != method || r.URL.EscapedPath() != path { + t.Error(r.Method, r.URL.EscapedPath(), path) + } + if r.Header.Get("Authorization") == "" || r.Header.Get("Polymorfa-Version") != "2026-09-22" { + t.Error(r.Header) + } + if body != nil { + var actual any + _ = json.NewDecoder(r.Body).Decode(&actual) + expected, _ := json.Marshal(body) + got, _ := json.Marshal(actual) + if string(got) != string(expected) { + var want any + _ = json.Unmarshal(expected, &want) + b, _ := json.Marshal(want) + if string(got) != string(b) { + t.Error(string(got), string(b)) + } + } + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": reply}) + })) + c := mustMessaging(t, server.URL).Passkeys() + switch i { + case 0: + v, e := c.IssueChallenge(context.Background(), "desk/1", f.Issue) + if e != nil || v.Data.Data.TimeoutMS == nil || *v.Data.Data.TimeoutMS != 120000 { + t.Fatal(v, e) + } + case 1: + v, e := c.GetChallenge(context.Background(), "desk/1", "ticket/?") + if e != nil || v.Data.Data.ExpiresAt != f.Ticket.ExpiresAt { + t.Fatal(v, e) + } + case 2: + v, e := c.SubmitAssertion(context.Background(), "desk/1", f.Assertion) + if e != nil || !v.Data.Data.OK { + t.Fatal(v, e) + } + case 3: + v, e := c.GetConfirmation(context.Background(), "desk/1") + if e != nil || v.Data.Data.Status != "code_available" { + t.Fatal(v, e) + } + case 4: + v, e := c.Confirm(context.Background(), "desk/1", f.Confirm) + if e != nil || v.Data.Data.Status != "waiting_for_pairing" { + t.Fatal(v, e) + } + } + server.Close() + if count != 1 { + t.Fatal(count) + } + } +} + +func TestPasskeyBoundaries(t *testing.T) { + data, _ := os.ReadFile("../../contracts/fixtures/passkeys.candidate.json") + var f struct { + Issue IssuePasskeyChallengeRequest + Assertion SubmitPasskeyAssertionRequest + Confirm ConfirmPasskeyRequest + Statuses []string + Errors []int + } + if e := json.Unmarshal(data, &f); e != nil { + t.Fatal(e) + } + ctx := context.Background() + retries := 3 + o := RequestOptions{MaxNetworkRetries: &retries, IdempotencyKey: "caller"} + for _, status := range f.Statuses { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "data": map[string]any{"status": status, "skipHandoffUX": false}}) + })) + v, e := mustMessaging(t, server.URL).Passkeys().GetConfirmation(ctx, "s") + server.Close() + if e != nil || v.Data.Data.Status != status || v.Data.Data.SkipHandoffUX == nil || *v.Data.Data.SkipHandoffUX || v.Data.Data.Code != nil { + t.Fatal(v, e) + } + } + for _, status := range f.Errors { + var count atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(status) + _, _ = w.Write([]byte(`{"error":{"code":"future_passkey","message":"refused"}}`)) + })) + _, e := mustMessaging(t, server.URL).Passkeys().Confirm(ctx, "s", f.Confirm, o) + server.Close() + var api *Error + if !errors.As(e, &api) || api.Status != status || api.Code != "future_passkey" || count.Load() != 1 { + t.Fatal(e, count.Load()) + } + } + for _, mode := range []string{"503", "non-json", "network"} { + for i := 0; i < 3; i++ { + var count atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + count.Add(1) + if mode == "network" { + conn, _, e := w.(http.Hijacker).Hijack() + if e == nil { + _ = conn.Close() + } + return + } + if mode == "503" { + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(503) + _, _ = w.Write([]byte(`{"error":{"code":"future_passkey","message":"refused"}}`)) + } else { + w.Header().Set("Content-Type", "text/html") + w.WriteHeader(502) + _, _ = w.Write([]byte("unavailable")) + } + })) + r := mustMessaging(t, server.URL).Passkeys() + var e error + switch i { + case 0: + _, e = r.IssueChallenge(ctx, "s", f.Issue, o) + case 1: + _, e = r.SubmitAssertion(ctx, "s", f.Assertion, o) + case 2: + _, e = r.Confirm(ctx, "s", f.Confirm, o) + } + server.Close() + if e == nil || count.Load() != 1 { + t.Fatal(mode, i, e, count.Load()) + } + } + } + for _, body := range []any{IssuePasskeyChallengeRequest{}, ConfirmPasskeyRequest{TicketID: "t"}} { + bytes, _ := json.Marshal(body) + var fields map[string]any + _ = json.Unmarshal(bytes, &fields) + if _, ok := fields["code"]; ok { + t.Fatal(fields) + } + if _, ok := fields["challengeB64Url"]; ok { + t.Fatal(fields) + } + } + bad := f.Assertion + bad.Assertion.ID = strings.Repeat("x", 1365) + if _, e := mustMessaging(t, "http://127.0.0.1:1").Passkeys().SubmitAssertion(ctx, "s", bad); e == nil { + t.Fatal("oversized credential accepted") + } + bad = f.Assertion + bad.Assertion.Response.SignatureB64 = strings.Repeat("x", 1048576) + if e := passkeyBudget(bad); e == nil { + t.Fatal("body budget accepted") + } +} + +func TestPasskeyMaximumWire(t *testing.T) { + handle := strings.Repeat("x", 86) + body := SubmitPasskeyAssertionRequest{TicketID: "t", Assertion: PasskeyAssertion{ID: strings.Repeat("x", 1364), RawIDB64URL: strings.Repeat("x", 1364), Response: PasskeyAssertionResponse{AuthenticatorDataB64: "AQID", ClientDataJSONB64: "e30", SignatureB64: "AQID", UserHandleB64: &handle}}} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var got SubmitPasskeyAssertionRequest + if e := json.NewDecoder(r.Body).Decode(&got); e != nil { + t.Error(e) + } + if got.Assertion.ID != body.Assertion.ID || got.Assertion.RawIDB64URL != body.Assertion.RawIDB64URL || got.Assertion.Response.UserHandleB64 == nil || *got.Assertion.Response.UserHandleB64 != handle { + t.Error("maximum wire fields changed") + } + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"success":true,"data":{"ok":true,"ticket":"t","session":"s"}}`)) + })) + defer server.Close() + if _, e := mustMessaging(t, server.URL).Passkeys().SubmitAssertion(context.Background(), "s", body); e != nil { + t.Fatal(e) + } +} diff --git a/packages/php/README.md b/packages/php/README.md index c43a6437..ca38b90e 100644 --- a/packages/php/README.md +++ b/packages/php/README.md @@ -1002,3 +1002,39 @@ guarantees coverage; all other Ban Insurance conditions still apply. The server calculates this value. Do not send it in an update or calculate it from the current switch alone. This addition is unreleased; source preparation does not establish API deployment or package publication. + +## Direct passkey relay (unreleased) + +Use server credentials with `sessions:manage` for every operation, including reads. +The API also checks live Number ownership, direct-pairing access, relay enrollment +and the current attempt. These methods accept ceremony data supplied by an +external participant. They do not run WebAuthn, store credentials, poll, or confirm +a pairing automatically. Hosted QuickLink and extension relay endpoints are excluded. + +Challenge TTL uses seconds; ceremony `timeoutMs` uses milliseconds. The server +owns ceremony options and ignores the deprecated `userVerification` and +`timeoutMs` request fields. An optional expected `challengeB64Url` must match the +current attempt. Keep `rawIdB64Url` and the named `*B64` assertion strings unchanged; +do not convert between base64 and base64url. Omit optional fields when absent. + +Challenge issuance, assertion submission and explicit confirmation each make one +attempt, even with caller retry options or an idempotency key. If the outcome is +uncertain, read current challenge/confirmation state and reconcile it before a new +write. Assertion acceptance is not pairing completion. Forwarded API failures keep +their status and open error code; the SDK does not invent passkey error codes. + +The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16 +units and optional user handles allow 86. Other opaque assertion fields share the +body limit. The API validates encodings, decoded bounds and challenge binding. + +This is source preparation. Matching API acceptance, package qualification and +publication are still required before using a released package. + +```php +$ticket = $client->passkeys->issueChallenge($session); +$client->passkeys->getChallenge($session, $ticket->data['data']['ticket']); +$client->passkeys->submitAssertion($session, $suppliedAssertion); +$state = $client->passkeys->getConfirmation($session); +// Invoke only after an explicit caller decision based on the current state. +$confirmExplicitly = fn () => $client->passkeys->confirm($session, $suppliedConfirmation); +``` diff --git a/packages/php/src/MessagingClient.php b/packages/php/src/MessagingClient.php index abd0791c..acbfd74b 100644 --- a/packages/php/src/MessagingClient.php +++ b/packages/php/src/MessagingClient.php @@ -9,6 +9,7 @@ final readonly class MessagingClient { + public Resources\Passkeys $passkeys; public BanSafe $banSafe; public Resources\Calls $calls; public Resources\MessagingCampaigns $campaigns; @@ -56,6 +57,7 @@ public function __construct( ?ClientInterface $http = null ) { $transport = new HttpTransport($credential, $baseUrl, $apiVersion, $timeout, $maxNetworkRetries, $proxy, $http); + $this->passkeys = new Resources\Passkeys($transport); $this->banSafe = new BanSafe($transport); $this->calls = new Resources\Calls($transport); $this->campaigns = new Resources\MessagingCampaigns($transport); diff --git a/packages/php/src/PasskeyModels.php b/packages/php/src/PasskeyModels.php new file mode 100644 index 00000000..0702357c --- /dev/null +++ b/packages/php/src/PasskeyModels.php @@ -0,0 +1,19 @@ +server(); + return '/messaging/'.$this->segment($session).'/passkey'; + } + private static function text(mixed $value, ?int $limit = null): bool + { + if (!is_string($value) || $value === '') { return false; } + if ($limit === null) { return true; } + // JSON escaped UTF-16 units, without requiring an optional mbstring extension. + $encoded = json_encode($value, JSON_THROW_ON_ERROR); + preg_match_all('/\\\\u[0-9a-fA-F]{4}|\\\\.|[^"\\\\]/', substr($encoded, 1, -1), $units); + return count($units[0]) <= $limit; + } + /** @param array $body */ + private static function budget(array $body): void + { + if (strlen(json_encode((object)$body, JSON_THROW_ON_ERROR)) > 1048576) { throw new ValidationException('Passkey request exceeds 1 MiB.'); } + } + /** + * userVerification and timeoutMs are deprecated ignored inputs; the runner owns them. + * @param Issue $body + * @return ApiResponse + */ + public function issueChallenge(string $session, array $body = [], ?RequestOptions $options = null): ApiResponse + { + $path = $this->path($session); + if ((array_key_exists('challengeB64Url', $body) && !self::text($body['challengeB64Url'])) || (array_key_exists('ttlSeconds', $body) && !is_int($body['ttlSeconds'])) || (array_key_exists('timeoutMs', $body) && (!is_int($body['timeoutMs']) || $body['timeoutMs'] <= 0)) || (array_key_exists('userVerification', $body) && !in_array($body['userVerification'], ['required','preferred','discouraged'], true))) { throw new ValidationException('Invalid challenge options.'); } + self::budget($body); + /** @var ApiResponse */ + return $this->request('POST', $path.'/challenge', (object)$body, options:($options ?? new RequestOptions())->withoutRetries()); + } + /** @return ApiResponse */ + public function getChallenge(string $session, string $ticketId, ?RequestOptions $options = null): ApiResponse + { + /** @var ApiResponse */ + return $this->request('GET', $this->path($session).'/challenge/'.$this->segment($ticketId), options:$options); + } + /** + * @param Submit $body + * @return ApiResponse + */ + public function submitAssertion(string $session, array $body, ?RequestOptions $options = null): ApiResponse + { + $path = $this->path($session); $a = $body['assertion'] ?? null; $r = is_array($a) ? ($a['response'] ?? null) : null; + if (!self::text($body['ticketId'] ?? null) || !is_array($a) || !self::text($a['id'] ?? null,1364) || !self::text($a['rawIdB64Url'] ?? null,1364) || !is_array($r) || !self::text($r['authenticatorDataB64'] ?? null) || !self::text($r['clientDataJSONB64'] ?? null) || !self::text($r['signatureB64'] ?? null)) { throw new ValidationException('Provide the ticket and bounded assertion.'); } + if (array_key_exists('userHandleB64',$r) && (!is_string($r['userHandleB64']) || ($r['userHandleB64'] !== '' && !self::text($r['userHandleB64'],86)))) { throw new ValidationException('userHandleB64 exceeds 86 characters.'); } + self::budget($body); + /** @var ApiResponse */ + return $this->request('POST', $path.'/assertion', $body, options:($options ?? new RequestOptions())->withoutRetries()); + } + /** @return ApiResponse */ + public function getConfirmation(string $session, ?RequestOptions $options = null): ApiResponse + { + /** @var ApiResponse */ + return $this->request('GET', $this->path($session).'/confirmation', options:$options); + } + /** + * @param Confirm $body + * @return ApiResponse + */ + public function confirm(string $session, array $body, ?RequestOptions $options = null): ApiResponse + { + $path = $this->path($session); + if (!self::text($body['ticketId'] ?? null) || (array_key_exists('code',$body) && (!is_string($body['code']) || !preg_match('/^[A-Z0-9]{4}-[A-Z0-9]{4}$/D',$body['code'])))) { throw new ValidationException('Provide the ticket and optional confirmation code.'); } + self::budget($body); + /** @var ApiResponse */ + return $this->request('POST', $path.'/confirmation', $body, options:($options ?? new RequestOptions())->withoutRetries()); + } +} diff --git a/packages/php/tests/passkeys.php b/packages/php/tests/passkeys.php new file mode 100644 index 00000000..9bce0632 --- /dev/null +++ b/packages/php/tests/passkeys.php @@ -0,0 +1,41 @@ + $c->passkeys->issueChallenge($f['session'],$f['issue'])], + ['GET','/challenge/'.rawurlencode($f['ticketId']),null,'ticket',fn($c) => $c->passkeys->getChallenge($f['session'],$f['ticketId'])], + ['POST','/assertion',$f['assertion'],'assertionResult',fn($c) => $c->passkeys->submitAssertion($f['session'],$f['assertion'])], + ['GET','/confirmation',null,null,fn($c) => $c->passkeys->getConfirmation($f['session'])], + ['POST','/confirmation',$f['confirm'],null,fn($c) => $c->passkeys->confirm($f['session'],$f['confirm'])], +]; +foreach ($cases as [$method,$suffix,$body,$key,$invoke]) { + $reply = ['success'=>true,'data'=>$key === null ? ['status'=>'waiting_for_pairing','skipHandoffUX'=>false] : $f[$key]]; + $history=[]; $client=new \Polymorfa\MessagingClient($passkeyCredential,http:mocked([jsonResponse($reply)],$history)); + check($invoke($client)->data === $reply,'passkey response'); + $r=$history[0]['request']; check($r->getMethod()===$method && $r->getUri()->getPath()==='/messaging/desk%2F1/passkey'.$suffix,'passkey path'); + check($r->getHeaderLine('Authorization')==='Bearer pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k' && $r->hasHeader('Polymorfa-Version'),'passkey headers'); + check($body === null || json_decode((string)$r->getBody(),true)===$body,'passkey body'); +} +foreach ($f['statuses'] as $status) { + $h=[]; $c=new \Polymorfa\MessagingClient($passkeyCredential,http:mocked([jsonResponse(['success'=>true,'data'=>['status'=>$status]])],$h)); + check($c->passkeys->getConfirmation('desk')->data['data']['status']===$status,'passkey outcome'); +} +foreach (['issueChallenge'=>'issue','submitAssertion'=>'assertion','confirm'=>'confirm'] as $method=>$key) { + $h=[]; $c=new \Polymorfa\MessagingClient($passkeyCredential,http:mocked([jsonResponse(['error'=>['code'=>'future_passkey','message'=>'refused']],503)],$h)); + raises(fn()=>$c->passkeys->$method('desk',$f[$key],new \Polymorfa\RequestOptions(maxNetworkRetries:3,idempotencyKey:'caller-key')),\Polymorfa\ServerException::class); + check(count($h)===1,'passkey write must not repeat'); +} + +foreach ($f['errors'] as $status) { + $h=[]; $c=new \Polymorfa\MessagingClient($passkeyCredential,http:mocked([jsonResponse(['error'=>['code'=>'future_passkey','message'=>'refused']],$status)],$h)); + $e=raises(fn()=>$c->passkeys->confirm('desk',$f['confirm'],new \Polymorfa\RequestOptions(maxNetworkRetries:3,idempotencyKey:'caller')),\Polymorfa\PolymorfaException::class); + check($e->status===$status && $e->errorCode==='future_passkey' && count($h)===1,'passkey refusal forwarding'); +} +$h=[]; $c=new \Polymorfa\MessagingClient($passkeyCredential,http:mocked([],$h)); +$bad=$f['assertion']; $bad['assertion']['id']=str_repeat('x',1365); +raises(fn()=>$c->passkeys->submitAssertion('desk',$bad),\Polymorfa\ValidationException::class); +$bad=$f['assertion']; $bad['assertion']['response']['signatureB64']=str_repeat('x',1048576); +raises(fn()=>$c->passkeys->submitAssertion('desk',$bad),\Polymorfa\ValidationException::class); +check(count($h)===0,'passkey bounds refuse before transport'); diff --git a/packages/php/tests/run.php b/packages/php/tests/run.php index b428227a..2fa56fe9 100644 --- a/packages/php/tests/run.php +++ b/packages/php/tests/run.php @@ -270,3 +270,5 @@ function mocked(array $responses, array &$history): HttpClient require __DIR__.'/official_round2.php'; require __DIR__.'/campaign_webhook_metadata.php'; + +require __DIR__.'/passkeys.php'; diff --git a/packages/python/README.md b/packages/python/README.md index b094523f..efc77e4c 100644 --- a/packages/python/README.md +++ b/packages/python/README.md @@ -869,3 +869,42 @@ guarantees coverage; all other Ban Insurance conditions still apply. The server calculates this value. Do not send it in an update or calculate it from the current switch alone. This addition is unreleased; source preparation does not establish API deployment or package publication. + +## Direct passkey relay (unreleased) + +Use server credentials with `sessions:manage` for every operation, including reads. +The API also checks live Number ownership, direct-pairing access, relay enrollment +and the current attempt. These methods accept ceremony data supplied by an +external participant. They do not run WebAuthn, store credentials, poll, or confirm +a pairing automatically. Hosted QuickLink and extension relay endpoints are excluded. + +Challenge TTL uses seconds; ceremony `timeoutMs` uses milliseconds. The server +owns ceremony options and ignores the deprecated `userVerification` and +`timeoutMs` request fields. An optional expected `challengeB64Url` must match the +current attempt. Keep `rawIdB64Url` and the named `*B64` assertion strings unchanged; +do not convert between base64 and base64url. Omit optional fields when absent. + +Challenge issuance, assertion submission and explicit confirmation each make one +attempt, even with caller retry options or an idempotency key. If the outcome is +uncertain, read current challenge/confirmation state and reconcile it before a new +write. Assertion acceptance is not pairing completion. Forwarded API failures keep +their status and open error code; the SDK does not invent passkey error codes. + +The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16 +units and optional user handles allow 86. Other opaque assertion fields share the +body limit. The API validates encodings, decoded bounds and challenge binding. + +This is source preparation. Matching API acceptance, package qualification and +publication are still required before using a released package. + +```python +ticket = await client.passkeys.issue_challenge(session) +await client.passkeys.get_challenge(session, ticket.data["data"]["ticket"]) +await client.passkeys.submit_assertion(session, supplied_assertion) +state = await client.passkeys.get_confirmation(session) + + +async def confirm_explicitly(): + # Call only after an explicit decision based on the current state. + return await client.passkeys.confirm(session, supplied_confirmation) +``` diff --git a/packages/python/src/polymorfa/__init__.py b/packages/python/src/polymorfa/__init__.py index d5804fe0..906f15ff 100644 --- a/packages/python/src/polymorfa/__init__.py +++ b/packages/python/src/polymorfa/__init__.py @@ -192,6 +192,19 @@ descriptor_from_media, download_whatsapp_media_stream, ) +from .passkeys import ( + ConfirmPasskey, + IssuePasskeyChallenge, + PasskeyAssertion, + PasskeyAssertionResponse, + PasskeyAssertionResult, + PasskeyConfirmation, + Passkeys, + PasskeyStatus, + PasskeyTicket, + SubmitPasskeyAssertion, + UserVerification, +) from .policies import graph_transport_headers from .raw_pagination import PageResult, RawCursorPage from .request_logs import ( @@ -241,6 +254,17 @@ ) __all__ = [ + "ConfirmPasskey", + "IssuePasskeyChallenge", + "PasskeyAssertion", + "PasskeyAssertionResponse", + "PasskeyAssertionResult", + "PasskeyConfirmation", + "Passkeys", + "PasskeyStatus", + "PasskeyTicket", + "SubmitPasskeyAssertion", + "UserVerification", "API_VERSION", "CALL_REACTION_EMOJI", "CUSTOMER_OWNED_API_VERSION", diff --git a/packages/python/src/polymorfa/client.py b/packages/python/src/polymorfa/client.py index 05d3885b..c816b39d 100644 --- a/packages/python/src/polymorfa/client.py +++ b/packages/python/src/polymorfa/client.py @@ -40,6 +40,7 @@ SecurityIncidents, SessionBans, ) +from .passkeys import Passkeys from .platform_media import Media as PlatformMedia from .policies import HybridLink, ObservationPolicies, SessionConfigurations from .quick_replies import QuickReplies, Users @@ -59,6 +60,7 @@ class AsyncMessagingClient: history_sync: HistorySync campaigns: MessagingCampaigns official_groups: OfficialGroups + passkeys: Passkeys catalogs: Catalogs cloud_onboarding: CloudOnboarding testing: Testing @@ -100,6 +102,7 @@ def __init__(self, credential: Credential, **options: Unpack[ClientOptions]) -> for name, resource in ( ("campaigns", MessagingCampaigns), ("official_groups", OfficialGroups), + ("passkeys", Passkeys), ("catalogs", Catalogs), ("cloud_onboarding", CloudOnboarding), ("testing", Testing), diff --git a/packages/python/src/polymorfa/passkeys.py b/packages/python/src/polymorfa/passkeys.py new file mode 100644 index 00000000..5c508c57 --- /dev/null +++ b/packages/python/src/polymorfa/passkeys.py @@ -0,0 +1,193 @@ +"""Direct server passkey operations. Ceremony and ownership remain API-owned.""" + +from __future__ import annotations + +import json +import re +from dataclasses import replace +from typing import Literal + +from typing_extensions import NotRequired, TypedDict + +from .errors import ValidationError +from .messaging import O, Resource, segment +from .models import Envelope +from .transport import ApiResponse, RequestOptions + +UserVerification = Literal["required", "preferred", "discouraged"] +PasskeyStatus = Literal[ + "none", + "challenge_pending", + "assertion_sending", + "assertion_submitted", + "code_available", + "confirmation_sending", + "waiting_for_pairing", + "completed", + "failed", +] + + +class IssuePasskeyChallenge(TypedDict): + challengeB64Url: NotRequired[str] + ttlSeconds: NotRequired[int] + userVerification: NotRequired[UserVerification] # Deprecated, ignored by server. + timeoutMs: NotRequired[int] # Deprecated, ignored by server. + + +class PasskeyTicket(TypedDict): + version: Literal[2] + ticket: str + session: str + rpId: str + challengeB64Url: str + userVerification: UserVerification + expiresAt: str + timeoutMs: NotRequired[int] + + +class PasskeyAssertionResponse(TypedDict): + authenticatorDataB64: str + clientDataJSONB64: str + signatureB64: str + userHandleB64: NotRequired[str] + + +class PasskeyAssertion(TypedDict): + id: str + rawIdB64Url: str + response: PasskeyAssertionResponse + + +class SubmitPasskeyAssertion(TypedDict): + ticketId: str + assertion: PasskeyAssertion + + +class PasskeyAssertionResult(TypedDict): + ok: Literal[True] + ticket: str + session: str + + +class ConfirmPasskey(TypedDict): + ticketId: str + code: NotRequired[str] + + +class PasskeyConfirmation(TypedDict): + status: PasskeyStatus + ticketId: NotRequired[str] + code: NotRequired[str] + skipHandoffUX: NotRequired[bool] + error: NotRequired[str] + + +def _text(value: object, limit: int | None = None) -> bool: + return ( + isinstance(value, str) + and bool(value) + and (limit is None or len(value.encode("utf-16-le")) // 2 <= limit) + ) + + +def _capture(body: object) -> object: + encoded = json.dumps(body, allow_nan=False) + if len(encoded.encode()) > 1048576: + raise ValidationError("Passkey request exceeds 1 MiB.") + return json.loads(encoded) + + +class Passkeys(Resource): + def _path(self, session: str) -> str: + self._server() + return f"/messaging/{segment(session)}/passkey" + + async def issue_challenge( + self, + session: str, + body: IssuePasskeyChallenge | None = None, + *, + options: RequestOptions = O, + ) -> ApiResponse[Envelope[PasskeyTicket]]: + path = self._path(session) + body = {} if body is None else body + if "challengeB64Url" in body and not _text(body["challengeB64Url"]): + raise ValidationError("Provide the expected challenge.") + if "ttlSeconds" in body and type(body["ttlSeconds"]) is not int: + raise ValidationError("ttlSeconds must be an integer.") + if "timeoutMs" in body and (type(body["timeoutMs"]) is not int or body["timeoutMs"] <= 0): + raise ValidationError("timeoutMs must be a positive integer.") + if "userVerification" in body and body["userVerification"] not in ( + "required", + "preferred", + "discouraged", + ): + raise ValidationError("Invalid userVerification.") + return await self._request( + "POST", + path + "/challenge", + body=_capture(body), + options=replace(options, max_network_retries=0), + ) + + async def get_challenge( + self, session: str, ticket_id: str, *, options: RequestOptions = O + ) -> ApiResponse[Envelope[PasskeyTicket]]: + return await self._request( + "GET", self._path(session) + "/challenge/" + segment(ticket_id), options=options + ) + + async def submit_assertion( + self, session: str, body: SubmitPasskeyAssertion, *, options: RequestOptions = O + ) -> ApiResponse[Envelope[PasskeyAssertionResult]]: + path = self._path(session) + a = body.get("assertion") + if ( + not _text(body.get("ticketId")) + or not isinstance(a, dict) + or not _text(a.get("id"), 1364) + or not _text(a.get("rawIdB64Url"), 1364) + ): + raise ValidationError("Provide the ticket and bounded credential.") + r = a.get("response") + if not isinstance(r, dict) or any( + not _text(r.get(k)) + for k in ("authenticatorDataB64", "clientDataJSONB64", "signatureB64") + ): + raise ValidationError("Provide the assertion response.") + if "userHandleB64" in r and ( + not isinstance(r["userHandleB64"], str) + or len(r["userHandleB64"].encode("utf-16-le")) // 2 > 86 + ): + raise ValidationError("userHandleB64 exceeds 86 characters.") + return await self._request( + "POST", + path + "/assertion", + body=_capture(body), + options=replace(options, max_network_retries=0), + ) + + async def get_confirmation( + self, session: str, *, options: RequestOptions = O + ) -> ApiResponse[Envelope[PasskeyConfirmation]]: + return await self._request("GET", self._path(session) + "/confirmation", options=options) + + async def confirm( + self, session: str, body: ConfirmPasskey, *, options: RequestOptions = O + ) -> ApiResponse[Envelope[PasskeyConfirmation]]: + path = self._path(session) + if not _text(body.get("ticketId")) or ( + "code" in body + and ( + not isinstance(body["code"], str) + or re.fullmatch(r"[A-Z0-9]{4}-[A-Z0-9]{4}", body["code"]) is None + ) + ): + raise ValidationError("Provide the ticket and optional confirmation code.") + return await self._request( + "POST", + path + "/confirmation", + body=_capture(body), + options=replace(options, max_network_retries=0), + ) diff --git a/packages/python/tests/test_passkeys.py b/packages/python/tests/test_passkeys.py new file mode 100644 index 00000000..3583287b --- /dev/null +++ b/packages/python/tests/test_passkeys.py @@ -0,0 +1,180 @@ +import asyncio +import json +from pathlib import Path + +import httpx +import pytest + +from polymorfa import AsyncMessagingClient, Credential, RequestOptions + +F = json.loads( + (Path(__file__).parents[3] / "contracts/fixtures/passkeys.candidate.json").read_text() +) + + +def test_passkey_five_wire(): + asyncio.run(_wire()) + + +async def _wire(): + expected = [ + ("POST", "/challenge", F["issue"], F["ticket"]), + ("GET", "/challenge/ticket%2F%3F", None, F["ticket"]), + ("POST", "/assertion", F["assertion"], F["assertionResult"]), + ("GET", "/confirmation", None, {"status": "code_available", "skipHandoffUX": False}), + ("POST", "/confirmation", F["confirm"], {"status": "waiting_for_pairing"}), + ] + for i, (method, path, body, reply) in enumerate(expected): + + def handler(request): + assert request.method == method + assert request.url.raw_path.decode() == "/messaging/desk%2F1/passkey" + path + assert request.headers["authorization"].startswith("Bearer pmfa_") + assert request.headers["polymorfa-version"] == "2026-09-22" + assert (json.loads(request.content) if request.content else None) == body + return httpx.Response(200, json={"success": True, "data": reply}) + + async with AsyncMessagingClient( + credential=Credential("organization_api_key", "pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"), + http_transport=httpx.MockTransport(handler), + ) as c: + p = c.passkeys + if i == 0: + r = await p.issue_challenge(F["session"], F["issue"]) + elif i == 1: + r = await p.get_challenge(F["session"], F["ticketId"]) + elif i == 2: + r = await p.submit_assertion(F["session"], F["assertion"]) + elif i == 3: + r = await p.get_confirmation(F["session"]) + else: + r = await p.confirm(F["session"], F["confirm"]) + assert r.data == {"success": True, "data": reply} + + +def test_passkey_writes_are_not_retried(): + asyncio.run(_once()) + + +async def _once(): + for kind in ("issue", "assertion", "confirm"): + count = 0 + + def handler(request): + nonlocal count + count += 1 + return httpx.Response( + 503, + json={"error": {"code": "future_passkey_failure", "message": "Unknown outcome"}}, + ) + + async with AsyncMessagingClient( + credential=Credential("organization_api_key", "pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"), + max_network_retries=3, + http_transport=httpx.MockTransport(handler), + ) as c: + opts = RequestOptions(max_network_retries=3, idempotency_key="caller-key") + with pytest.raises(Exception) as error: + if kind == "issue": + await c.passkeys.issue_challenge("s", F["issue"], options=opts) + elif kind == "assertion": + await c.passkeys.submit_assertion("s", F["assertion"], options=opts) + else: + await c.passkeys.confirm("s", F["confirm"], options=opts) + assert error.value.code == "future_passkey_failure" + assert count == 1 + + +def test_passkey_outcomes_refusals_omission_and_bounds(): + asyncio.run(_boundaries()) + + +async def _boundaries(): + key = Credential("organization_api_key", "pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k") + for status in F["statuses"]: + async with AsyncMessagingClient( + key, + http_transport=httpx.MockTransport( + lambda r: httpx.Response( + 200, json={"success": True, "data": {"status": status, "skipHandoffUX": False}} + ) + ), + ) as c: + assert (await c.passkeys.get_confirmation("s")).data["data"] == { + "status": status, + "skipHandoffUX": False, + } + for status in F["errors"]: + count = 0 + + def refuse(request): + nonlocal count + count += 1 + return httpx.Response( + status, json={"error": {"code": "future_passkey", "message": "refused"}} + ) + + async with AsyncMessagingClient(key, http_transport=httpx.MockTransport(refuse)) as c: + with pytest.raises(Exception) as e: + await c.passkeys.confirm( + "s", + F["confirm"], + options=RequestOptions(max_network_retries=3, idempotency_key="caller"), + ) + assert e.value.status == status and e.value.code == "future_passkey" and count == 1 + for mode in ("non-json", "network"): + for kind in ("issue", "assertion", "confirm"): + count = 0 + + def broken(request): + nonlocal count + count += 1 + if mode == "network": + raise httpx.ReadError("connection lost after upload", request=request) + return httpx.Response( + 502, text="unavailable", headers={"content-type": "text/html"} + ) + + async with AsyncMessagingClient(key, http_transport=httpx.MockTransport(broken)) as c: + with pytest.raises(Exception): + o = RequestOptions(max_network_retries=3, idempotency_key="caller") + if kind == "issue": + await c.passkeys.issue_challenge("s", F["issue"], options=o) + elif kind == "assertion": + await c.passkeys.submit_assertion("s", F["assertion"], options=o) + else: + await c.passkeys.confirm("s", F["confirm"], options=o) + assert count == 1 + bodies = [] + + def capture(request): + bodies.append(json.loads(request.content)) + return httpx.Response(200, json={"success": True, "data": {"status": "none"}}) + + async with AsyncMessagingClient(key, http_transport=httpx.MockTransport(capture)) as c: + await c.passkeys.issue_challenge("s") + await c.passkeys.confirm("s", {"ticketId": "ticket"}) + assert bodies == [{}, {"ticketId": "ticket"}] + bad = json.loads(json.dumps(F["assertion"])) + bad["assertion"]["id"] = "x" * 1365 + with pytest.raises(Exception): + await c.passkeys.submit_assertion("s", bad) + bad["assertion"]["id"] = "x" * 1364 + bad["assertion"]["rawIdB64Url"] = "x" * 1364 + bad["assertion"]["response"]["userHandleB64"] = "x" * 86 + await c.passkeys.submit_assertion("s", bad) + assert bodies[-1] == bad + bad["assertion"]["response"]["signatureB64"] = "x" * 1048576 + with pytest.raises(Exception): + await c.passkeys.submit_assertion("s", bad) + assert len(bodies) == 3 + assert not hasattr(c.passkeys, "verify") and not hasattr( + c.passkeys, "submit_quicklink_passkey" + ) + async with AsyncMessagingClient( + Credential("client_token", "pmfa_ct_Zml4dHVyZQ"), + http_transport=httpx.MockTransport(capture), + ) as c: + with pytest.raises(Exception): + await c.passkeys.get_confirmation("s") + assert len(bodies) == 3 diff --git a/packages/rust/README.md b/packages/rust/README.md index aaaf9f08..58c5421b 100644 --- a/packages/rust/README.md +++ b/packages/rust/README.md @@ -946,3 +946,47 @@ does not establish API deployment or package publication. Rust response struct literals must include `simultaneous_calls_coverage_excluded_until: None` or a timestamp. + +## Direct passkey relay (unreleased) + +Use server credentials with `sessions:manage` for every operation, including reads. +The API also checks live Number ownership, direct-pairing access, relay enrollment +and the current attempt. These methods accept ceremony data supplied by an +external participant. They do not run WebAuthn, store credentials, poll, or confirm +a pairing automatically. Hosted QuickLink and extension relay endpoints are excluded. + +Challenge TTL uses seconds; ceremony `timeoutMs` uses milliseconds. The server +owns ceremony options and ignores the deprecated `userVerification` and +`timeoutMs` request fields. An optional expected `challengeB64Url` must match the +current attempt. Keep `rawIdB64Url` and the named `*B64` assertion strings unchanged; +do not convert between base64 and base64url. Omit optional fields when absent. + +Challenge issuance, assertion submission and explicit confirmation each make one +attempt, even with caller retry options or an idempotency key. If the outcome is +uncertain, read current challenge/confirmation state and reconcile it before a new +write. Assertion acceptance is not pairing completion. Forwarded API failures keep +their status and open error code; the SDK does not invent passkey error codes. + +The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16 +units and optional user handles allow 86. Other opaque assertion fields share the +body limit. The API validates encodings, decoded bounds and challenge binding. + +This is source preparation. Matching API acceptance, package qualification and +publication are still required before using a released package. + +```rust,no_run +# async fn supplied_steps(client: &polymorfa_sdk::MessagingClient, session: &str, supplied_assertion: &polymorfa_sdk::passkeys::SubmitPasskeyAssertion, supplied_confirmation: &polymorfa_sdk::passkeys::ConfirmPasskey) -> polymorfa_sdk::Result<()> { +use polymorfa_sdk::{passkeys::IssuePasskeyChallenge, RequestOptions}; +let ticket = client.passkeys().issue_challenge(session, &IssuePasskeyChallenge::default(), RequestOptions::default()).await?; +client.passkeys().get_challenge(session, &ticket.data.data.ticket, RequestOptions::default()).await?; +client.passkeys().submit_assertion(session, supplied_assertion, RequestOptions::default()).await?; +let state = client.passkeys().get_confirmation(session, RequestOptions::default()).await?; +// This function is called only after the caller's explicit decision. +async fn confirm_explicitly(client: &polymorfa_sdk::MessagingClient, session: &str, supplied: &polymorfa_sdk::passkeys::ConfirmPasskey) -> polymorfa_sdk::Result<()> { + client.passkeys().confirm(session, supplied, RequestOptions::default()).await?; + Ok(()) +} +# let _ = (state, supplied_confirmation); +# Ok(()) +# } +``` diff --git a/packages/rust/src/lib.rs b/packages/rust/src/lib.rs index 94baa589..c63a5673 100644 --- a/packages/rust/src/lib.rs +++ b/packages/rust/src/lib.rs @@ -42,6 +42,7 @@ pub mod operations; pub mod organization; pub mod organization_controls; pub mod pagination; +pub mod passkeys; pub mod platform; pub mod platform_sessions; pub mod policies; diff --git a/packages/rust/src/messaging.rs b/packages/rust/src/messaging.rs index 9468588c..2bb998be 100644 --- a/packages/rust/src/messaging.rs +++ b/packages/rust/src/messaging.rs @@ -10,6 +10,10 @@ pub struct MessagingClient { pub(crate) http: HttpTransport, } impl MessagingClient { + pub fn passkeys(&self) -> crate::passkeys::Passkeys<'_> { + crate::passkeys::Passkeys(&self.http) + } + pub fn new(credential: Credential, options: ClientOptions) -> Result { Ok(Self { http: HttpTransport::new(credential, options)?, diff --git a/packages/rust/src/passkeys.rs b/packages/rust/src/passkeys.rs new file mode 100644 index 00000000..c92aeb7e --- /dev/null +++ b/packages/rust/src/passkeys.rs @@ -0,0 +1,229 @@ +//! Direct server passkey operations. No ceremony, polling, or automatic confirmation. +use crate::{ + models::SuccessEnvelope, + transport::{encode, HttpTransport}, + ApiResponse, Error, ErrorKind, RequestOptions, Result, +}; +use reqwest::Method; +use serde::{Deserialize, Serialize}; +fn invalid(message: &str) -> Error { + Error::local(ErrorKind::Validation, message, "invalid_parameter") +} +#[derive(Clone, Debug, Default, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct IssuePasskeyChallenge { + #[serde(skip_serializing_if = "Option::is_none")] + pub challenge_b64_url: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub ttl_seconds: Option, + /// Deprecated and ignored by the server; the runner owns this value. + #[serde(skip_serializing_if = "Option::is_none")] + pub user_verification: Option, + /// Deprecated and ignored by the server. Milliseconds. + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyTicket { + pub version: u8, + pub ticket: String, + pub session: String, + pub rp_id: String, + pub challenge_b64_url: String, + pub user_verification: String, + pub expires_at: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub timeout_ms: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyAssertionResponse { + pub authenticator_data_b64: String, + #[serde(rename = "clientDataJSONB64")] + pub client_data_json_b64: String, + pub signature_b64: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub user_handle_b64: Option, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyAssertion { + pub id: String, + pub raw_id_b64_url: String, + pub response: PasskeyAssertionResponse, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct SubmitPasskeyAssertion { + pub ticket_id: String, + pub assertion: PasskeyAssertion, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyAssertionResult { + pub ok: bool, + pub ticket: String, + pub session: String, +} +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ConfirmPasskey { + pub ticket_id: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, +} +/// Known status values; unknown response strings remain available for forward compatibility. +pub const PASSKEY_STATUSES: [&str; 9] = [ + "none", + "challenge_pending", + "assertion_sending", + "assertion_submitted", + "code_available", + "confirmation_sending", + "waiting_for_pairing", + "completed", + "failed", +]; +#[derive(Clone, Debug, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct PasskeyConfirmation { + pub status: String, + #[serde(skip_serializing_if = "Option::is_none")] + pub ticket_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub code: Option, + #[serde(skip_serializing_if = "Option::is_none", rename = "skipHandoffUX")] + pub skip_handoff_ux: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub error: Option, +} +pub struct Passkeys<'a>(pub(crate) &'a HttpTransport); +fn text(s: &str, max: usize) -> bool { + !s.is_empty() && s.encode_utf16().count() <= max +} +impl Passkeys<'_> { + fn path(&self, session: &str) -> Result { + self.0.server()?; + if session.is_empty() { + return Err(invalid("Provide a Number ID.")); + } + Ok(format!("/messaging/{}/passkey", encode(session))) + } + async fn write( + &self, + path: String, + body: &B, + mut options: RequestOptions, + ) -> Result>> { + let bytes = serde_json::to_vec(body).map_err(|_| invalid("Invalid passkey JSON."))?; + if bytes.len() > 1048576 { + return Err(invalid("Passkey request exceeds 1 MiB.")); + } + options.max_network_retries = Some(0); + self.0 + .request(Method::POST, &path, &[], Some(body), options) + .await + } + pub async fn issue_challenge( + &self, + session: &str, + body: &IssuePasskeyChallenge, + options: RequestOptions, + ) -> Result>> { + let path = self.path(session)?; + if body + .challenge_b64_url + .as_ref() + .is_some_and(|s| s.is_empty()) + || body.timeout_ms == Some(0) + || body + .user_verification + .as_ref() + .is_some_and(|s| !matches!(s.as_str(), "required" | "preferred" | "discouraged")) + { + return Err(invalid("Invalid challenge options.")); + } + self.write(path + "/challenge", body, options).await + } + pub async fn get_challenge( + &self, + session: &str, + ticket: &str, + options: RequestOptions, + ) -> Result>> { + let path = self.path(session)?; + if ticket.is_empty() { + return Err(invalid("Provide a ticket.")); + } + self.0 + .request::<_, ()>( + Method::GET, + &format!("{path}/challenge/{}", encode(ticket)), + &[], + None, + options, + ) + .await + } + pub async fn submit_assertion( + &self, + session: &str, + body: &SubmitPasskeyAssertion, + options: RequestOptions, + ) -> Result>> { + let path = self.path(session)?; + let a = &body.assertion; + let r = &a.response; + if body.ticket_id.is_empty() + || !text(&a.id, 1364) + || !text(&a.raw_id_b64_url, 1364) + || r.authenticator_data_b64.is_empty() + || r.client_data_json_b64.is_empty() + || r.signature_b64.is_empty() + || r.user_handle_b64 + .as_ref() + .is_some_and(|s| s.encode_utf16().count() > 86) + { + return Err(invalid("Provide the ticket and bounded assertion.")); + } + self.write(path + "/assertion", body, options).await + } + pub async fn get_confirmation( + &self, + session: &str, + options: RequestOptions, + ) -> Result>> { + self.0 + .request::<_, ()>( + Method::GET, + &(self.path(session)? + "/confirmation"), + &[], + None, + options, + ) + .await + } + pub async fn confirm( + &self, + session: &str, + body: &ConfirmPasskey, + options: RequestOptions, + ) -> Result>> { + let path = self.path(session)?; + if body.ticket_id.is_empty() + || body.code.as_ref().is_some_and(|s| { + s.len() != 9 + || s.as_bytes()[4] != b'-' + || s.bytes() + .enumerate() + .any(|(i, c)| i != 4 && !c.is_ascii_uppercase() && !c.is_ascii_digit()) + }) + { + return Err(invalid( + "Provide the ticket and optional confirmation code.", + )); + } + self.write(path + "/confirmation", body, options).await + } +} diff --git a/packages/rust/tests/passkeys.rs b/packages/rust/tests/passkeys.rs new file mode 100644 index 00000000..808566f1 --- /dev/null +++ b/packages/rust/tests/passkeys.rs @@ -0,0 +1,336 @@ +mod support; +use polymorfa_sdk::{passkeys::*, RequestOptions}; +use serde_json::{json, Value}; +fn fixture() -> Value { + serde_json::from_str(include_str!( + "../../../contracts/fixtures/passkeys.candidate.json" + )) + .unwrap() +} +#[tokio::test] +async fn five_public_operations() { + let f = fixture(); + let cases = [ + ( + "POST", + "/challenge", + Some(f["issue"].clone()), + f["ticket"].clone(), + ), + ("GET", "/challenge/ticket%2F%3F", None, f["ticket"].clone()), + ( + "POST", + "/assertion", + Some(f["assertion"].clone()), + f["assertionResult"].clone(), + ), + ( + "GET", + "/confirmation", + None, + json!({"status":"code_available","code":"ABCD-EF12","skipHandoffUX":false}), + ), + ( + "POST", + "/confirmation", + Some(f["confirm"].clone()), + json!({"status":"waiting_for_pairing"}), + ), + ]; + for (i, (method, suffix, body, data)) in cases.into_iter().enumerate() { + let reply = json!({"success":true,"data":data}); + let (base, server) = support::wire( + method, + &format!("/messaging/desk%2F1/passkey{suffix}"), + body.clone(), + reply.clone(), + ) + .await; + let c = support::messaging(base); + let r = c.passkeys(); + let actual = match i { + 0 => serde_json::to_value( + r.issue_challenge( + "desk/1", + &serde_json::from_value(body.unwrap()).unwrap(), + RequestOptions::default(), + ) + .await + .unwrap() + .data, + ) + .unwrap(), + 1 => serde_json::to_value( + r.get_challenge("desk/1", "ticket/?", RequestOptions::default()) + .await + .unwrap() + .data, + ) + .unwrap(), + 2 => serde_json::to_value( + r.submit_assertion( + "desk/1", + &serde_json::from_value(body.unwrap()).unwrap(), + RequestOptions::default(), + ) + .await + .unwrap() + .data, + ) + .unwrap(), + 3 => serde_json::to_value( + r.get_confirmation("desk/1", RequestOptions::default()) + .await + .unwrap() + .data, + ) + .unwrap(), + _ => serde_json::to_value( + r.confirm( + "desk/1", + &serde_json::from_value(body.unwrap()).unwrap(), + RequestOptions::default(), + ) + .await + .unwrap() + .data, + ) + .unwrap(), + }; + assert_eq!(actual, reply); + server.await.unwrap(); + } +} +#[tokio::test] +async fn outcomes_and_write_retry_override() { + let f = fixture(); + for status in f["statuses"].as_array().unwrap() { + let reply = json!({"success":true,"data":{"status":status}}); + let (base, server) = support::wire( + "GET", + "/messaging/desk/passkey/confirmation", + None, + reply.clone(), + ) + .await; + let c = support::messaging(base); + assert_eq!( + serde_json::to_value( + c.passkeys() + .get_confirmation("desk", RequestOptions::default()) + .await + .unwrap() + .data + ) + .unwrap(), + reply + ); + server.await.unwrap(); + } + for (i, suffix) in ["challenge", "assertion", "confirmation"] + .iter() + .enumerate() + { + let key = ["issue", "assertion", "confirm"][i]; + let (base, server) = support::wire_status( + 503, + "POST", + &format!("/messaging/desk/passkey/{suffix}"), + Some(f[key].clone()), + json!({"error":{"code":"future_passkey","message":"refused"}}), + ) + .await; + let c = support::messaging(base); + let o = RequestOptions { + max_network_retries: Some(3), + idempotency_key: Some("caller-key".into()), + ..Default::default() + }; + let r = c.passkeys(); + let err = match i { + 0 => r + .issue_challenge("desk", &serde_json::from_value(f[key].clone()).unwrap(), o) + .await + .err() + .unwrap(), + 1 => r + .submit_assertion("desk", &serde_json::from_value(f[key].clone()).unwrap(), o) + .await + .err() + .unwrap(), + _ => r + .confirm("desk", &serde_json::from_value(f[key].clone()).unwrap(), o) + .await + .err() + .unwrap(), + }; + assert_eq!(err.code.as_deref(), Some("future_passkey")); + server.await.unwrap(); + } + let c = support::messaging("http://127.0.0.1:1".into()); + let mut bad: SubmitPasskeyAssertion = serde_json::from_value(f["assertion"].clone()).unwrap(); + bad.assertion.id = "a".repeat(1365); + assert!(c + .passkeys() + .submit_assertion("desk", &bad, RequestOptions::default()) + .await + .is_err()); +} + +#[tokio::test] +async fn refusal_statuses_and_optional_serialization() { + let f = fixture(); + for status in f["errors"].as_array().unwrap() { + let status = status.as_u64().unwrap() as u16; + let (base, server) = support::wire_status( + status, + "POST", + "/messaging/desk/passkey/confirmation", + Some(f["confirm"].clone()), + json!({"error":{"code":"future_passkey","message":"refused"}}), + ) + .await; + let c = support::messaging(base); + let err = c + .passkeys() + .confirm( + "desk", + &serde_json::from_value(f["confirm"].clone()).unwrap(), + RequestOptions { + max_network_retries: Some(3), + idempotency_key: Some("caller".into()), + ..Default::default() + }, + ) + .await + .err() + .unwrap(); + assert_eq!(err.status, Some(status)); + assert_eq!(err.code.as_deref(), Some("future_passkey")); + server.await.unwrap(); + } + assert_eq!( + serde_json::to_value(IssuePasskeyChallenge::default()).unwrap(), + json!({}) + ); + assert_eq!( + serde_json::to_value(ConfirmPasskey { + ticket_id: "t".into(), + code: None + }) + .unwrap(), + json!({"ticketId":"t"}) + ); + let mut max: SubmitPasskeyAssertion = serde_json::from_value(f["assertion"].clone()).unwrap(); + max.assertion.id = "x".repeat(1364); + max.assertion.raw_id_b64_url = "x".repeat(1364); + max.assertion.response.user_handle_b64 = Some("x".repeat(86)); + let body = serde_json::to_value(&max).unwrap(); + let (base, server) = support::wire( + "POST", + "/messaging/s/passkey/assertion", + Some(body), + json!({"success":true,"data":f["assertionResult"]}), + ) + .await; + let c = support::messaging(base); + c.passkeys() + .submit_assertion("s", &max, RequestOptions::default()) + .await + .unwrap(); + server.await.unwrap(); + max.assertion.response.signature_b64 = "x".repeat(1048576); + assert!(c + .passkeys() + .submit_assertion("s", &max, RequestOptions::default()) + .await + .is_err()); +} + +#[tokio::test] +async fn uncertain_upload_and_non_json_do_not_repeat() { + use std::sync::{ + atomic::{AtomicUsize, Ordering}, + Arc, + }; + use tokio::io::{AsyncReadExt, AsyncWriteExt}; + let f = fixture(); + for non_json in [false, true] { + for i in 0..3 { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let base = format!("http://{}", listener.local_addr().unwrap()); + let count = Arc::new(AtomicUsize::new(0)); + let seen = count.clone(); + let server = tokio::spawn(async move { + while let Ok((mut socket, _)) = listener.accept().await { + let mut bytes = Vec::new(); + let mut buf = [0u8; 4096]; + let (end, length) = loop { + let n = socket.read(&mut buf).await.unwrap(); + if n == 0 { + return; + } + bytes.extend_from_slice(&buf[..n]); + if let Some(end) = bytes.windows(4).position(|w| w == b"\r\n\r\n") { + let h = String::from_utf8_lossy(&bytes[..end]); + let length = h + .lines() + .find_map(|l| { + l.to_ascii_lowercase() + .strip_prefix("content-length:") + .and_then(|s| s.trim().parse::().ok()) + }) + .unwrap_or(0); + break (end + 4, length); + } + }; + while bytes.len() < end + length { + let n = socket.read(&mut buf).await.unwrap(); + if n == 0 { + break; + } + bytes.extend_from_slice(&buf[..n]); + } + seen.fetch_add(1, Ordering::SeqCst); + if non_json { + let body = "unavailable"; + let _=socket.write_all(format!("HTTP/1.1 502 Bad Gateway\r\ncontent-type: text/html\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{body}",body.len()).as_bytes()).await; + } + } + }); + let c = support::messaging(base); + let r = c.passkeys(); + let o = RequestOptions { + max_network_retries: Some(3), + idempotency_key: Some("caller".into()), + ..Default::default() + }; + let err = match i { + 0 => r + .issue_challenge("s", &serde_json::from_value(f["issue"].clone()).unwrap(), o) + .await + .err(), + 1 => r + .submit_assertion( + "s", + &serde_json::from_value(f["assertion"].clone()).unwrap(), + o, + ) + .await + .err(), + _ => r + .confirm( + "s", + &serde_json::from_value(f["confirm"].clone()).unwrap(), + o, + ) + .await + .err(), + }; + server.abort(); + let _ = server.await; + assert!(err.is_some()); + assert_eq!(count.load(Ordering::SeqCst), 1); + } + } +} diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 81b9be8c..067a8423 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -3472,3 +3472,40 @@ guarantees coverage; all other Ban Insurance conditions still apply. The server calculates this value. Do not send it in an update or calculate it from the current switch alone. This addition is unreleased; source preparation does not establish API deployment or package publication. + +## Direct passkey relay (unreleased) + +Use server credentials with `sessions:manage` for every operation, including reads. +The API also checks live Number ownership, direct-pairing access, relay enrollment +and the current attempt. These methods accept ceremony data supplied by an +external participant. They do not run WebAuthn, store credentials, poll, or confirm +a pairing automatically. Hosted QuickLink and extension relay endpoints are excluded. + +Challenge TTL uses seconds; ceremony `timeoutMs` uses milliseconds. The server +owns ceremony options and ignores the deprecated `userVerification` and +`timeoutMs` request fields. An optional expected `challengeB64Url` must match the +current attempt. Keep `rawIdB64Url` and the named `*B64` assertion strings unchanged; +do not convert between base64 and base64url. Omit optional fields when absent. + +Challenge issuance, assertion submission and explicit confirmation each make one +attempt, even with caller retry options or an idempotency key. If the outcome is +uncertain, read current challenge/confirmation state and reconcile it before a new +write. Assertion acceptance is not pairing completion. Forwarded API failures keep +their status and open error code; the SDK does not invent passkey error codes. + +The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16 +units and optional user handles allow 86. Other opaque assertion fields share the +body limit. The API validates encodings, decoded bounds and challenge binding. + +This is source preparation. Matching API acceptance, package qualification and +publication are still required before using a released package. + +```ts +const ticket = await client.passkeys.issueChallenge(session); +await client.passkeys.getChallenge(session, ticket.data.data.ticket); +await client.passkeys.submitAssertion(session, suppliedAssertion); +const state = await client.passkeys.getConfirmation(session); +// Invoke only after the caller verifies the current state and makes its decision. +const confirmExplicitly = () => + client.passkeys.confirm(session, suppliedConfirmation); +``` diff --git a/packages/typescript/src/index.ts b/packages/typescript/src/index.ts index eef7f372..ccce64ab 100644 --- a/packages/typescript/src/index.ts +++ b/packages/typescript/src/index.ts @@ -1416,3 +1416,5 @@ export * from "./messaging/campaign-drip.js"; export type { IncomingOrderCart } from "./messaging/order-cart.js"; export type { CloudReadinessFailureCode } from "./messaging/cloud-readiness.js"; + +export * from "./messaging/passkeys.js"; diff --git a/packages/typescript/src/messaging/client.ts b/packages/typescript/src/messaging/client.ts index 47cf930f..6fd758ea 100644 --- a/packages/typescript/src/messaging/client.ts +++ b/packages/typescript/src/messaging/client.ts @@ -1,3 +1,4 @@ +import { PasskeysResource } from "./passkeys.js"; import { HybridLinkResource } from "./hybrid-link.js"; import { CloudOnboardingResource, TestingResource } from "./onboarding.js"; import { @@ -41,6 +42,7 @@ import { VoipResource } from "./voip.js"; import { WebhooksResource } from "./webhooks.js"; export class MessagingClient { + readonly passkeys: PasskeysResource; readonly banSafe: MessagingBanSafeResource; readonly business: BusinessResource; readonly calls: CallsResource; @@ -131,6 +133,7 @@ export class MessagingClient { transport, credential.type, ); + this.passkeys = new PasskeysResource(transport, credential.type); this.catalogs = new CatalogsResource(transport, credential.type); this.cloudCatalogs = new CloudCatalogsResource(transport, credential.type); this.cloudMarketing = new CloudMarketingResource( diff --git a/packages/typescript/src/messaging/passkeys.ts b/packages/typescript/src/messaging/passkeys.ts new file mode 100644 index 00000000..5c807eb0 --- /dev/null +++ b/packages/typescript/src/messaging/passkeys.ts @@ -0,0 +1,196 @@ +import type { MessagingCredential } from "../credentials.js"; +import { + PolymorfaConfigurationError, + PolymorfaValidationError, +} from "../errors.js"; +import { HttpTransport } from "../transport/http.js"; +import type { ApiResponse, RequestOptions } from "../transport/types.js"; +export interface IssuePasskeyChallengeRequest { + readonly challengeB64Url?: string; + readonly ttlSeconds?: number; + /** @deprecated Ignored. The runner determines ceremony verification. */ + readonly userVerification?: "required" | "preferred" | "discouraged"; + /** @deprecated Ignored. The runner determines ceremony timeout. */ + readonly timeoutMs?: number; +} +export interface PasskeyTicket { + readonly version: 2; + readonly ticket: string; + readonly session: string; + readonly rpId: string; + readonly challengeB64Url: string; + readonly userVerification: "required" | "preferred" | "discouraged"; + readonly expiresAt: string; + readonly timeoutMs?: number; +} +export interface PasskeyAssertionResponse { + readonly authenticatorDataB64: string; + readonly clientDataJSONB64: string; + readonly signatureB64: string; + readonly userHandleB64?: string; +} +export interface PasskeyAssertion { + readonly id: string; + readonly rawIdB64Url: string; + readonly response: PasskeyAssertionResponse; +} +export interface SubmitPasskeyAssertionRequest { + readonly ticketId: string; + readonly assertion: PasskeyAssertion; +} +export interface PasskeyAssertionResult { + readonly ok: true; + readonly ticket: string; + readonly session: string; +} +export interface ConfirmPasskeyRequest { + readonly ticketId: string; + readonly code?: string; +} +export type PasskeyConfirmationStatus = + | "none" + | "challenge_pending" + | "assertion_sending" + | "assertion_submitted" + | "code_available" + | "confirmation_sending" + | "waiting_for_pairing" + | "completed" + | "failed"; +export interface PasskeyConfirmation { + readonly status: PasskeyConfirmationStatus; + readonly ticketId?: string; + readonly code?: string; + readonly skipHandoffUX?: boolean; + readonly error?: string; +} +export interface PasskeyResponse { + readonly success: true; + readonly data: T; +} +function invalid(): never { + throw new PolymorfaValidationError("Invalid passkey request."); +} +function nonempty(s: unknown): s is string { + return typeof s === "string" && s.length > 0; +} +/** Direct customer relay. All five operations require sessions:manage server credentials. */ +export class PasskeysResource { + constructor( + private readonly transport: HttpTransport, + private readonly credentialType: MessagingCredential["type"], + ) {} + private path(session: string): string { + if (this.credentialType === "clientToken") + throw new PolymorfaConfigurationError( + "Passkeys require a server credential.", + "credential", + ); + if (!nonempty(session)) invalid(); + return `/messaging/${encodeURIComponent(session)}/passkey`; + } + private write( + path: string, + body: object, + options: RequestOptions, + ): Promise>> { + let text: string; + try { + text = JSON.stringify(body); + } catch { + return invalid(); + } + if (new TextEncoder().encode(text).length > 1048576) invalid(); + return this.transport.request({ + method: "POST", + path, + body: JSON.parse(text), + ...options, + maxNetworkRetries: 0, + }); + } + issueChallenge( + session: string, + body: IssuePasskeyChallengeRequest = {}, + options: RequestOptions = {}, + ): Promise>> { + const path = this.path(session); + if (!body || typeof body !== "object") invalid(); + if ( + (body.challengeB64Url !== undefined && !nonempty(body.challengeB64Url)) || + (body.ttlSeconds !== undefined && + !Number.isSafeInteger(body.ttlSeconds)) || + (body.timeoutMs !== undefined && + (!Number.isSafeInteger(body.timeoutMs) || body.timeoutMs <= 0)) || + (body.userVerification !== undefined && + !["required", "preferred", "discouraged"].includes( + body.userVerification, + )) + ) + invalid(); + return this.write(path + "/challenge", body, options); + } + getChallenge( + session: string, + ticketId: string, + options: RequestOptions = {}, + ): Promise>> { + const path = this.path(session); + if (!nonempty(ticketId)) invalid(); + return this.transport.request({ + method: "GET", + path: path + "/challenge/" + encodeURIComponent(ticketId), + ...options, + }); + } + submitAssertion( + session: string, + body: SubmitPasskeyAssertionRequest, + options: RequestOptions = {}, + ): Promise>> { + const path = this.path(session); + const a = body?.assertion; + const v = a?.response; + if ( + !nonempty(body?.ticketId) || + !nonempty(a?.id) || + a.id.length > 1364 || + !nonempty(a?.rawIdB64Url) || + a.rawIdB64Url.length > 1364 || + !v || + !nonempty(v.authenticatorDataB64) || + !nonempty(v.clientDataJSONB64) || + !nonempty(v.signatureB64) || + (v.userHandleB64 !== undefined && + (typeof v.userHandleB64 !== "string" || v.userHandleB64.length > 86)) + ) + invalid(); + return this.write(path + "/assertion", body, options); + } + getConfirmation( + session: string, + options: RequestOptions = {}, + ): Promise>> { + return this.transport.request({ + method: "GET", + path: this.path(session) + "/confirmation", + ...options, + }); + } + /** Confirm only after comparison; an accepted receipt is not pairing completion. */ + confirm( + session: string, + body: ConfirmPasskeyRequest, + options: RequestOptions = {}, + ): Promise>> { + const path = this.path(session); + if ( + !nonempty(body?.ticketId) || + (body.code !== undefined && + (typeof body.code !== "string" || + !/^[A-Z0-9]{4}-[A-Z0-9]{4}$/.test(body.code))) + ) + invalid(); + return this.write(path + "/confirmation", body, options); + } +} diff --git a/packages/typescript/test/passkeys.test.ts b/packages/typescript/test/passkeys.test.ts new file mode 100644 index 00000000..c28c4c3a --- /dev/null +++ b/packages/typescript/test/passkeys.test.ts @@ -0,0 +1,259 @@ +import { readFileSync } from "node:fs"; +import { describe, it, expect } from "vitest"; +import { MessagingClient } from "../src/index.js"; +import { ORGANIZATION_API_KEY } from "./support/credentials.js"; +const f = JSON.parse( + readFileSync( + new URL( + "../../../contracts/fixtures/passkeys.candidate.json", + import.meta.url, + ), + "utf8", + ), +); +const operations = [ + { + method: "POST", + suffix: "/challenge", + body: f.issue, + reply: f.ticket, + call: (c: MessagingClient) => c.passkeys.issueChallenge(f.session, f.issue), + }, + { + method: "GET", + suffix: "/challenge/ticket%2F%3F", + body: undefined, + reply: f.ticket, + call: (c: MessagingClient) => + c.passkeys.getChallenge(f.session, f.ticketId), + }, + { + method: "POST", + suffix: "/assertion", + body: f.assertion, + reply: f.assertionResult, + call: (c: MessagingClient) => + c.passkeys.submitAssertion(f.session, f.assertion), + }, + { + method: "GET", + suffix: "/confirmation", + body: undefined, + reply: { + status: "code_available", + ticketId: f.ticketId, + code: "ABCD-EF12", + skipHandoffUX: false, + }, + call: (c: MessagingClient) => c.passkeys.getConfirmation(f.session), + }, + { + method: "POST", + suffix: "/confirmation", + body: f.confirm, + reply: { status: "waiting_for_pairing" }, + call: (c: MessagingClient) => c.passkeys.confirm(f.session, f.confirm), + }, +]; +describe("five direct passkey operations", () => { + for (const [i, op] of operations.entries()) + it("wire " + i, async () => { + const c = new MessagingClient({ + credential: { type: "apiKey", value: ORGANIZATION_API_KEY }, + baseUrl: "https://api.example", + fetch: async (url, init) => { + expect(String(url)).toBe( + "https://api.example/messaging/desk%2F1/passkey" + op.suffix, + ); + expect(init?.method).toBe(op.method); + expect(new Headers(init?.headers).get("authorization")).toBe( + "Bearer " + ORGANIZATION_API_KEY, + ); + expect(new Headers(init?.headers).get("polymorfa-version")).toBe( + "2026-09-22", + ); + expect( + init?.body === undefined + ? undefined + : JSON.parse(String(init.body)), + ).toEqual(op.body); + return new Response( + JSON.stringify({ success: true, data: op.reply }), + { headers: { "content-type": "application/json" } }, + ); + }, + }); + expect((await op.call(c)).data).toEqual({ + success: true, + data: op.reply, + }); + }); + it("never retries uncertain writes even with an idempotency key", async () => { + for (const kind of ["issue", "assertion", "confirm"]) { + let calls = 0; + const c = new MessagingClient({ + credential: { type: "apiKey", value: ORGANIZATION_API_KEY }, + maxNetworkRetries: 3, + fetch: async () => { + calls++; + return new Response( + JSON.stringify({ + error: { + code: "future_passkey_failure", + message: "Unknown outcome", + }, + }), + { status: 503, headers: { "content-type": "application/json" } }, + ); + }, + }); + const options = { maxNetworkRetries: 3, idempotencyKey: "caller-key" }; + const invoke = () => + kind === "issue" + ? c.passkeys.issueChallenge("s", f.issue, options) + : kind === "assertion" + ? c.passkeys.submitAssertion("s", f.assertion, options) + : c.passkeys.confirm("s", f.confirm, options); + await expect(invoke()).rejects.toMatchObject({ + code: "future_passkey_failure", + }); + expect(calls).toBe(1); + } + }); + it("preserves all confirmation statuses and optional absence", async () => { + for (const status of f.statuses) { + const c = new MessagingClient({ + credential: { type: "apiKey", value: ORGANIZATION_API_KEY }, + fetch: async () => + new Response(JSON.stringify({ success: true, data: { status } }), { + headers: { "content-type": "application/json" }, + }), + }); + expect((await c.passkeys.getConfirmation("s")).data.data).toEqual({ + status, + }); + } + }); + it("refuses client tokens and oversized bodies before I/O", () => { + let calls = 0; + const c = new MessagingClient({ + credential: { type: "apiKey", value: ORGANIZATION_API_KEY }, + fetch: async () => { + calls++; + throw Error("unexpected"); + }, + }); + expect(() => + c.passkeys.submitAssertion("s", { + ...f.assertion, + assertion: { + ...f.assertion.assertion, + response: { + ...f.assertion.assertion.response, + signatureB64: "x".repeat(1048576), + }, + }, + }), + ).toThrow(); + expect(() => + c.passkeys.submitAssertion("s", { + ...f.assertion, + assertion: { ...f.assertion.assertion, id: "x".repeat(1365) }, + }), + ).toThrow(); + expect(() => + c.passkeys.confirm("s", { ticketId: "x", code: "abcd-1234" }), + ).toThrow(); + const browser = new MessagingClient({ + credential: { type: "clientToken", value: "pmfa_ct_Zml4dHVyZQ" }, + fetch: async () => { + calls++; + throw Error("unexpected"); + }, + }); + expect(() => browser.passkeys.getConfirmation("s")).toThrow(); + expect(calls).toBe(0); + }); +}); + +it("preserves refusal statuses, non-JSON errors and uncertain upload without duplicate POST", async () => { + for (const status of f.errors) { + let calls = 0; + const c = new MessagingClient({ + credential: { type: "apiKey", value: ORGANIZATION_API_KEY }, + fetch: async () => { + calls++; + return new Response( + JSON.stringify({ + error: { code: "future_code", message: "refused" }, + }), + { status, headers: { "content-type": "application/json" } }, + ); + }, + }); + await expect( + c.passkeys.confirm("s", f.confirm, { + maxNetworkRetries: 3, + idempotencyKey: "caller", + }), + ).rejects.toMatchObject({ status, code: "future_code" }); + expect(calls).toBe(1); + } + for (const mode of ["non-json", "network"]) { + for (const kind of ["issue", "assertion", "confirm"]) { + let calls = 0; + const c = new MessagingClient({ + credential: { type: "apiKey", value: ORGANIZATION_API_KEY }, + fetch: async () => { + calls++; + if (mode === "network") + throw new TypeError("connection lost after upload"); + return new Response("unavailable", { + status: 502, + headers: { "content-type": "text/html" }, + }); + }, + }); + const o = { maxNetworkRetries: 3, idempotencyKey: "caller" }; + await expect( + kind === "issue" + ? c.passkeys.issueChallenge("s", f.issue, o) + : kind === "assertion" + ? c.passkeys.submitAssertion("s", f.assertion, o) + : c.passkeys.confirm("s", f.confirm, o), + ).rejects.toBeDefined(); + expect(calls).toBe(1); + } + } +}); +it("omits optional inputs and keeps maximum credential/user-handle wire strings", async () => { + const bodies: unknown[] = []; + const c = new MessagingClient({ + credential: { type: "apiKey", value: ORGANIZATION_API_KEY }, + fetch: async (_, init) => { + bodies.push(JSON.parse(String(init?.body))); + return new Response( + JSON.stringify({ success: true, data: { status: "none" } }), + { headers: { "content-type": "application/json" } }, + ); + }, + }); + await c.passkeys.issueChallenge("s"); + await c.passkeys.confirm("s", { ticketId: "ticket" }); + const max = { + ...f.assertion, + assertion: { + ...f.assertion.assertion, + id: "x".repeat(1364), + rawIdB64Url: "x".repeat(1364), + response: { + ...f.assertion.assertion.response, + userHandleB64: "x".repeat(86), + }, + }, + }; + await c.passkeys.submitAssertion("s", max); + expect(bodies).toEqual([{}, { ticketId: "ticket" }, max]); + expect("verify" in c.passkeys).toBe(false); + expect("submitQuickLinkPasskey" in c.passkeys).toBe(false); +}); diff --git a/scripts/passkeys-candidate.test.mjs b/scripts/passkeys-candidate.test.mjs new file mode 100644 index 00000000..75a96a2b --- /dev/null +++ b/scripts/passkeys-candidate.test.mjs @@ -0,0 +1,86 @@ +import assert from "node:assert/strict"; +import { readFileSync } from "node:fs"; +import { createHash } from "node:crypto"; +import { test } from "node:test"; +import Ajv from "ajv"; +const read = (p) => readFileSync(new URL("../" + p, import.meta.url), "utf8"); +const m = JSON.parse(read("contracts/passkeys.candidate.json")); +const fragment = JSON.parse(read(m.fragment.path)); +const f = JSON.parse(read("contracts/fixtures/passkeys.candidate.json")); +// OpenAPI 3.0 uses boolean exclusiveMinimum, unlike JSON Schema draft7. +function jsonSchema(value) { + if (Array.isArray(value)) return value.map(jsonSchema); + if (!value || typeof value !== "object") return value; + const out = Object.fromEntries( + Object.entries(value).map(([key, child]) => [key, jsonSchema(child)]), + ); + if (typeof out.exclusiveMinimum === "boolean") { + if (out.exclusiveMinimum) { + out.exclusiveMinimum = out.minimum; + delete out.minimum; + } else delete out.exclusiveMinimum; + } + if (out.nullable && typeof out.type === "string") + out.type = [out.type, "null"]; + delete out.nullable; + return out; +} + +test("candidate five-operation scope, immutable fragment, independent producer status", () => { + assert.equal(m.sourceAccepted, false); + assert.equal(m.published, false); + assert.equal( + m.candidateSource.commit, + "c9902b86e4a1b52092029486cc16c1699456d539", + ); + assert.equal( + m.coexistingCandidate.commit, + "0b7b739493b089c386fc21f2cf76f05f60de9151", + ); + assert.equal(m.coexistingCandidate.sourceAccepted, false); + assert.equal( + m.fragment.sha256, + createHash("sha256").update(read(m.fragment.path)).digest("hex"), + ); + assert.equal(m.operations.length, 5); + assert.equal(m.excluded.length, 3); + for (const op of m.operations) { + const actual = fragment.paths[op.path][op.method.toLowerCase()]; + assert.equal(actual.operationId, op.operationId); + assert.equal(actual["x-required-scope"], "sessions:manage"); + assert.deepEqual(actual.security, [{ BearerAuth: [] }]); + assert.equal(Object.keys(op.counterparts).length, 6); + assert.equal(op.serverCredentialsOnly, true); + if (op.method === "POST") assert.equal(op.automaticRetries, 0); + } + assert.deepEqual( + f.statuses, + fragment.components.schemas.PasskeyConfirmation.properties.status.enum, + ); +}); +test("synthetic native wire values match candidate public schemas, not a live ceremony", () => { + const ajv = new Ajv({ strict: false, allErrors: true }); + function check(schema, value) { + const validate = ajv.compile( + jsonSchema({ + components: fragment.components, + ...schema, + }), + ); + assert.equal(validate(value), true, JSON.stringify(validate.errors)); + } + check({ $ref: "#/components/schemas/IssuePasskeyChallengeRequest" }, f.issue); + check( + { $ref: "#/components/schemas/SubmitPasskeyAssertionRequest" }, + f.assertion, + ); + check({ $ref: "#/components/schemas/PasskeyTicket" }, f.ticket); + check( + { $ref: "#/components/schemas/PasskeyAssertionResult" }, + f.assertionResult, + ); + const confirmation = + fragment.paths["/messaging/{session}/passkey/confirmation"].post.requestBody + .content["application/json"].schema; + check(confirmation, f.confirm); +}); From b233051900ba457228f13a48c337aafc8babf78c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Fri, 23 Oct 2026 16:50:54 +0300 Subject: [PATCH 04/21] docs: bind unchanged snippets to passkey counterpart source --- contracts/doc-snippets.json | 4 ++-- scripts/doc-snippets/recipes.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/doc-snippets.json b/contracts/doc-snippets.json index 5a9b8547..d07c54e8 100644 --- a/contracts/doc-snippets.json +++ b/contracts/doc-snippets.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "sourceCommit": "71f5893673ac2d61a9ed9b8b3919100868eaf311", - "sourceFilesSha256": "2fc582a52d78f52f27a002f480c4bb4ceae0edebf34492b875a86a62e9ef833b", + "sourceCommit": "ce6c41d069aa0f2b6e3f44790b1dabd0fe208112", + "sourceFilesSha256": "e24d3c15db553ac3c25692932c8dcb45f369dfc0642071fd110fdc78d23fb6bc", "apiVersion": "2026-09-22", "apiSourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99", "specs": { diff --git a/scripts/doc-snippets/recipes.mjs b/scripts/doc-snippets/recipes.mjs index 97ddd9ee..0d7a580e 100644 --- a/scripts/doc-snippets/recipes.mjs +++ b/scripts/doc-snippets/recipes.mjs @@ -1,7 +1,7 @@ // Requests come from the pinned OpenAPI and existing native wire fixtures. // Calls are handwritten in the language modules. Never derive arguments from // contracts/coverage.json's display-only method names. -export const sourceCommit = "71f5893673ac2d61a9ed9b8b3919100868eaf311"; +export const sourceCommit = "ce6c41d069aa0f2b6e3f44790b1dabd0fe208112"; export const apiVersion = "2026-09-22"; export const languages = [ "typescript", From fc8f2dff2aa358da6ae2df1d974b5f8c6d1ef60b Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 01:56:37 +0200 Subject: [PATCH 05/21] style: format the call-settings exclusion-end candidate manifest npm run format:check failed on this file since 71f58936. --- contracts/calls-coverage-end.candidate.json | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/contracts/calls-coverage-end.candidate.json b/contracts/calls-coverage-end.candidate.json index ef02d77d..08d1c838 100644 --- a/contracts/calls-coverage-end.candidate.json +++ b/contracts/calls-coverage-end.candidate.json @@ -32,10 +32,7 @@ "fragmentSha256": "8553868e4cb332bb7e756b28d01b828ceab5a05866bb72710ce6372b7988837d", "fixturePath": "contracts/fixtures/calls-coverage-end.candidate.json", "fixtureSha256": "9e0a281012f6b94ad44ef38454da52d95fd4fca759c45e1070af861ab24d5cee", - "operations": [ - "getCallSettings", - "updateCallSettings" - ], + "operations": ["getCallSettings", "updateCallSettings"], "field": "simultaneousCallsCoverageExcludedUntil", "responseOnly": true, "required": true, From 1be189828252ca5139cf17d8968e3dc80cebd1b4 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 01:57:50 +0200 Subject: [PATCH 06/21] contracts: bind passkey and call-settings counterparts to accepted API dev 77e9ce31 API dev 77e9ce31 (merge of polymorfa/polymorfa#168, containing #798) holds both producers. Regenerated specs equal the committed ones. - Passkeys: the five operations and 13 schemas are unchanged from c9902b86, so the fragment bytes stay as they are. The manifest gains an acceptedSource block with 77e9 file hashes; sourceAccepted is now true and published stays false. - Call settings: the simultaneousCallsCoverageExcludedUntil description takes the corrected 77e9 wording (when a ban is excluded). Fragment and fixture hashes rebound. The private Console schema note is resolved at 77e9. - READMEs and the changelog state the accepted source and repeat the clarified exclusion rule. Packages remain unpublished. - The candidate contract test asserts both bindings and now runs in CI. --- .github/workflows/ci.yml | 2 +- CHANGELOG.md | 4 +- ...alls-coverage-end.candidate.fragments.json | 2 +- contracts/calls-coverage-end.candidate.json | 43 ++++++++++++-- .../calls-coverage-end.candidate.json | 4 +- contracts/fixtures/passkeys.candidate.json | 4 +- contracts/passkeys.candidate.json | 56 ++++++++++++++++++- docs/passkeys.md | 4 +- packages/dotnet/README.md | 8 ++- packages/go/README.md | 8 ++- packages/php/README.md | 8 ++- packages/python/README.md | 8 ++- packages/rust/README.md | 8 ++- packages/typescript/README.md | 8 ++- scripts/passkeys-candidate.test.mjs | 33 ++++++++++- 15 files changed, 161 insertions(+), 39 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4f7e8440..13430e3c 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -30,7 +30,7 @@ jobs: - run: npm run typecheck - run: npm run build:workspaces - run: npm test - - run: node --test scripts/release-gate.test.mjs scripts/native-release.test.mjs scripts/native-publish.test.mjs scripts/sync-linear-coverage.test.mjs + - run: node --test scripts/release-gate.test.mjs scripts/native-release.test.mjs scripts/native-publish.test.mjs scripts/sync-linear-coverage.test.mjs scripts/passkeys-candidate.test.mjs - run: node --test scripts/sdk-fixture-server.test.mjs scripts/multi-language-coverage.test.mjs examples/calls-example/server-credential.test.mjs - run: python3 scripts/inspect-native-artifacts.test.py - run: python3 scripts/prepare-php-mirror.test.py diff --git a/CHANGELOG.md b/CHANGELOG.md index 236ba9c1..54af397f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,9 +2,9 @@ ## Unreleased -- Prepare direct server passkey challenge, assertion and explicit confirmation methods in all six SDKs. Writes use one attempt; ceremony execution remains external. Unreleased and awaiting matching API/package acceptance. +- Prepare direct server passkey challenge, assertion and explicit confirmation methods in all six SDKs. Writes use one attempt; ceremony execution remains external. Bound to accepted API source `77e9ce31`; unreleased until package publication. -- Add the read-only nullable call-settings exclusion-end response field across all six SDKs. Rust response struct literals require the new optional field; C# positional construction remains compatible. Unreleased and dependent on the matching API contract. +- Add the read-only nullable call-settings exclusion-end response field across all six SDKs. Rust response struct literals require the new optional field; C# positional construction remains compatible. Bound to accepted API source `77e9ce31`, which also clarifies when a ban is excluded; unreleased. - All six server SDKs expose optional automatic-pause `reason`/`code` and optional recipient-skip `messageIndex`/`followUpId` from accepted API source diff --git a/contracts/calls-coverage-end.candidate.fragments.json b/contracts/calls-coverage-end.candidate.fragments.json index 68a4e671..a9b5a92f 100644 --- a/contracts/calls-coverage-end.candidate.fragments.json +++ b/contracts/calls-coverage-end.candidate.fragments.json @@ -36,7 +36,7 @@ "type": "string", "nullable": true, "format": "date-time", - "description": "Read-only. While `simultaneousCalls` is false, when Ban Insurance stops excluding this Number because of an earlier period with it on: 30 days after the latest period ended. A ban that starts before this time is not refunded; from this time on, that period no longer excludes it. Null while `simultaneousCalls` is true, because the exclusion has no end yet, and null when no period ended in the last 30 days. Null does not mean the Number is covered: the other Ban Insurance conditions still apply." + "description": "Read-only. While `simultaneousCalls` is false, when Ban Insurance stops excluding this Number because of an earlier period with it on: 30 days after the latest period ended. A ban is excluded when the setting was on at any time in the 30 days before it, so until this time, a new ban is not refunded; from this time on, that period no longer excludes a ban. Null while `simultaneousCalls` is true, because the exclusion has no end yet, and null when no period ended in the last 30 days. Null does not mean the Number is covered: the other Ban Insurance conditions still apply." }, "revision": { "type": "integer", diff --git a/contracts/calls-coverage-end.candidate.json b/contracts/calls-coverage-end.candidate.json index 08d1c838..7bf989d0 100644 --- a/contracts/calls-coverage-end.candidate.json +++ b/contracts/calls-coverage-end.candidate.json @@ -1,7 +1,40 @@ { "schemaVersion": 1, - "sourceAccepted": false, + "sourceAccepted": true, "published": false, + "acceptedSource": { + "repository": "polymorfa/polymorfa", + "branch": "dev", + "commit": "77e9ce31aa5e0ea58815662ba0028f1927f690d5", + "tree": "b2f9fdfc575a0fabb5edb57b7780411f5e9b4b90", + "parents": [ + "6ce6f6c975224abe8ab21eae96b381044f3a6073", + "4be63eb3310615f47b1c3dae410f1adbd5dc0727" + ], + "pullRequests": ["polymorfa/polymorfa#168", "polymorfa/polymorfa#798"], + "files": [ + { + "path": "apps/api/packages/api/src/routes/call-settings.ts", + "sha256": "c39aa1ad8facc02b391974be7ba502293662797c5dce3626f4509ecbf7cae4eb" + }, + { + "path": "apps/api/packages/api/src/db/calls/settings-repo.ts", + "sha256": "e72ac879bf6b685acf045039b58f8246a9dc068dcd1991ab7de510fbe8d7871e" + }, + { + "path": "apps/api/docs/openapi.management.json", + "sha256": "3a4370816296cf96296d643e3d823185c64d354ca29ac24343cebff774d43319" + }, + { + "path": "docs/api-reference/openapi.management.json", + "sha256": "af09de19a9152fe4977e5545213ccb09210e4a0ed3019020fb46c78b66f3f096" + }, + { + "path": "docs/openapi.json", + "sha256": "03da2795efd1c1801d98f513a7585395d6522e8585ab4a8d2b5863f4e81d6443" + } + ] + }, "candidateCommit": "0b7b739493b089c386fc21f2cf76f05f60de9151", "candidateTree": "1b5c55375b928e2a3f9068d180aa43bd5be61cb8", "candidateParent": "4bf556ab46e23c6d87fa376151f1bd217f6f0812", @@ -29,15 +62,15 @@ } ], "fragmentPath": "contracts/calls-coverage-end.candidate.fragments.json", - "fragmentSha256": "8553868e4cb332bb7e756b28d01b828ceab5a05866bb72710ce6372b7988837d", + "fragmentSha256": "ebf836d45a87eea629808f2b2701667d742a06df031c71b476d942cca61da79a", "fixturePath": "contracts/fixtures/calls-coverage-end.candidate.json", - "fixtureSha256": "9e0a281012f6b94ad44ef38454da52d95fd4fca759c45e1070af861ab24d5cee", + "fixtureSha256": "b7a2af6007dac4de11fc86fd236cd4d36bd4af2362a35078b1d5ee928fa4568b", "operations": ["getCallSettings", "updateCallSettings"], "field": "simultaneousCallsCoverageExcludedUntil", "responseOnly": true, "required": true, "nullable": true, "format": "date-time", - "acceptedContainingCommit": null, - "privateConsoleSchemaNote": "Candidate shared serializer adds the field, but SessionCallSettings private Console schema in management snapshot lacks it; producer-owned reconciliation required. No SDK private route." + "acceptedContainingCommit": "77e9ce31aa5e0ea58815662ba0028f1927f690d5", + "privateConsoleSchemaNote": "At 77e9ce31 the private Console SessionCallSettings schema also carries the field. No SDK private route." } diff --git a/contracts/fixtures/calls-coverage-end.candidate.json b/contracts/fixtures/calls-coverage-end.candidate.json index de036fb8..71d487a7 100644 --- a/contracts/fixtures/calls-coverage-end.candidate.json +++ b/contracts/fixtures/calls-coverage-end.candidate.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "candidateSource": "0b7b739493b089c386fc21f2cf76f05f60de9151", - "sourceAccepted": false, + "candidateSource": "77e9ce31aa5e0ea58815662ba0028f1927f690d5", + "sourceAccepted": true, "settings": { "callsEnabled": true, "conferenceMode": false, diff --git a/contracts/fixtures/passkeys.candidate.json b/contracts/fixtures/passkeys.candidate.json index 4c97301b..b49a8d45 100644 --- a/contracts/fixtures/passkeys.candidate.json +++ b/contracts/fixtures/passkeys.candidate.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "candidateSource": "c9902b86e4a1b52092029486cc16c1699456d539", - "sourceAccepted": false, + "candidateSource": "77e9ce31aa5e0ea58815662ba0028f1927f690d5", + "sourceAccepted": true, "session": "desk/1", "ticketId": "ticket/?", "issue": { diff --git a/contracts/passkeys.candidate.json b/contracts/passkeys.candidate.json index 8f86a16a..ddaec945 100644 --- a/contracts/passkeys.candidate.json +++ b/contracts/passkeys.candidate.json @@ -1,7 +1,56 @@ { "schemaVersion": 1, - "sourceAccepted": false, + "sourceAccepted": true, "published": false, + "acceptedSource": { + "repository": "polymorfa/polymorfa", + "branch": "dev", + "commit": "77e9ce31aa5e0ea58815662ba0028f1927f690d5", + "tree": "b2f9fdfc575a0fabb5edb57b7780411f5e9b4b90", + "parents": [ + "6ce6f6c975224abe8ab21eae96b381044f3a6073", + "4be63eb3310615f47b1c3dae410f1adbd5dc0727" + ], + "pullRequests": ["polymorfa/polymorfa#168", "polymorfa/polymorfa#798"], + "files": [ + { + "path": "apps/api/packages/api/src/routes/session-passkey.ts", + "sha256": "04fdd4beb424a83f2fb63f5090af26992b06c60a2123bb2f35a5dc31150bb890" + }, + { + "path": "apps/api/packages/api/src/lib/session-passkey-tickets.ts", + "sha256": "e5f0b4b941c506db0e0bb42236c02d7f36b134fd1b0f635678925dcdad1276c0" + }, + { + "path": "apps/api/packages/api/src/lib/passkey-assertion-limits.ts", + "sha256": "f442c6d9b79504263f807343838a2217c0c99149a196bce0318679783dfeed52" + }, + { + "path": "apps/api/packages/api/src/middleware/client-rules.ts", + "sha256": "96fdd17e89e50bf252c64521ba553b182d5aacce7e8bf9116e673b627c5d825d" + }, + { + "path": "apps/api/packages/api/src/lib/credential-capabilities.ts", + "sha256": "96afee6a1f4019c3d5b8b881cfcd3cfd1e73e585442d279bdabb942a762b9342" + }, + { + "path": "apps/api/packages/api/src/composition/messaging-native.ts", + "sha256": "3626f2773cbb51aa7e06bc7a47176e05cd5b3b0c07a30623d29906decdcba81a" + }, + { + "path": "apps/api/docs/openapi.json", + "sha256": "0dae88e889900491fc79c592785142810490a92b71e988b4138a64fdaf78002a" + }, + { + "path": "docs/api-reference/openapi.json", + "sha256": "ea163e6639cb954cb6d2ba3bb7ebc06ea86eb185d1553e72ffc6ffe030cd55f8" + }, + { + "path": "docs/openapi.json", + "sha256": "03da2795efd1c1801d98f513a7585395d6522e8585ab4a8d2b5863f4e81d6443" + } + ] + }, "candidateSource": { "repository": "polymorfa/polymorfa", "commit": "c9902b86e4a1b52092029486cc16c1699456d539", @@ -154,9 +203,10 @@ "coexistingCandidate": { "commit": "0b7b739493b089c386fc21f2cf76f05f60de9151", "laterTestOnlyHead": "fce83265c1c99b75de3678191e5b07aac6de2cf0", - "sourceAccepted": false, + "sourceAccepted": true, "published": false, - "note": "Calls response-only candidate preserved in parent78f6; c990 does not contain it. Unified containing API acceptance and final specs remain required." + "acceptedContainingCommit": "77e9ce31aa5e0ea58815662ba0028f1927f690d5", + "note": "Calls response-only field merged to API dev by #798 (0f6e92ec); 77e9ce31 contains both producers." }, "acceptedPinsUnchanged": true, "coverageLedgerCredit": false diff --git a/docs/passkeys.md b/docs/passkeys.md index aede159a..11538bf0 100644 --- a/docs/passkeys.md +++ b/docs/passkeys.md @@ -22,8 +22,8 @@ The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16 units and optional user handles allow 86. Other opaque assertion fields share the body limit. The API validates encodings, decoded bounds and challenge binding. -This is source preparation. Matching API acceptance, package qualification and -publication are still required before using a released package. +The matching API source is accepted. Package qualification and publication are +still required before using a released package. | Operation | TypeScript | Go | Python | PHP | Rust | .NET | | ---------------------- | ------------------------ | -------------------------- | ------------------------- | ------------------------- | --------------------------- | ----------------------------- | diff --git a/packages/dotnet/README.md b/packages/dotnet/README.md index 6ebaca72..eac6ef32 100644 --- a/packages/dotnet/README.md +++ b/packages/dotnet/README.md @@ -1041,7 +1041,9 @@ Call-settings GET and PUT responses include the read-only `simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null. While `simultaneousCalls` is on, null means the exclusion has no end yet. When it is off, a future timestamp is 30 days after the latest period ended. -At that timestamp the earlier period no longer excludes a ban. Null while off +A ban is excluded when the setting was on at any time in the 30 days before it, +so until that timestamp a new ban is not refunded. At that timestamp the earlier +period no longer excludes a ban. Null while off means no recent period was reported. Neither null nor an elapsed timestamp guarantees coverage; all other Ban Insurance conditions still apply. @@ -1076,8 +1078,8 @@ The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16 units and optional user handles allow 86. Other opaque assertion fields share the body limit. The API validates encodings, decoded bounds and challenge binding. -This is source preparation. Matching API acceptance, package qualification and -publication are still required before using a released package. +The matching API source is accepted. Package qualification and publication are +still required before using a released package. ```csharp var ticket = await client.Passkeys.IssueChallengeAsync(session); diff --git a/packages/go/README.md b/packages/go/README.md index fd221cbb..d1bd3db0 100644 --- a/packages/go/README.md +++ b/packages/go/README.md @@ -842,7 +842,9 @@ Call-settings GET and PUT responses include the read-only `simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null. While `simultaneousCalls` is on, null means the exclusion has no end yet. When it is off, a future timestamp is 30 days after the latest period ended. -At that timestamp the earlier period no longer excludes a ban. Null while off +A ban is excluded when the setting was on at any time in the 30 days before it, +so until that timestamp a new ban is not refunded. At that timestamp the earlier +period no longer excludes a ban. Null while off means no recent period was reported. Neither null nor an elapsed timestamp guarantees coverage; all other Ban Insurance conditions still apply. @@ -876,8 +878,8 @@ The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16 units and optional user handles allow 86. Other opaque assertion fields share the body limit. The API validates encodings, decoded bounds and challenge binding. -This is source preparation. Matching API acceptance, package qualification and -publication are still required before using a released package. +The matching API source is accepted. Package qualification and publication are +still required before using a released package. ```go ticket, err := client.Passkeys().IssueChallenge(ctx, session, polymorfa.IssuePasskeyChallengeRequest{}) diff --git a/packages/php/README.md b/packages/php/README.md index ca38b90e..0ca1c39b 100644 --- a/packages/php/README.md +++ b/packages/php/README.md @@ -995,7 +995,9 @@ Call-settings GET and PUT responses include the read-only `simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null. While `simultaneousCalls` is on, null means the exclusion has no end yet. When it is off, a future timestamp is 30 days after the latest period ended. -At that timestamp the earlier period no longer excludes a ban. Null while off +A ban is excluded when the setting was on at any time in the 30 days before it, +so until that timestamp a new ban is not refunded. At that timestamp the earlier +period no longer excludes a ban. Null while off means no recent period was reported. Neither null nor an elapsed timestamp guarantees coverage; all other Ban Insurance conditions still apply. @@ -1027,8 +1029,8 @@ The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16 units and optional user handles allow 86. Other opaque assertion fields share the body limit. The API validates encodings, decoded bounds and challenge binding. -This is source preparation. Matching API acceptance, package qualification and -publication are still required before using a released package. +The matching API source is accepted. Package qualification and publication are +still required before using a released package. ```php $ticket = $client->passkeys->issueChallenge($session); diff --git a/packages/python/README.md b/packages/python/README.md index efc77e4c..ee75fb9a 100644 --- a/packages/python/README.md +++ b/packages/python/README.md @@ -862,7 +862,9 @@ Call-settings GET and PUT responses include the read-only `simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null. While `simultaneousCalls` is on, null means the exclusion has no end yet. When it is off, a future timestamp is 30 days after the latest period ended. -At that timestamp the earlier period no longer excludes a ban. Null while off +A ban is excluded when the setting was on at any time in the 30 days before it, +so until that timestamp a new ban is not refunded. At that timestamp the earlier +period no longer excludes a ban. Null while off means no recent period was reported. Neither null nor an elapsed timestamp guarantees coverage; all other Ban Insurance conditions still apply. @@ -894,8 +896,8 @@ The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16 units and optional user handles allow 86. Other opaque assertion fields share the body limit. The API validates encodings, decoded bounds and challenge binding. -This is source preparation. Matching API acceptance, package qualification and -publication are still required before using a released package. +The matching API source is accepted. Package qualification and publication are +still required before using a released package. ```python ticket = await client.passkeys.issue_challenge(session) diff --git a/packages/rust/README.md b/packages/rust/README.md index 58c5421b..d54a2916 100644 --- a/packages/rust/README.md +++ b/packages/rust/README.md @@ -936,7 +936,9 @@ Call-settings GET and PUT responses include the read-only `simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null. While `simultaneousCalls` is on, null means the exclusion has no end yet. When it is off, a future timestamp is 30 days after the latest period ended. -At that timestamp the earlier period no longer excludes a ban. Null while off +A ban is excluded when the setting was on at any time in the 30 days before it, +so until that timestamp a new ban is not refunded. At that timestamp the earlier +period no longer excludes a ban. Null while off means no recent period was reported. Neither null nor an elapsed timestamp guarantees coverage; all other Ban Insurance conditions still apply. @@ -971,8 +973,8 @@ The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16 units and optional user handles allow 86. Other opaque assertion fields share the body limit. The API validates encodings, decoded bounds and challenge binding. -This is source preparation. Matching API acceptance, package qualification and -publication are still required before using a released package. +The matching API source is accepted. Package qualification and publication are +still required before using a released package. ```rust,no_run # async fn supplied_steps(client: &polymorfa_sdk::MessagingClient, session: &str, supplied_assertion: &polymorfa_sdk::passkeys::SubmitPasskeyAssertion, supplied_confirmation: &polymorfa_sdk::passkeys::ConfirmPasskey) -> polymorfa_sdk::Result<()> { diff --git a/packages/typescript/README.md b/packages/typescript/README.md index 067a8423..78c67f7c 100644 --- a/packages/typescript/README.md +++ b/packages/typescript/README.md @@ -3465,7 +3465,9 @@ Call-settings GET and PUT responses include the read-only `simultaneousCallsCoverageExcludedUntil` field as an RFC3339 string or null. While `simultaneousCalls` is on, null means the exclusion has no end yet. When it is off, a future timestamp is 30 days after the latest period ended. -At that timestamp the earlier period no longer excludes a ban. Null while off +A ban is excluded when the setting was on at any time in the 30 days before it, +so until that timestamp a new ban is not refunded. At that timestamp the earlier +period no longer excludes a ban. Null while off means no recent period was reported. Neither null nor an elapsed timestamp guarantees coverage; all other Ban Insurance conditions still apply. @@ -3497,8 +3499,8 @@ The whole JSON body is limited to 1 MiB. Credential ID strings allow 1364 UTF-16 units and optional user handles allow 86. Other opaque assertion fields share the body limit. The API validates encodings, decoded bounds and challenge binding. -This is source preparation. Matching API acceptance, package qualification and -publication are still required before using a released package. +The matching API source is accepted. Package qualification and publication are +still required before using a released package. ```ts const ticket = await client.passkeys.issueChallenge(session); diff --git a/scripts/passkeys-candidate.test.mjs b/scripts/passkeys-candidate.test.mjs index 75a96a2b..d0674bdc 100644 --- a/scripts/passkeys-candidate.test.mjs +++ b/scripts/passkeys-candidate.test.mjs @@ -26,18 +26,29 @@ function jsonSchema(value) { return out; } -test("candidate five-operation scope, immutable fragment, independent producer status", () => { - assert.equal(m.sourceAccepted, false); +const ACCEPTED = "77e9ce31aa5e0ea58815662ba0028f1927f690d5"; +const sha = (p) => createHash("sha256").update(read(p)).digest("hex"); +test("five-operation scope bound to the accepted API dev source, unpublished", () => { + assert.equal(m.sourceAccepted, true); assert.equal(m.published, false); + assert.equal(m.acceptedSource.commit, ACCEPTED); + assert.equal(m.acceptedSource.branch, "dev"); assert.equal( m.candidateSource.commit, "c9902b86e4a1b52092029486cc16c1699456d539", ); + assert.deepEqual( + m.acceptedSource.files.map((x) => x.path), + m.candidateSource.files.map((x) => x.path), + ); assert.equal( m.coexistingCandidate.commit, "0b7b739493b089c386fc21f2cf76f05f60de9151", ); - assert.equal(m.coexistingCandidate.sourceAccepted, false); + assert.equal(m.coexistingCandidate.sourceAccepted, true); + assert.equal(m.coexistingCandidate.acceptedContainingCommit, ACCEPTED); + assert.equal(f.candidateSource, ACCEPTED); + assert.equal(f.sourceAccepted, true); assert.equal( m.fragment.sha256, createHash("sha256").update(read(m.fragment.path)).digest("hex"), @@ -84,3 +95,19 @@ test("synthetic native wire values match candidate public schemas, not a live ce .content["application/json"].schema; check(confirmation, f.confirm); }); +test("call-settings exclusion end bound to the same accepted source", () => { + const c = JSON.parse(read("contracts/calls-coverage-end.candidate.json")); + assert.equal(c.sourceAccepted, true); + assert.equal(c.published, false); + assert.equal(c.acceptedSource.commit, ACCEPTED); + assert.equal(c.acceptedContainingCommit, ACCEPTED); + assert.equal(c.fragmentSha256, sha(c.fragmentPath)); + assert.equal(c.fixtureSha256, sha(c.fixturePath)); + const fixture = JSON.parse(read(c.fixturePath)); + assert.equal(fixture.candidateSource, ACCEPTED); + const field = JSON.parse(read(c.fragmentPath)).schemas + .PlatformAccessCallSettings.properties[c.field]; + assert.equal(field.nullable, true); + assert.equal(field.format, "date-time"); + assert.match(field.description, /at any time in the 30 days before it/); +}); From 52c1b506aad9459004b65ec541f7c161ab3dac68 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 01:58:35 +0200 Subject: [PATCH 07/21] test(rust): scope dead-code allowance for passkey test support The passkeys integration target uses only part of the shared tests/support module, so cargo clippy --all-targets --all-features -D warnings rejected the unused helpers. Every other target that includes support carries the same allowance; this one now states why. --- packages/rust/tests/passkeys.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/rust/tests/passkeys.rs b/packages/rust/tests/passkeys.rs index 808566f1..378ae11a 100644 --- a/packages/rust/tests/passkeys.rs +++ b/packages/rust/tests/passkeys.rs @@ -1,3 +1,7 @@ +#[allow( + dead_code, + reason = "shared integration-test support; this target uses only part of it" +)] mod support; use polymorfa_sdk::{passkeys::*, RequestOptions}; use serde_json::{json, Value}; From 06e4ff557bf8550d22a2b542f46164ab736e8165 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 01:58:35 +0200 Subject: [PATCH 08/21] test(dotnet): call the two-argument Equal explicitly in coverage-end tests Program.cs declares a top-level Equal(T, T, string), which shadows the using-static ResourceTests.Equal in this file (CS8801/CS7036), so the test project did not compile. Qualify the calls; assertions are unchanged. --- .../CallSettingsCoverageEndTests.cs | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallSettingsCoverageEndTests.cs b/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallSettingsCoverageEndTests.cs index 7a08d41a..a23516ae 100644 --- a/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallSettingsCoverageEndTests.cs +++ b/packages/dotnet/tests/Polymorfa.Sdk.Tests/CallSettingsCoverageEndTests.cs @@ -8,8 +8,8 @@ public static async Task RunAsync() { var old = new SessionCallSettings(true, false, "clients", null, false, false, 2, null, false); var (enabled, conference, route, trunk, claim, hosted, revision, updated, simultaneous) = old; - Equal(revision, 2L); - Equal(old.SimultaneousCallsCoverageExcludedUntil, null); + ResourceTests.Equal(revision, 2L); + ResourceTests.Equal(old.SimultaneousCallsCoverageExcludedUntil, null); foreach (var item in new (bool On, string? Until)[] { (false, "2026-11-22T12:34:56.789Z"), (true, null), (false, null) }) foreach (var method in new[] { "GET", "PUT" }) { @@ -19,10 +19,10 @@ public static async Task RunAsync() await Messaging(method, "/platform/sessions/s/call-settings", method == "GET" ? null : "{\"callsEnabled\":false}", response, async c => { var value = method == "GET" ? await c.Calls.RetrieveCallSettingsAsync("s") : await c.Calls.UpdateCallSettingsAsync("s", new(CallsEnabled: false)); - Equal(value.Data.Data.SimultaneousCallsCoverageExcludedUntil, until); - Equal(value.Data.Data.Revision, 2L); + ResourceTests.Equal(value.Data.Data.SimultaneousCallsCoverageExcludedUntil, until); + ResourceTests.Equal(value.Data.Data.Revision, 2L); using var roundtrip = JsonDocument.Parse(JsonSerializer.Serialize(value.Data.Data, new JsonSerializerOptions { PropertyNamingPolicy = JsonNamingPolicy.CamelCase })); - Equal(roundtrip.RootElement.GetProperty("simultaneousCallsCoverageExcludedUntil").GetString(), until); + ResourceTests.Equal(roundtrip.RootElement.GetProperty("simultaneousCallsCoverageExcludedUntil").GetString(), until); }); } True(typeof(UpdateCallSettingsRequest).GetProperty("SimultaneousCallsCoverageExcludedUntil") is null); From fdbeb5c874621f138b67af3c83428148754bd9bb Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 02:00:48 +0200 Subject: [PATCH 09/21] test(python): bind passkey test loop variables and assert concrete errors ruff (locked 0.17.0) reported B023 for handlers that captured loop variables late and B017 for pytest.raises(Exception). Handlers now bind their loop values, and each failure path asserts the error the SDK actually raises: ServerError, ConnectionError, ValidationError, ConfigurationError, or PolymorfaError for the per-status refusals. Re-sort __all__ (RUF022) after the passkey exports were added. --- packages/python/src/polymorfa/__init__.py | 22 +++++++++++----------- packages/python/tests/test_passkeys.py | 21 +++++++++++---------- 2 files changed, 22 insertions(+), 21 deletions(-) diff --git a/packages/python/src/polymorfa/__init__.py b/packages/python/src/polymorfa/__init__.py index 906f15ff..3f2096df 100644 --- a/packages/python/src/polymorfa/__init__.py +++ b/packages/python/src/polymorfa/__init__.py @@ -254,17 +254,6 @@ ) __all__ = [ - "ConfirmPasskey", - "IssuePasskeyChallenge", - "PasskeyAssertion", - "PasskeyAssertionResponse", - "PasskeyAssertionResult", - "PasskeyConfirmation", - "Passkeys", - "PasskeyStatus", - "PasskeyTicket", - "SubmitPasskeyAssertion", - "UserVerification", "API_VERSION", "CALL_REACTION_EMOJI", "CUSTOMER_OWNED_API_VERSION", @@ -349,6 +338,7 @@ "CampaignTrackedLinkReport", "CancelledError", "ConfigurationError", + "ConfirmPasskey", "ConflictError", "ConnectionError", "CreateAudienceFromCampaignRequest", @@ -373,6 +363,7 @@ "HistorySync", "ImportConflictSample", "ImportInvalidSample", + "IssuePasskeyChallenge", "LifecycleCandidate", "LifecycleEvent", "LifecycleReady", @@ -393,6 +384,13 @@ "NotFoundError", "OutgoingVideoFrame", "PageResult", + "PasskeyAssertion", + "PasskeyAssertionResponse", + "PasskeyAssertionResult", + "PasskeyConfirmation", + "PasskeyStatus", + "PasskeyTicket", + "Passkeys", "PaymentRequiredError", "PlatformCampaignRecipientExportParams", "PlatformCampaignTestSendRequest", @@ -439,6 +437,7 @@ "StopConfirmationSettings", "StopConfirmationSummary", "StreamedEvent", + "SubmitPasskeyAssertion", "TimeoutError", "TrickleCandidate", "TypedWebhookEvent", @@ -446,6 +445,7 @@ "UpdateStopConfirmationSettingsInput", "UploadGrant", "UploadLocation", + "UserVerification", "ValidationError", "VideoFrame", "VideoTrack", diff --git a/packages/python/tests/test_passkeys.py b/packages/python/tests/test_passkeys.py index 3583287b..29fdaba1 100644 --- a/packages/python/tests/test_passkeys.py +++ b/packages/python/tests/test_passkeys.py @@ -5,7 +5,7 @@ import httpx import pytest -from polymorfa import AsyncMessagingClient, Credential, RequestOptions +from polymorfa import AsyncMessagingClient, Credential, RequestOptions, errors F = json.loads( (Path(__file__).parents[3] / "contracts/fixtures/passkeys.candidate.json").read_text() @@ -26,7 +26,7 @@ async def _wire(): ] for i, (method, path, body, reply) in enumerate(expected): - def handler(request): + def handler(request, method=method, path=path, body=body, reply=reply): assert request.method == method assert request.url.raw_path.decode() == "/messaging/desk%2F1/passkey" + path assert request.headers["authorization"].startswith("Bearer pmfa_") @@ -95,7 +95,7 @@ async def _boundaries(): async with AsyncMessagingClient( key, http_transport=httpx.MockTransport( - lambda r: httpx.Response( + lambda r, status=status: httpx.Response( 200, json={"success": True, "data": {"status": status, "skipHandoffUX": False}} ) ), @@ -107,7 +107,7 @@ async def _boundaries(): for status in F["errors"]: count = 0 - def refuse(request): + def refuse(request, status=status): nonlocal count count += 1 return httpx.Response( @@ -115,7 +115,7 @@ def refuse(request): ) async with AsyncMessagingClient(key, http_transport=httpx.MockTransport(refuse)) as c: - with pytest.raises(Exception) as e: + with pytest.raises(errors.PolymorfaError) as e: await c.passkeys.confirm( "s", F["confirm"], @@ -126,7 +126,7 @@ def refuse(request): for kind in ("issue", "assertion", "confirm"): count = 0 - def broken(request): + def broken(request, mode=mode): nonlocal count count += 1 if mode == "network": @@ -136,7 +136,8 @@ def broken(request): ) async with AsyncMessagingClient(key, http_transport=httpx.MockTransport(broken)) as c: - with pytest.raises(Exception): + expected = errors.ConnectionError if mode == "network" else errors.ServerError + with pytest.raises(expected): o = RequestOptions(max_network_retries=3, idempotency_key="caller") if kind == "issue": await c.passkeys.issue_challenge("s", F["issue"], options=o) @@ -157,7 +158,7 @@ def capture(request): assert bodies == [{}, {"ticketId": "ticket"}] bad = json.loads(json.dumps(F["assertion"])) bad["assertion"]["id"] = "x" * 1365 - with pytest.raises(Exception): + with pytest.raises(errors.ValidationError): await c.passkeys.submit_assertion("s", bad) bad["assertion"]["id"] = "x" * 1364 bad["assertion"]["rawIdB64Url"] = "x" * 1364 @@ -165,7 +166,7 @@ def capture(request): await c.passkeys.submit_assertion("s", bad) assert bodies[-1] == bad bad["assertion"]["response"]["signatureB64"] = "x" * 1048576 - with pytest.raises(Exception): + with pytest.raises(errors.ValidationError): await c.passkeys.submit_assertion("s", bad) assert len(bodies) == 3 assert not hasattr(c.passkeys, "verify") and not hasattr( @@ -175,6 +176,6 @@ def capture(request): Credential("client_token", "pmfa_ct_Zml4dHVyZQ"), http_transport=httpx.MockTransport(capture), ) as c: - with pytest.raises(Exception): + with pytest.raises(errors.ConfigurationError): await c.passkeys.get_confirmation("s") assert len(bodies) == 3 From 88e0282f1a8c8d6b6797a63ec50a3487ca5f365b Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 02:00:48 +0200 Subject: [PATCH 10/21] test(python): bind loop variables in the coverage-end handler Fixes ruff B023 in the call-settings exclusion-end test. --- packages/python/tests/test_calls_coverage_end.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/python/tests/test_calls_coverage_end.py b/packages/python/tests/test_calls_coverage_end.py index 4ff39207..11f9b65c 100644 --- a/packages/python/tests/test_calls_coverage_end.py +++ b/packages/python/tests/test_calls_coverage_end.py @@ -25,7 +25,7 @@ async def _coverage_end(): "simultaneousCallsCoverageExcludedUntil": case["until"], } - def handler(request): + def handler(request, method=method, settings=settings): assert request.method == method assert request.url.path == "/platform/sessions/support/call-settings" if method == "PUT": From c810362638f5256ca6f68efe18161c3578aef9db Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 02:03:47 +0200 Subject: [PATCH 11/21] docs: bind unchanged snippets to the rebound counterpart source sourceCommit 88e0282f. All 31 programs per language checked with the real TypeScript, Go, Python, .NET and Rust checkers, each with the negative test. PHP was not checked locally (no PHP toolchain). --- contracts/doc-snippets.json | 4 ++-- scripts/doc-snippets/recipes.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/doc-snippets.json b/contracts/doc-snippets.json index d07c54e8..4b6fc689 100644 --- a/contracts/doc-snippets.json +++ b/contracts/doc-snippets.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "sourceCommit": "ce6c41d069aa0f2b6e3f44790b1dabd0fe208112", - "sourceFilesSha256": "e24d3c15db553ac3c25692932c8dcb45f369dfc0642071fd110fdc78d23fb6bc", + "sourceCommit": "88e0282f1a8c8d6b6797a63ec50a3487ca5f365b", + "sourceFilesSha256": "cd2ab087d832cafcc861eac04c2c41b1b9e6cb8282780ea3865c380d17625769", "apiVersion": "2026-09-22", "apiSourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99", "specs": { diff --git a/scripts/doc-snippets/recipes.mjs b/scripts/doc-snippets/recipes.mjs index 0d7a580e..884560db 100644 --- a/scripts/doc-snippets/recipes.mjs +++ b/scripts/doc-snippets/recipes.mjs @@ -1,7 +1,7 @@ // Requests come from the pinned OpenAPI and existing native wire fixtures. // Calls are handwritten in the language modules. Never derive arguments from // contracts/coverage.json's display-only method names. -export const sourceCommit = "ce6c41d069aa0f2b6e3f44790b1dabd0fe208112"; +export const sourceCommit = "88e0282f1a8c8d6b6797a63ec50a3487ca5f365b"; export const apiVersion = "2026-09-22"; export const languages = [ "typescript", From fbdde786f2c7c25c5e9376fe71ef163498f35fbd Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 02:19:47 +0200 Subject: [PATCH 12/21] fix(php): validate passkey request bodies as received arrays PHPStan level 8 flagged the runtime checks as always true or false against the public array-shape phpdoc (22 errors). Callers can pass any array at runtime, so the checks move into private helpers typed array, as CallPolicy and Voip already do. Public shapes and the validated rules are unchanged. --- packages/php/src/Resources/Passkeys.php | 29 ++++++++++++++++++++----- 1 file changed, 24 insertions(+), 5 deletions(-) diff --git a/packages/php/src/Resources/Passkeys.php b/packages/php/src/Resources/Passkeys.php index e04321fc..a571298e 100644 --- a/packages/php/src/Resources/Passkeys.php +++ b/packages/php/src/Resources/Passkeys.php @@ -34,6 +34,26 @@ private static function budget(array $body): void { if (strlen(json_encode((object)$body, JSON_THROW_ON_ERROR)) > 1048576) { throw new ValidationException('Passkey request exceeds 1 MiB.'); } } + /** + * Callers can pass any array at runtime, so validate the received shape, not the phpdoc. + * @param array $body + */ + private static function checkIssue(array $body): void + { + if ((array_key_exists('challengeB64Url', $body) && !self::text($body['challengeB64Url'])) || (array_key_exists('ttlSeconds', $body) && !is_int($body['ttlSeconds'])) || (array_key_exists('timeoutMs', $body) && (!is_int($body['timeoutMs']) || $body['timeoutMs'] <= 0)) || (array_key_exists('userVerification', $body) && !in_array($body['userVerification'], ['required','preferred','discouraged'], true))) { throw new ValidationException('Invalid challenge options.'); } + } + /** @param array $body */ + private static function checkSubmit(array $body): void + { + $a = $body['assertion'] ?? null; $r = is_array($a) ? ($a['response'] ?? null) : null; + if (!self::text($body['ticketId'] ?? null) || !is_array($a) || !self::text($a['id'] ?? null,1364) || !self::text($a['rawIdB64Url'] ?? null,1364) || !is_array($r) || !self::text($r['authenticatorDataB64'] ?? null) || !self::text($r['clientDataJSONB64'] ?? null) || !self::text($r['signatureB64'] ?? null)) { throw new ValidationException('Provide the ticket and bounded assertion.'); } + if (array_key_exists('userHandleB64',$r) && (!is_string($r['userHandleB64']) || ($r['userHandleB64'] !== '' && !self::text($r['userHandleB64'],86)))) { throw new ValidationException('userHandleB64 exceeds 86 characters.'); } + } + /** @param array $body */ + private static function checkConfirm(array $body): void + { + if (!self::text($body['ticketId'] ?? null) || (array_key_exists('code',$body) && (!is_string($body['code']) || !preg_match('/^[A-Z0-9]{4}-[A-Z0-9]{4}$/D',$body['code'])))) { throw new ValidationException('Provide the ticket and optional confirmation code.'); } + } /** * userVerification and timeoutMs are deprecated ignored inputs; the runner owns them. * @param Issue $body @@ -42,7 +62,7 @@ private static function budget(array $body): void public function issueChallenge(string $session, array $body = [], ?RequestOptions $options = null): ApiResponse { $path = $this->path($session); - if ((array_key_exists('challengeB64Url', $body) && !self::text($body['challengeB64Url'])) || (array_key_exists('ttlSeconds', $body) && !is_int($body['ttlSeconds'])) || (array_key_exists('timeoutMs', $body) && (!is_int($body['timeoutMs']) || $body['timeoutMs'] <= 0)) || (array_key_exists('userVerification', $body) && !in_array($body['userVerification'], ['required','preferred','discouraged'], true))) { throw new ValidationException('Invalid challenge options.'); } + self::checkIssue($body); self::budget($body); /** @var ApiResponse */ return $this->request('POST', $path.'/challenge', (object)$body, options:($options ?? new RequestOptions())->withoutRetries()); @@ -59,9 +79,8 @@ public function getChallenge(string $session, string $ticketId, ?RequestOptions */ public function submitAssertion(string $session, array $body, ?RequestOptions $options = null): ApiResponse { - $path = $this->path($session); $a = $body['assertion'] ?? null; $r = is_array($a) ? ($a['response'] ?? null) : null; - if (!self::text($body['ticketId'] ?? null) || !is_array($a) || !self::text($a['id'] ?? null,1364) || !self::text($a['rawIdB64Url'] ?? null,1364) || !is_array($r) || !self::text($r['authenticatorDataB64'] ?? null) || !self::text($r['clientDataJSONB64'] ?? null) || !self::text($r['signatureB64'] ?? null)) { throw new ValidationException('Provide the ticket and bounded assertion.'); } - if (array_key_exists('userHandleB64',$r) && (!is_string($r['userHandleB64']) || ($r['userHandleB64'] !== '' && !self::text($r['userHandleB64'],86)))) { throw new ValidationException('userHandleB64 exceeds 86 characters.'); } + $path = $this->path($session); + self::checkSubmit($body); self::budget($body); /** @var ApiResponse */ return $this->request('POST', $path.'/assertion', $body, options:($options ?? new RequestOptions())->withoutRetries()); @@ -79,7 +98,7 @@ public function getConfirmation(string $session, ?RequestOptions $options = null public function confirm(string $session, array $body, ?RequestOptions $options = null): ApiResponse { $path = $this->path($session); - if (!self::text($body['ticketId'] ?? null) || (array_key_exists('code',$body) && (!is_string($body['code']) || !preg_match('/^[A-Z0-9]{4}-[A-Z0-9]{4}$/D',$body['code'])))) { throw new ValidationException('Provide the ticket and optional confirmation code.'); } + self::checkConfirm($body); self::budget($body); /** @var ApiResponse */ return $this->request('POST', $path.'/confirmation', $body, options:($options ?? new RequestOptions())->withoutRetries()); From b26ab24bec90fd8ff52b951089ffc437e94fe809 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 02:22:22 +0200 Subject: [PATCH 13/21] docs: bind unchanged snippets to the PHP validation fix source sourceCommit fbdde786. TypeScript, Go, Python, .NET and Rust exported programs re-checked with negative tests; PHP is checked in CI. --- contracts/doc-snippets.json | 4 ++-- scripts/doc-snippets/recipes.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/doc-snippets.json b/contracts/doc-snippets.json index 4b6fc689..21b65a02 100644 --- a/contracts/doc-snippets.json +++ b/contracts/doc-snippets.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "sourceCommit": "88e0282f1a8c8d6b6797a63ec50a3487ca5f365b", - "sourceFilesSha256": "cd2ab087d832cafcc861eac04c2c41b1b9e6cb8282780ea3865c380d17625769", + "sourceCommit": "fbdde786f2c7c25c5e9376fe71ef163498f35fbd", + "sourceFilesSha256": "09d6803fe929a78cb20dc8b404aac868ddef67e3f1f2fb9f2a54fe294283cb61", "apiVersion": "2026-09-22", "apiSourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99", "specs": { diff --git a/scripts/doc-snippets/recipes.mjs b/scripts/doc-snippets/recipes.mjs index 884560db..b0b3c2fe 100644 --- a/scripts/doc-snippets/recipes.mjs +++ b/scripts/doc-snippets/recipes.mjs @@ -1,7 +1,7 @@ // Requests come from the pinned OpenAPI and existing native wire fixtures. // Calls are handwritten in the language modules. Never derive arguments from // contracts/coverage.json's display-only method names. -export const sourceCommit = "88e0282f1a8c8d6b6797a63ec50a3487ca5f365b"; +export const sourceCommit = "fbdde786f2c7c25c5e9376fe71ef163498f35fbd"; export const apiVersion = "2026-09-22"; export const languages = [ "typescript", From 0097546ce537a32223dc9eb462d04300c2caa9cf Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 02:33:26 +0200 Subject: [PATCH 14/21] fix(go): send passkey writes as single submissions noRetry disabled SDK retries, but a caller Idempotency-Key still let net/http rewind the request body and resend a passkey write on a reused connection. The three writes now use singleSubmission, like the other single-use Go writes, which clears GetBody. A new transport test fails without this change. --- packages/go/passkeys.go | 6 ++--- packages/go/passkeys_test.go | 45 ++++++++++++++++++++++++++++++++++++ 2 files changed, 48 insertions(+), 3 deletions(-) diff --git a/packages/go/passkeys.go b/packages/go/passkeys.go index 699da2a6..23b411b9 100644 --- a/packages/go/passkeys.go +++ b/packages/go/passkeys.go @@ -100,7 +100,7 @@ func (r *Passkeys) IssueChallenge(ctx context.Context, s string, body IssuePassk if err = passkeyBudget(body); err != nil { return Response[Envelope[PasskeyTicket]]{}, err } - return request[Envelope[PasskeyTicket]](ctx, r.t, "POST", p+"/challenge", nil, body, noRetry(options(o))) + return request[Envelope[PasskeyTicket]](ctx, r.t, "POST", p+"/challenge", nil, singleSubmission{body}, noRetry(options(o))) } func (r *Passkeys) GetChallenge(ctx context.Context, s, ticket string, o ...RequestOptions) (Response[Envelope[PasskeyTicket]], error) { if err := serverOnly(r.t); err != nil { @@ -131,7 +131,7 @@ func (r *Passkeys) SubmitAssertion(ctx context.Context, s string, body SubmitPas if err = passkeyBudget(body); err != nil { return Response[Envelope[PasskeyAssertionResult]]{}, err } - return request[Envelope[PasskeyAssertionResult]](ctx, r.t, "POST", p+"/assertion", nil, body, noRetry(options(o))) + return request[Envelope[PasskeyAssertionResult]](ctx, r.t, "POST", p+"/assertion", nil, singleSubmission{body}, noRetry(options(o))) } func (r *Passkeys) GetConfirmation(ctx context.Context, s string, o ...RequestOptions) (Response[Envelope[PasskeyConfirmation]], error) { if err := serverOnly(r.t); err != nil { @@ -163,5 +163,5 @@ func (r *Passkeys) Confirm(ctx context.Context, s string, body ConfirmPasskeyReq if err = passkeyBudget(body); err != nil { return Response[Envelope[PasskeyConfirmation]]{}, err } - return request[Envelope[PasskeyConfirmation]](ctx, r.t, "POST", p+"/confirmation", nil, body, noRetry(options(o))) + return request[Envelope[PasskeyConfirmation]](ctx, r.t, "POST", p+"/confirmation", nil, singleSubmission{body}, noRetry(options(o))) } diff --git a/packages/go/passkeys_test.go b/packages/go/passkeys_test.go index 8fb64ab0..cc9db3cd 100644 --- a/packages/go/passkeys_test.go +++ b/packages/go/passkeys_test.go @@ -4,6 +4,7 @@ import ( "context" "encoding/json" "errors" + "io" "net/http" "net/http/httptest" "os" @@ -234,3 +235,47 @@ func TestPasskeyMaximumWire(t *testing.T) { t.Fatal(e) } } + +// A caller idempotency key must not let net/http rewind and resend a +// single-use passkey write on a reused connection. +func TestPasskeyWritesOfferNoBodyRewind(t *testing.T) { + handle := "AQID" + assertion := SubmitPasskeyAssertionRequest{TicketID: "t", Assertion: PasskeyAssertion{ID: "id", RawIDB64URL: "id", Response: PasskeyAssertionResponse{AuthenticatorDataB64: "AQID", ClientDataJSONB64: "e30", SignatureB64: "AQID", UserHandleB64: &handle}}} + rewindable := map[string]bool{} + config := orgConfig("https://api.fixture.invalid") + config.HTTPClient = &http.Client{Transport: mediaRoundTripper(func(req *http.Request) (*http.Response, error) { + if req.Method == "POST" { + rewindable[req.URL.Path] = req.GetBody != nil + if req.Header.Get("Idempotency-Key") != "caller" { + t.Error("caller idempotency key not sent") + } + } + data := `{"success":true,"data":{"status":"none"}}` + if strings.HasSuffix(req.URL.Path, "/assertion") { + data = `{"success":true,"data":{"ok":true,"ticket":"t","session":"s"}}` + } + return &http.Response{StatusCode: 200, Header: http.Header{"Content-Type": []string{"application/json"}}, Body: io.NopCloser(strings.NewReader(data)), Request: req}, nil + })} + c, err := NewMessagingClient(config) + if err != nil { + t.Fatal(err) + } + ctx, o := context.Background(), RequestOptions{IdempotencyKey: "caller"} + if _, e := c.Passkeys().IssueChallenge(ctx, "s", IssuePasskeyChallengeRequest{}, o); e != nil { + t.Fatal(e) + } + if _, e := c.Passkeys().SubmitAssertion(ctx, "s", assertion, o); e != nil { + t.Fatal(e) + } + if _, e := c.Passkeys().Confirm(ctx, "s", ConfirmPasskeyRequest{TicketID: "t"}, o); e != nil { + t.Fatal(e) + } + if len(rewindable) != 3 { + t.Fatalf("expected three passkey writes, saw %v", rewindable) + } + for path, rewind := range rewindable { + if rewind { + t.Errorf("%s offers a body rewind", path) + } + } +} From 7227080dfa99bf3ced43b7944b9a55cac16ec2a9 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 02:33:26 +0200 Subject: [PATCH 15/21] fix: refuse unknown and response-only call-setting keys before sending The API request schema allows only the nine writable keys (additionalProperties false). TypeScript, Python and PHP forwarded the caller body unchanged, so simultaneousCallsCoverageExcludedUntil or an unknown key next to callsEnabled reached the network. All three now refuse such keys locally. The tests pair the response-only field with callsEnabled and assert that nothing is sent. Go, Rust and .NET serialize fixed request fields and cannot send extra keys. --- CHANGELOG.md | 2 ++ packages/php/src/Resources/Voip.php | 4 +++ packages/php/tests/voip.php | 4 +++ packages/python/src/polymorfa/messaging.py | 17 ++++++++++ .../python/tests/test_calls_coverage_end.py | 22 +++++++++++++ packages/typescript/src/messaging/voip.ts | 20 +++++++++++ .../test/calls-coverage-end.test.ts | 33 ++++++++++++++++++- 7 files changed, 101 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54af397f..cef7379f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ - Add the read-only nullable call-settings exclusion-end response field across all six SDKs. Rust response struct literals require the new optional field; C# positional construction remains compatible. Bound to accepted API source `77e9ce31`, which also clarifies when a ban is excluded; unreleased. +- TypeScript, Python and PHP call-settings updates now refuse keys the API does not accept, including the response-only `simultaneousCallsCoverageExcludedUntil`, before sending. Go, Rust and .NET requests already serialize only writable fields. + - All six server SDKs expose optional automatic-pause `reason`/`code` and optional recipient-skip `messageIndex`/`followUpId` from accepted API source `9bb1e2ed`. Future reason/code strings and raw envelope fields are retained; diff --git a/packages/php/src/Resources/Voip.php b/packages/php/src/Resources/Voip.php index 3c16f138..d380c95f 100644 --- a/packages/php/src/Resources/Voip.php +++ b/packages/php/src/Resources/Voip.php @@ -293,6 +293,10 @@ private static function validateReport(array $body): void /** @param array $body */ private static function validateSettings(array $body): void { + // The API accepts only these keys; simultaneousCallsCoverageExcludedUntil is response-only. + if (array_diff(array_keys($body), ['callsEnabled','conferenceMode','inboundRoute','sipTrunkId','sipClaim','hostCloudApiCalls','simultaneousCalls','acknowledgeRisk','expectedRevision']) !== []) { + throw new ValidationException('Unknown or read-only call setting.'); + } foreach (['callsEnabled','conferenceMode','sipClaim','hostCloudApiCalls','simultaneousCalls','acknowledgeRisk'] as $name) { if (array_key_exists($name, $body) && !is_bool($body[$name])) { throw new ValidationException('Call switches must be booleans.'); diff --git a/packages/php/tests/voip.php b/packages/php/tests/voip.php index 7cf1f21b..0a394594 100644 --- a/packages/php/tests/voip.php +++ b/packages/php/tests/voip.php @@ -76,3 +76,7 @@ $client = new Polymorfa\MessagingClient($credential, http: mocked([], $history)); raises(fn () => $client->voip->updateCallSettings('support', ['simultaneousCallsCoverageExcludedUntil' => null]), Polymorfa\ValidationException::class); check($history === [], 'Response-only field is not a call setting'); +foreach ([['simultaneousCallsCoverageExcludedUntil' => null], ['simultaneousCallsCoverageExcludedUntil' => '2026-11-22T12:34:56.789Z'], ['unknownSetting' => true]] as $extra) { + raises(fn () => $client->voip->updateCallSettings('support', ['callsEnabled' => false] + $extra), Polymorfa\ValidationException::class); +} +check($history === [], 'Response-only and unknown keys are refused beside a setting'); diff --git a/packages/python/src/polymorfa/messaging.py b/packages/python/src/polymorfa/messaging.py index 7822ecf0..996e2d7f 100644 --- a/packages/python/src/polymorfa/messaging.py +++ b/packages/python/src/polymorfa/messaging.py @@ -108,6 +108,20 @@ T = TypeVar("T") O = RequestOptions() +# Writable call-setting keys; the API rejects any other key. +_CALL_SETTING_KEYS = frozenset( + ( + "callsEnabled", + "conferenceMode", + "inboundRoute", + "sipTrunkId", + "sipClaim", + "hostCloudApiCalls", + "simultaneousCalls", + "acknowledgeRisk", + "expectedRevision", + ) +) def segment(value: str) -> str: @@ -934,6 +948,9 @@ async def update_call_settings( or "includeSelfAudio" in body ): raise ValidationError("Provide a supported call setting.") + # The API accepts only these keys; simultaneousCallsCoverageExcludedUntil is response-only. + if not set(body) <= _CALL_SETTING_KEYS: + raise ValidationError("Unknown or read-only call setting.") for key in ( "callsEnabled", "conferenceMode", diff --git a/packages/python/tests/test_calls_coverage_end.py b/packages/python/tests/test_calls_coverage_end.py index 11f9b65c..77fa20b0 100644 --- a/packages/python/tests/test_calls_coverage_end.py +++ b/packages/python/tests/test_calls_coverage_end.py @@ -68,3 +68,25 @@ async def _older_response(): "simultaneousCallsCoverageExcludedUntil" not in (await client.voip.retrieve_call_settings("support")).data["data"] ) + + +def test_call_settings_reject_response_only_and_unknown_keys(): + asyncio.run(_reject_extra_keys()) + + +async def _reject_extra_keys(): + sent = [] + async with AsyncMessagingClient( + credential=Credential("organization_api_key", "pmfa_AAECAwQFBgcICQoLDA0ODxAREhM73f-k"), + http_transport=httpx.MockTransport( + lambda request: sent.append(request) or httpx.Response(200, json={}) + ), + ) as client: + for extra in ( + {"simultaneousCallsCoverageExcludedUntil": None}, + {"simultaneousCallsCoverageExcludedUntil": "2026-11-22T12:34:56.789Z"}, + {"unknownSetting": True}, + ): + with pytest.raises(ValidationError): + await client.voip.update_call_settings("support", {"callsEnabled": False, **extra}) + assert sent == [] diff --git a/packages/typescript/src/messaging/voip.ts b/packages/typescript/src/messaging/voip.ts index 670f674f..e9f1f820 100644 --- a/packages/typescript/src/messaging/voip.ts +++ b/packages/typescript/src/messaging/voip.ts @@ -29,6 +29,18 @@ import type { const PARTICIPANT_PATTERN = /^[A-Za-z0-9._:@-]{1,128}$/; const CONNECTION_ID_PATTERN = /^[A-Za-z0-9_-]{8,64}$/; +// Writable call-setting keys; the API rejects any other key. +const CALL_SETTING_KEYS: ReadonlySet = new Set([ + "callsEnabled", + "conferenceMode", + "inboundRoute", + "sipTrunkId", + "sipClaim", + "hostCloudApiCalls", + "simultaneousCalls", + "acknowledgeRisk", + "expectedRevision", +]); const REPORT_SDK_PATTERN = /^[a-z0-9@/._-]{1,32}$/; const REPORT_VERSION_PATTERN = /^[0-9]{1,6}\.[0-9]{1,6}\.[0-9]{1,6}(?:[-+][0-9A-Za-z.+-]{1,24})?$/; @@ -422,6 +434,14 @@ export class VoipResource { "includeSelfAudio was replaced by conferenceMode.", ); } + // The API accepts only these keys; simultaneousCallsCoverageExcludedUntil is response-only. + for (const key of Object.keys(body)) { + if (!CALL_SETTING_KEYS.has(key)) { + throw new PolymorfaValidationError( + `${key} is not a writable call setting.`, + ); + } + } const { callsEnabled, conferenceMode, diff --git a/packages/typescript/test/calls-coverage-end.test.ts b/packages/typescript/test/calls-coverage-end.test.ts index 9c61d0fd..4fb42fc0 100644 --- a/packages/typescript/test/calls-coverage-end.test.ts +++ b/packages/typescript/test/calls-coverage-end.test.ts @@ -1,6 +1,10 @@ import { readFileSync } from "node:fs"; import { describe, expect, it } from "vitest"; -import { MessagingClient } from "../src/index.js"; +import { + MessagingClient, + PolymorfaValidationError, + type UpdateSessionCallSettingsRequest, +} from "../src/index.js"; import { ORGANIZATION_API_KEY } from "./support/credentials.js"; const fixture = JSON.parse( @@ -52,6 +56,33 @@ describe("call settings coverage end is response-only", () => { expect(result.data.data).toEqual(settings); }); } + it("rejects the response-only field and unknown keys next to a setting", async () => { + let sent = 0; + const client = new MessagingClient({ + credential: { type: "apiKey", value: ORGANIZATION_API_KEY }, + maxNetworkRetries: 0, + fetch: async () => { + sent += 1; + return new Response("{}", { + headers: { "content-type": "application/json" }, + }); + }, + }); + for (const extra of [ + { simultaneousCallsCoverageExcludedUntil: null }, + { simultaneousCallsCoverageExcludedUntil: "2026-11-22T12:34:56.789Z" }, + { unknownSetting: true }, + ]) { + const body = { + callsEnabled: false, + ...extra, + } as unknown as UpdateSessionCallSettingsRequest; + expect(() => client.voip.updateCallSettings("support", body)).toThrow( + PolymorfaValidationError, + ); + } + expect(sent).toBe(0); + }); it("preserves older-server absence without converting it to a coverage decision", async () => { const client = new MessagingClient({ credential: { type: "apiKey", value: ORGANIZATION_API_KEY }, From 320c7cea30bd56f3ea31da096e5acd734fa7ec20 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sun, 25 Oct 2026 02:35:59 +0200 Subject: [PATCH 16/21] docs: bind unchanged snippets to the call-settings and Go write fixes sourceCommit 7227080d. TypeScript, Go, Python, .NET and Rust exported programs re-checked with negative tests; PHP is checked in CI. --- contracts/doc-snippets.json | 4 ++-- scripts/doc-snippets/recipes.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/doc-snippets.json b/contracts/doc-snippets.json index 21b65a02..d3b32e15 100644 --- a/contracts/doc-snippets.json +++ b/contracts/doc-snippets.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "sourceCommit": "fbdde786f2c7c25c5e9376fe71ef163498f35fbd", - "sourceFilesSha256": "09d6803fe929a78cb20dc8b404aac868ddef67e3f1f2fb9f2a54fe294283cb61", + "sourceCommit": "7227080dfa99bf3ced43b7944b9a55cac16ec2a9", + "sourceFilesSha256": "e046c7771ae7a418ebb16e1a8c1ea667b57969f592dab2a57aba2c5258c8580f", "apiVersion": "2026-09-22", "apiSourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99", "specs": { diff --git a/scripts/doc-snippets/recipes.mjs b/scripts/doc-snippets/recipes.mjs index b0b3c2fe..ec407f60 100644 --- a/scripts/doc-snippets/recipes.mjs +++ b/scripts/doc-snippets/recipes.mjs @@ -1,7 +1,7 @@ // Requests come from the pinned OpenAPI and existing native wire fixtures. // Calls are handwritten in the language modules. Never derive arguments from // contracts/coverage.json's display-only method names. -export const sourceCommit = "fbdde786f2c7c25c5e9376fe71ef163498f35fbd"; +export const sourceCommit = "7227080dfa99bf3ced43b7944b9a55cac16ec2a9"; export const apiVersion = "2026-09-22"; export const languages = [ "typescript", From b231fbf0711108efdf3efef6a66eef9689e1cf79 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 05:26:20 +0200 Subject: [PATCH 17/21] docs: bind unchanged snippets to the main merge source sourceCommit a3037332 (merge of main 3b222dca). TypeScript, Go, Python and Rust exported programs re-checked with negative tests. The .NET verifier could not complete locally because api.nuget.org was unreachable; PHP and .NET are checked in CI. --- contracts/doc-snippets.json | 4 ++-- scripts/doc-snippets/recipes.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/doc-snippets.json b/contracts/doc-snippets.json index d3b32e15..0bf67dd2 100644 --- a/contracts/doc-snippets.json +++ b/contracts/doc-snippets.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "sourceCommit": "7227080dfa99bf3ced43b7944b9a55cac16ec2a9", - "sourceFilesSha256": "e046c7771ae7a418ebb16e1a8c1ea667b57969f592dab2a57aba2c5258c8580f", + "sourceCommit": "a3037332a3068536998b568673040a62ee2c44f8", + "sourceFilesSha256": "0a0e1ad89041d4b587b88c9feac40bb2a29603e6a440308f93799e00a1acbf48", "apiVersion": "2026-09-22", "apiSourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99", "specs": { diff --git a/scripts/doc-snippets/recipes.mjs b/scripts/doc-snippets/recipes.mjs index ec407f60..972ff672 100644 --- a/scripts/doc-snippets/recipes.mjs +++ b/scripts/doc-snippets/recipes.mjs @@ -1,7 +1,7 @@ // Requests come from the pinned OpenAPI and existing native wire fixtures. // Calls are handwritten in the language modules. Never derive arguments from // contracts/coverage.json's display-only method names. -export const sourceCommit = "7227080dfa99bf3ced43b7944b9a55cac16ec2a9"; +export const sourceCommit = "a3037332a3068536998b568673040a62ee2c44f8"; export const apiVersion = "2026-09-22"; export const languages = [ "typescript", From 3bdcb012542c7fb777cca2df59b7e321f0820314 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 07:34:52 +0200 Subject: [PATCH 18/21] test(rust): count pre-header closes in the admitted body-timeout fixture The request timeout covers connect, request write and response headers, and an eligible keyed attempt retries when it expires there. On a stalled runner (stable CI job 122613877226) the first attempt closed before sending complete headers. The fixture panicked on that EOF and dropped its listener, so the retries reported Connection instead of the admitted body Timeout. The fixture now counts exactly those incomplete-header EOFs, bounded by the retry budget and a 10 s watchdog, and still requires one admitted request, no request after it, status 200, request and operation IDs, Timeout, and attempts equal to early closes + 1 (1 when none closed early). The 30 ms deadline, retry budget and SDK behavior are unchanged. --- packages/rust/tests/transport.rs | 67 ++++++++++++++++++++++++-------- 1 file changed, 50 insertions(+), 17 deletions(-) diff --git a/packages/rust/tests/transport.rs b/packages/rust/tests/transport.rs index 22cd567d..84093d54 100644 --- a/packages/rust/tests/transport.rs +++ b/packages/rust/tests/transport.rs @@ -11,22 +11,54 @@ use tokio::{ async fn admitted_operation_body_timeout_preserves_metadata_without_retry() { let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); let base = format!("http://{}", listener.local_addr().unwrap()); + // The request timeout covers connect, request write and response headers; + // an eligible keyed attempt may retry when it expires there, before admission. + // A stalled runner can close such an attempt before the server sees complete + // headers. Count exactly those incomplete-header EOFs, bounded by the retry + // budget, then require one admitted request and no request after it. + const RETRIES: u32 = 3; let server = tokio::spawn(async move { - let (mut socket, _) = listener.accept().await.unwrap(); - let mut request = Vec::new(); - let mut bytes = [0u8; 4096]; - while !request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { - let count = socket.read(&mut bytes).await.unwrap(); - assert_ne!(count, 0); - request.extend_from_slice(&bytes[..count]); - } - socket.write_all(b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nrequest-id: admitted-request\r\nx-polymorfa-operation-id: operation-1\r\ncontent-length: 100\r\nconnection: close\r\n\r\n{\"success\":").await.unwrap(); - tokio::time::sleep(Duration::from_millis(100)).await; - assert!( - tokio::time::timeout(Duration::from_millis(20), listener.accept()) + let watchdog = tokio::time::Instant::now() + Duration::from_secs(10); + let mut early_closes = 0u32; + loop { + assert!( + early_closes <= RETRIES, + "more pre-header closes than the retry budget allows" + ); + let (mut socket, _) = tokio::time::timeout_at(watchdog, listener.accept()) .await - .is_err() - ); + .expect("fixture watchdog expired before an admitted request") + .unwrap(); + let mut request = Vec::new(); + let mut bytes = [0u8; 4096]; + let complete = loop { + if request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { + break true; + } + let count = tokio::time::timeout_at(watchdog, socket.read(&mut bytes)) + .await + .expect("fixture watchdog expired while reading request headers") + .unwrap(); + if count == 0 { + break false; + } + request.extend_from_slice(&bytes[..count]); + }; + if !complete { + early_closes += 1; + continue; + } + assert!(request.starts_with(b"POST /messaging/operation ")); + socket.write_all(b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nrequest-id: admitted-request\r\nx-polymorfa-operation-id: operation-1\r\ncontent-length: 100\r\nconnection: close\r\n\r\n{\"success\":").await.unwrap(); + tokio::time::sleep(Duration::from_millis(100)).await; + assert!( + tokio::time::timeout(Duration::from_millis(20), listener.accept()) + .await + .is_err(), + "request after the admitted response" + ); + return early_closes; + } }); let error = support::messaging(base) .raw::( @@ -36,7 +68,7 @@ async fn admitted_operation_body_timeout_preserves_metadata_without_retry() { Some(&serde_json::json!({"value":1})), RequestOptions { timeout: Some(Duration::from_millis(30)), - max_network_retries: Some(3), + max_network_retries: Some(RETRIES), idempotency_key: Some("once".into()), ..RequestOptions::default() }, @@ -47,6 +79,7 @@ async fn admitted_operation_body_timeout_preserves_metadata_without_retry() { assert_eq!(error.status, Some(200)); assert_eq!(error.request_id.as_deref(), Some("admitted-request")); assert_eq!(error.operation_id.as_deref(), Some("operation-1")); - assert_eq!(error.metadata.as_ref().unwrap().attempts, 1); - server.await.unwrap(); + let early_closes = server.await.unwrap(); + // The admitted attempt is the last one: 1 when no attempt closed early. + assert_eq!(error.metadata.as_ref().unwrap().attempts, early_closes + 1); } From 262a4535549a1d40a444157853c0d85d8ae7e0da Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 08:01:11 +0200 Subject: [PATCH 19/21] docs: bind unchanged snippets to the Rust fixture fix source sourceCommit 3bdcb012 (after merging main 4357c53b). TypeScript, Go, Python and Rust exported programs re-checked with negative tests. The .NET verifier needs api.nuget.org, which was unreachable locally; .NET and PHP are checked in CI. --- contracts/doc-snippets.json | 4 ++-- scripts/doc-snippets/recipes.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/doc-snippets.json b/contracts/doc-snippets.json index 0bf67dd2..08c1d2d3 100644 --- a/contracts/doc-snippets.json +++ b/contracts/doc-snippets.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "sourceCommit": "a3037332a3068536998b568673040a62ee2c44f8", - "sourceFilesSha256": "0a0e1ad89041d4b587b88c9feac40bb2a29603e6a440308f93799e00a1acbf48", + "sourceCommit": "3bdcb012542c7fb777cca2df59b7e321f0820314", + "sourceFilesSha256": "2ad4eb52450b2d9f2c26c8ca6f9fa609ff7ba47b88e78600a392756841e17a49", "apiVersion": "2026-09-22", "apiSourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99", "specs": { diff --git a/scripts/doc-snippets/recipes.mjs b/scripts/doc-snippets/recipes.mjs index 972ff672..9596bb0c 100644 --- a/scripts/doc-snippets/recipes.mjs +++ b/scripts/doc-snippets/recipes.mjs @@ -1,7 +1,7 @@ // Requests come from the pinned OpenAPI and existing native wire fixtures. // Calls are handwritten in the language modules. Never derive arguments from // contracts/coverage.json's display-only method names. -export const sourceCommit = "a3037332a3068536998b568673040a62ee2c44f8"; +export const sourceCommit = "3bdcb012542c7fb777cca2df59b7e321f0820314"; export const apiVersion = "2026-09-22"; export const languages = [ "typescript", From b1a4cfe15858ae029b3d4e40cd691fbede2d7ec4 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 08:58:47 +0200 Subject: [PATCH 20/21] test(rust): observe client admission in the admitted body-timeout regression The integration fixture inferred admission from the server writing headers, so a stalled runner could still time out the header phase after that write and make a legitimate pre-admission retry. The regression is now a crate unit test that waits for the client itself to report successful headers and metadata from open_internal, through a cfg(test)-only synchronous observer that release builds do not compile. It runs on a paused current-thread runtime. A blocking task inhibits auto-advance until that observation, so connect, write and header latency cannot consume the unchanged 30 ms timeout; only then does the body timer run. It asserts one admitted attempt, metadata attempts 1, Timeout, status 200, request and operation IDs, the exact route, idempotency key and body, and no connection after admission, under a real wall-clock watchdog. Pre-header network retries stay covered in tests/idempotency_headers.rs. Uses the locked Tokio test-util dev feature; Cargo.lock is unchanged. --- packages/rust/Cargo.toml | 2 +- packages/rust/src/transport.rs | 6 + packages/rust/src/transport_admission_test.rs | 155 ++++++++++++++++++ packages/rust/tests/transport.rs | 85 ---------- 4 files changed, 162 insertions(+), 86 deletions(-) create mode 100644 packages/rust/src/transport_admission_test.rs delete mode 100644 packages/rust/tests/transport.rs diff --git a/packages/rust/Cargo.toml b/packages/rust/Cargo.toml index eec72107..b9bd178d 100644 --- a/packages/rust/Cargo.toml +++ b/packages/rust/Cargo.toml @@ -57,5 +57,5 @@ url = "2" uuid = { version = "1", features = ["v4"] } [dev-dependencies] -tokio = { version = "1", features = ["full"] } +tokio = { version = "1", features = ["full", "test-util"] } diff --git a/packages/rust/src/transport.rs b/packages/rust/src/transport.rs index 92aa0f38..0fa42c50 100644 --- a/packages/rust/src/transport.rs +++ b/packages/rust/src/transport.rs @@ -665,6 +665,8 @@ impl HttpTransport { let value = serde_json::from_slice(&bytes).unwrap_or(serde_json::Value::Null); return Err(Error::response(&value, metadata)); } + #[cfg(test)] + admission_test::observe_admission(attempt); return Ok((response, metadata)); } } @@ -992,3 +994,7 @@ fn metadata(response: &reqwest::Response, attempts: u32) -> ResponseMetadata { headers, } } + +#[cfg(test)] +#[path = "transport_admission_test.rs"] +mod admission_test; diff --git a/packages/rust/src/transport_admission_test.rs b/packages/rust/src/transport_admission_test.rs new file mode 100644 index 00000000..09c9bb6c --- /dev/null +++ b/packages/rust/src/transport_admission_test.rs @@ -0,0 +1,155 @@ +//! Phase-observed regression for the admitted response-body timeout. Test builds only. +//! +//! The request timeout covers connect, request write and response headers, and an +//! eligible keyed attempt retries when it expires there. This test needs the body +//! phase, so it waits for the client's own admission (successful headers and +//! metadata in `open_internal`) before letting the unchanged 30 ms timer run. +use crate::{models::Query, ClientOptions, Credential, ErrorKind, MessagingClient, RequestOptions}; +use std::{cell::RefCell, sync::mpsc, time::Duration}; +use tokio::io::{AsyncReadExt, AsyncWriteExt}; + +thread_local! { + static ADMITTED: RefCell>> = const { RefCell::new(None) }; +} + +/// Called synchronously by `open_internal` in test builds after the client has +/// received successful response headers and built their metadata. +pub(super) fn observe_admission(attempt: u32) { + ADMITTED.with(|slot| { + if let Some(sender) = slot.borrow().as_ref() { + let _ = sender.send(attempt); + } + }); +} + +const BODY: &[u8] = b"{\"value\":1}"; +const REAL_WATCHDOG: Duration = Duration::from_secs(30); +const INHIBIT_LIMIT: Duration = Duration::from_secs(20); + +fn credential() -> Credential { + let fixture: serde_json::Value = serde_json::from_slice( + &std::fs::read(concat!( + env!("CARGO_MANIFEST_DIR"), + "/../../contracts/machine-credentials.v1.json" + )) + .unwrap(), + ) + .unwrap(); + let token = fixture["opaque"] + .as_array() + .unwrap() + .iter() + .find(|entry| entry["kind"] == "key") + .unwrap()["token"] + .as_str() + .unwrap() + .to_owned(); + Credential::organization_api_key(token).unwrap() +} + +async fn scenario() { + let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap(); + let address = listener.local_addr().unwrap(); + // A running blocking task stops the paused clock from auto-advancing, so + // connect, write and header latency cannot consume the request timeout. + let (admitted_tx, admitted_rx) = mpsc::channel::(); + ADMITTED.with(|slot| *slot.borrow_mut() = Some(admitted_tx)); + let inhibitor = tokio::task::spawn_blocking(move || admitted_rx.recv_timeout(INHIBIT_LIMIT)); + let server = tokio::spawn(async move { + let (mut socket, _) = listener.accept().await.unwrap(); + let mut request = Vec::new(); + let mut bytes = [0u8; 4096]; + let head_end = loop { + if let Some(end) = request.windows(4).position(|w| w == b"\r\n\r\n") { + break end + 4; + } + let count = socket.read(&mut bytes).await.unwrap(); + assert_ne!(count, 0, "client closed before complete request headers"); + request.extend_from_slice(&bytes[..count]); + }; + while request.len() < head_end + BODY.len() { + let count = socket.read(&mut bytes).await.unwrap(); + assert_ne!(count, 0, "client closed before the request body"); + request.extend_from_slice(&bytes[..count]); + } + socket.write_all(b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nrequest-id: admitted-request\r\nx-polymorfa-operation-id: operation-1\r\ncontent-length: 100\r\nconnection: close\r\n\r\n{\"success\":").await.unwrap(); + // Leave the body incomplete; the client closes after its body timeout. + let mut rest = [0u8; 64]; + assert_eq!(socket.read(&mut rest).await.unwrap(), 0); + (request, head_end, listener) + }); + let client = MessagingClient::new( + credential(), + ClientOptions { + base_url: format!("http://{address}"), + max_network_retries: 0, + ..ClientOptions::default() + }, + ) + .unwrap(); + let error = client + .raw::( + reqwest::Method::POST, + "/messaging/operation", + &Query::new(), + Some(&serde_json::json!({"value":1})), + RequestOptions { + timeout: Some(Duration::from_millis(30)), + max_network_retries: Some(3), + idempotency_key: Some("once".into()), + ..RequestOptions::default() + }, + ) + .await + .unwrap_err(); + let admitted_attempt = inhibitor + .await + .unwrap() + .expect("client never reported admitted response headers"); + ADMITTED.with(|slot| slot.borrow_mut().take()); + let (request, head_end, listener) = server.await.unwrap(); + + assert_eq!(admitted_attempt, 1); + assert_eq!(error.kind, ErrorKind::Timeout); + assert_eq!(error.status, Some(200)); + assert_eq!(error.request_id.as_deref(), Some("admitted-request")); + assert_eq!(error.operation_id.as_deref(), Some("operation-1")); + assert_eq!(error.metadata.as_ref().unwrap().attempts, 1); + let head = String::from_utf8_lossy(&request[..head_end]).to_ascii_lowercase(); + assert!(head.starts_with("post /messaging/operation http/1.1\r\n")); + assert!(head.contains("\r\nidempotency-key: once\r\n")); + assert_eq!(&request[head_end..], BODY); + // No replay: nothing else reached the still-bound listener. + let listener = listener.into_std().unwrap(); + listener.set_nonblocking(true).unwrap(); + let pending = listener.accept(); + assert!( + matches!(&pending, Err(e) if e.kind() == std::io::ErrorKind::WouldBlock), + "request after the admitted response: {pending:?}" + ); +} + +#[test] +fn admitted_operation_body_timeout_preserves_metadata_without_retry() { + let (done_tx, done_rx) = mpsc::channel(); + let worker = std::thread::spawn(move || { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .start_paused(true) + .build() + .unwrap() + .block_on(scenario()); + let _ = done_tx.send(()); + }); + // Paused Tokio time cannot detect a stuck harness, so use a real wall clock. + match done_rx.recv_timeout(REAL_WATCHDOG) { + Ok(()) | Err(mpsc::RecvTimeoutError::Disconnected) => { + if let Err(panic) = worker.join() { + std::panic::resume_unwind(panic); + } + } + Err(mpsc::RecvTimeoutError::Timeout) => { + panic!("phase-observed harness exceeded its real wall-clock watchdog") + } + } +} diff --git a/packages/rust/tests/transport.rs b/packages/rust/tests/transport.rs deleted file mode 100644 index 84093d54..00000000 --- a/packages/rust/tests/transport.rs +++ /dev/null @@ -1,85 +0,0 @@ -#[allow(dead_code)] -mod support; -use polymorfa_sdk::{models::Query, ErrorKind, RequestOptions}; -use std::time::Duration; -use tokio::{ - io::{AsyncReadExt, AsyncWriteExt}, - net::TcpListener, -}; - -#[tokio::test] -async fn admitted_operation_body_timeout_preserves_metadata_without_retry() { - let listener = TcpListener::bind("127.0.0.1:0").await.unwrap(); - let base = format!("http://{}", listener.local_addr().unwrap()); - // The request timeout covers connect, request write and response headers; - // an eligible keyed attempt may retry when it expires there, before admission. - // A stalled runner can close such an attempt before the server sees complete - // headers. Count exactly those incomplete-header EOFs, bounded by the retry - // budget, then require one admitted request and no request after it. - const RETRIES: u32 = 3; - let server = tokio::spawn(async move { - let watchdog = tokio::time::Instant::now() + Duration::from_secs(10); - let mut early_closes = 0u32; - loop { - assert!( - early_closes <= RETRIES, - "more pre-header closes than the retry budget allows" - ); - let (mut socket, _) = tokio::time::timeout_at(watchdog, listener.accept()) - .await - .expect("fixture watchdog expired before an admitted request") - .unwrap(); - let mut request = Vec::new(); - let mut bytes = [0u8; 4096]; - let complete = loop { - if request.windows(4).any(|bytes| bytes == b"\r\n\r\n") { - break true; - } - let count = tokio::time::timeout_at(watchdog, socket.read(&mut bytes)) - .await - .expect("fixture watchdog expired while reading request headers") - .unwrap(); - if count == 0 { - break false; - } - request.extend_from_slice(&bytes[..count]); - }; - if !complete { - early_closes += 1; - continue; - } - assert!(request.starts_with(b"POST /messaging/operation ")); - socket.write_all(b"HTTP/1.1 200 OK\r\ncontent-type: application/json\r\nrequest-id: admitted-request\r\nx-polymorfa-operation-id: operation-1\r\ncontent-length: 100\r\nconnection: close\r\n\r\n{\"success\":").await.unwrap(); - tokio::time::sleep(Duration::from_millis(100)).await; - assert!( - tokio::time::timeout(Duration::from_millis(20), listener.accept()) - .await - .is_err(), - "request after the admitted response" - ); - return early_closes; - } - }); - let error = support::messaging(base) - .raw::( - reqwest::Method::POST, - "/messaging/operation", - &Query::new(), - Some(&serde_json::json!({"value":1})), - RequestOptions { - timeout: Some(Duration::from_millis(30)), - max_network_retries: Some(RETRIES), - idempotency_key: Some("once".into()), - ..RequestOptions::default() - }, - ) - .await - .unwrap_err(); - assert_eq!(error.kind, ErrorKind::Timeout); - assert_eq!(error.status, Some(200)); - assert_eq!(error.request_id.as_deref(), Some("admitted-request")); - assert_eq!(error.operation_id.as_deref(), Some("operation-1")); - let early_closes = server.await.unwrap(); - // The admitted attempt is the last one: 1 when no attempt closed early. - assert_eq!(error.metadata.as_ref().unwrap().attempts, early_closes + 1); -} From 65addec13a99093d5534d7915b6f98205192f5c7 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 28 Oct 2026 09:01:17 +0200 Subject: [PATCH 21/21] docs: bind unchanged snippets to the phase-observed Rust test source sourceCommit b1a4cfe1. TypeScript, Go, Python and Rust exported programs re-checked with negative tests; .NET and PHP are checked in CI. --- contracts/doc-snippets.json | 4 ++-- scripts/doc-snippets/recipes.mjs | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/contracts/doc-snippets.json b/contracts/doc-snippets.json index 08c1d2d3..e5bf593b 100644 --- a/contracts/doc-snippets.json +++ b/contracts/doc-snippets.json @@ -1,7 +1,7 @@ { "schemaVersion": 1, - "sourceCommit": "3bdcb012542c7fb777cca2df59b7e321f0820314", - "sourceFilesSha256": "2ad4eb52450b2d9f2c26c8ca6f9fa609ff7ba47b88e78600a392756841e17a49", + "sourceCommit": "b1a4cfe15858ae029b3d4e40cd691fbede2d7ec4", + "sourceFilesSha256": "22d0df64227263fd4566f91efb3d668fe2867f8a0e36f6d0d5d878b8498d2e3a", "apiVersion": "2026-09-22", "apiSourceCommit": "171a9683ff45d11a4b5d3917dc428ad2722b0d99", "specs": { diff --git a/scripts/doc-snippets/recipes.mjs b/scripts/doc-snippets/recipes.mjs index 9596bb0c..eb0abea4 100644 --- a/scripts/doc-snippets/recipes.mjs +++ b/scripts/doc-snippets/recipes.mjs @@ -1,7 +1,7 @@ // Requests come from the pinned OpenAPI and existing native wire fixtures. // Calls are handwritten in the language modules. Never derive arguments from // contracts/coverage.json's display-only method names. -export const sourceCommit = "3bdcb012542c7fb777cca2df59b7e321f0820314"; +export const sourceCommit = "b1a4cfe15858ae029b3d4e40cd691fbede2d7ec4"; export const apiVersion = "2026-09-22"; export const languages = [ "typescript",