From 58fb05da0058a2c3c3e8ab8648040d0ad4b3397e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 06:38:48 +0300 Subject: [PATCH 1/9] feat: read stored group capabilities with server credentials --- README.md | 16 + contracts/abprops-capabilities.json | 566 ++++++++++++++++++++++++++++ src/commands/group/capabilities.ts | 38 ++ src/services/groups.ts | 39 ++ test/commands/groups.test.ts | 28 ++ test/services/groups.test.ts | 32 ++ 6 files changed, 719 insertions(+) create mode 100644 contracts/abprops-capabilities.json create mode 100644 src/commands/group/capabilities.ts diff --git a/README.md b/README.md index da726c3c..4540a7ad 100644 --- a/README.md +++ b/README.md @@ -2569,3 +2569,19 @@ an API deployment exposing Analytics are separate requirements. ## Package release workflow CLI development and release PRs target `main`. Nightly packages use the `dev` npm tag after matching staging API acceptance; stable packages follow production acceptance. Each package pins an exact accepted SDK version. See [the release guide](RELEASING.md). + +### Stored group capabilities + +Use `polymorfa group capabilities GROUP_ID --session SESSION` to read the +linked number's stored group configuration. Add `--json` for the complete +response. This read requires `groups:read`, current group membership, and +number-capabilities beta enrollment. Unknown configuration stays `unknown`; +the command does not open the group chat or fetch its configuration. + +The keys `polls.endTime`, `polls.hideVoters`, and `polls.creatorEdit` describe +WhatsApp configuration. They do not grant poll-editing permissions. The API +must include the group-capabilities contract. + +The capability extension is pinned in `contracts/abprops-capabilities.json` to +API source `f4947a5bb`. This is the same scoped revision consumed by the SDK; +other command contracts and the published SDK dependency keep their recorded revisions. diff --git a/contracts/abprops-capabilities.json b/contracts/abprops-capabilities.json new file mode 100644 index 00000000..4dc77165 --- /dev/null +++ b/contracts/abprops-capabilities.json @@ -0,0 +1,566 @@ +{ + "repository": "polymorfa/polymorfa", + "sourceCommit": "f4947a5bb3dd1b51efd90c95be760ed5c4861d18", + "sourcePath": "apps/api/docs/openapi.json", + "sourceSha256": "62fc7613fc73e9259edd35ff995220fc4fd7a7b1e99a5f0a47a1df7ee3029407", + "published": false, + "scope": "Group capability read and canonical session capability webhook only. Other SDK contracts retain their recorded source revisions.", + "openapi": "3.0.3", + "paths": { + "/messaging/{session}/groups/{id}/capabilities": { + "get": { + "operationId": "getGroupCapabilities", + "tags": ["Groups"], + "summary": "Read stored WhatsApp group capabilities", + "security": [ + { + "BearerAuth": [] + } + ], + "x-required-scope": "groups:read", + "parameters": [ + { + "schema": { + "type": "string" + }, + "required": true, + "name": "session", + "in": "path" + }, + { + "schema": { + "type": "string", + "pattern": "^[1-9][0-9]{0,18}$" + }, + "required": true, + "name": "id", + "in": "path" + } + ], + "responses": { + "200": { + "description": "Account-bound stored group capabilities. Unknown until a successful group sync. Requires current group membership and the enrolled number-capabilities beta.", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/GetGroupCapabilitiesResponse" + } + } + }, + "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" + } + } + }, + "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" + } + } + } + }, + "x-mint": { + "content": "Linked Device API groups:read" + }, + "x-protocols": ["Linked Device API"], + "description": "**Protocols:** Linked Device API\n\n**Required scope:** `groups:read`" + } + } + }, + "components": { + "schemas": { + "GetGroupCapabilitiesResponse": { + "type": "object", + "properties": { + "success": { + "type": "boolean", + "enum": [true] + }, + "data": { + "$ref": "#/components/schemas/GroupCapabilities" + } + }, + "required": ["success", "data"] + }, + "GroupCapabilities": { + "type": "object", + "properties": { + "status": { + "type": "string", + "enum": ["synced", "unknown"] + }, + "syncedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "checkedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "capabilities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "enum": [ + "polls.endTime", + "polls.hideVoters", + "polls.creatorEdit" + ] + }, + "kind": { + "type": "string", + "enum": ["feature"] + }, + "unit": {}, + "value": { + "type": "boolean", + "nullable": true + }, + "source": { + "type": "string", + "nullable": true, + "enum": ["server", "client_default", null] + } + }, + "required": ["key", "kind", "unit", "value", "source"], + "additionalProperties": false + } + } + }, + "required": ["status", "syncedAt", "checkedAt", "capabilities"], + "additionalProperties": false + }, + "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", + "hybrid_routing_paused", + "hybrid_choice_required", + "hybrid_transition_ineligible", + "hybrid_automation_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", + "recipient_not_on_whatsapp", + "conversation_window_closed", + "template_not_approved", + "media_too_large", + "whatsapp_rate_limited", + "new_chat_limit_reached", + "whatsapp_account_restricted", + "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"] + }, + "SessionCapabilitiesUpdatedEvent": { + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique event identifier" + }, + "session": { + "type": "string", + "description": "Session that produced this event" + }, + "externalId": { + "type": "string", + "description": "Integrator reference from the QuickLink that created this session. Included on session lifecycle events when supplied." + }, + "timestamp": { + "type": "string", + "description": "ISO 8601 timestamp" + }, + "event": { + "type": "string", + "enum": ["session.capabilities_updated"], + "description": "Event type" + }, + "payload": { + "$ref": "#/components/schemas/SessionCapabilitiesUpdatedPayload" + } + }, + "required": ["id", "session", "timestamp", "event", "payload"] + }, + "SessionCapabilitiesUpdatedPayload": { + "type": "object", + "properties": { + "session": { + "type": "string", + "maxLength": 256 + }, + "projectId": { + "type": "string", + "format": "uuid" + }, + "status": { + "type": "string", + "enum": ["synced", "unknown"] + }, + "syncedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "checkedAt": { + "type": "string", + "nullable": true, + "format": "date-time" + }, + "accountType": { + "type": "string", + "nullable": true, + "enum": ["business", "personal", null] + }, + "capabilities": { + "type": "array", + "items": { + "type": "object", + "properties": { + "key": { + "type": "string", + "maxLength": 128 + }, + "kind": { + "type": "string", + "enum": ["feature", "limit"] + }, + "unit": { + "type": "string", + "nullable": true, + "enum": ["seconds", "count", "characters", "members", null] + }, + "value": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "integer" + }, + {} + ] + }, + "source": { + "type": "string", + "nullable": true, + "enum": ["server", "client_default", "account_type", null] + } + }, + "required": ["key", "kind", "unit", "value", "source"], + "additionalProperties": false + }, + "maxItems": 100 + }, + "changedKeys": { + "type": "array", + "items": { + "type": "string", + "maxLength": 128 + }, + "maxItems": 100 + } + }, + "required": [ + "session", + "projectId", + "status", + "syncedAt", + "checkedAt", + "accountType", + "capabilities", + "changedKeys" + ], + "additionalProperties": false + } + } + } +} diff --git a/src/commands/group/capabilities.ts b/src/commands/group/capabilities.ts new file mode 100644 index 00000000..7097202f --- /dev/null +++ b/src/commands/group/capabilities.ts @@ -0,0 +1,38 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../base-command.js' +import { + getGroupCapabilities, + renderGroupCapabilities, +} from '../../services/groups.js' + +export default class GroupCapabilitiesCommand extends BaseCommand { + static override args = { + group: Args.string({ + description: 'Polymorfa group identifier.', + required: true, + }), + } + static override description = + 'Read stored WhatsApp group capabilities. Requires current membership and enrollment in the number-capabilities beta.' + static override flags = { + session: Flags.string({ + description: 'Messaging session ID.', + required: true, + }), + } + static override examples = [ + '<%= config.bin %> group capabilities 9007199254740993 --session support', + '<%= config.bin %> group capabilities 9007199254740993 --session support --json', + ] + async run(): Promise { + const {args, flags} = await this.parse(GroupCapabilitiesCommand) + const result = await getGroupCapabilities( + await this.messagingClient(flags, { + serverApiKeyOnly: 'Group capabilities', + }), + flags.session, + args.group, + ) + this.writeValue(result, renderGroupCapabilities(result.data), flags) + } +} diff --git a/src/services/groups.ts b/src/services/groups.ts index 37281259..1a899b7b 100644 --- a/src/services/groups.ts +++ b/src/services/groups.ts @@ -255,3 +255,42 @@ function result(data: T, metadata: ResponseMetadata): ResourceResult { }, } } + +/** Wire contract shared with the SDK's GroupCapabilities type. Raw request is + * supported by the pinned SDK; this command needs no unpublished dependency. */ +export interface GroupCapabilities { + readonly status: 'synced' | 'unknown' + readonly syncedAt: string | null + readonly checkedAt: string | null + readonly capabilities: readonly { + readonly key: string + readonly kind: 'feature' + readonly unit: null + readonly value: boolean | null + readonly source: 'server' | 'client_default' | null + }[] +} +export async function getGroupCapabilities( + client: MessagingClient, + session: string, + group: string, +): Promise> { + const response = await client.raw.request<{ + success: true + data: GroupCapabilities + }>({ + method: 'GET', + path: `/messaging/${encodeURIComponent(session)}/groups/${encodeURIComponent(group)}/capabilities`, + }) + return result(response.data.data, response.metadata) +} +export function renderGroupCapabilities(view: GroupCapabilities): string { + return [ + `Status\t${view.status}`, + `Synced\t${view.syncedAt ?? 'never'}`, + ...view.capabilities.map( + (c) => + `${c.key}\t${c.value === null ? 'unknown' : String(c.value)}\t${c.source ?? 'unknown'}`, + ), + ].join('\n') +} diff --git a/test/commands/groups.test.ts b/test/commands/groups.test.ts index fffde134..ee3946ff 100644 --- a/test/commands/groups.test.ts +++ b/test/commands/groups.test.ts @@ -53,6 +53,34 @@ describe('group commands', () => { }) }) + test('prints unknown group capabilities as JSON', async () => { + const data = { + status: 'unknown', + syncedAt: null, + checkedAt: null, + capabilities: [ + { + key: 'polls.endTime', + kind: 'feature', + unit: null, + value: null, + source: null, + }, + ], + } + server = await startHttpServer({body: {success: true, data}}) + const result = await environment(() => + runCommand( + 'group capabilities 9007199254740993 --session session_1 --json', + ), + ) + expect(result.error).toBeUndefined() + expect(JSON.parse(result.stdout)).toMatchObject({data}) + expect(server.requests[0]?.url).toBe( + '/messaging/session_1/groups/9007199254740993/capabilities', + ) + }) + test('requires explicit session scope before making a request', async () => { const result = await runCommand('group list --json') diff --git a/test/services/groups.test.ts b/test/services/groups.test.ts index 2d3dc7f1..6306df2c 100644 --- a/test/services/groups.test.ts +++ b/test/services/groups.test.ts @@ -3,6 +3,8 @@ import {afterEach, describe, expect, test} from 'vitest' import {createMessagingClient} from '../../src/sdk/client.js' import { getGroupInviteLink, + getGroupCapabilities, + renderGroupCapabilities, listGroupParticipants, listGroups, viewGroup, @@ -53,6 +55,36 @@ describe('group read services', () => { ).resolves.toMatchObject({data: group.participants}) }) + test('reads group capabilities without replacing unknown values', async () => { + const data = { + status: 'unknown' as const, + syncedAt: null, + checkedAt: null, + capabilities: [ + { + key: 'polls.endTime', + kind: 'feature' as const, + unit: null, + value: null, + source: null, + }, + ], + } + server = await startHttpServer({body: {success: true, data}}) + const response = await getGroupCapabilities( + client(server.baseUrl), + 'session/main', + '9007199254740993', + ) + expect(response.data).toEqual(data) + expect(server.requests[0]?.url).toBe( + '/messaging/session%2Fmain/groups/9007199254740993/capabilities', + ) + expect(renderGroupCapabilities(data)).toContain( + 'polls.endTime\tunknown\tunknown', + ) + }) + test('derives the canonical invite URL while retaining its source code', async () => { server = await startHttpServer({ body: {data: {code: 'Invite_123'}, success: true}, From e3e5d20c10daf57983cf6138e19922d22b6cad64 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 06:45:11 +0300 Subject: [PATCH 2/9] docs: align capability contract with SDK source revision --- README.md | 2 +- contracts/abprops-capabilities.json | 22 ++++++++++++++-------- 2 files changed, 15 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index 4540a7ad..ed1cb1a4 100644 --- a/README.md +++ b/README.md @@ -2583,5 +2583,5 @@ WhatsApp configuration. They do not grant poll-editing permissions. The API must include the group-capabilities contract. The capability extension is pinned in `contracts/abprops-capabilities.json` to -API source `f4947a5bb`. This is the same scoped revision consumed by the SDK; +API source `98d1a5703813e22381c8e49612dfc2135869650f`. This is the same scoped revision consumed by the SDK; other command contracts and the published SDK dependency keep their recorded revisions. diff --git a/contracts/abprops-capabilities.json b/contracts/abprops-capabilities.json index 4dc77165..be376188 100644 --- a/contracts/abprops-capabilities.json +++ b/contracts/abprops-capabilities.json @@ -1,8 +1,8 @@ { "repository": "polymorfa/polymorfa", - "sourceCommit": "f4947a5bb3dd1b51efd90c95be760ed5c4861d18", + "sourceCommit": "98d1a5703813e22381c8e49612dfc2135869650f", "sourcePath": "apps/api/docs/openapi.json", - "sourceSha256": "62fc7613fc73e9259edd35ff995220fc4fd7a7b1e99a5f0a47a1df7ee3029407", + "sourceSha256": "1342405a5c2ed20038076bacb5c326153a86cfddc8eaebd4c2f4dcea49049eb9", "published": false, "scope": "Group capability read and canonical session capability webhook only. Other SDK contracts retain their recorded source revisions.", "openapi": "3.0.3", @@ -277,7 +277,11 @@ "type": "string", "enum": ["feature"] }, - "unit": {}, + "unit": { + "nullable": true, + "type": "string", + "enum": [null] + }, "value": { "type": "boolean", "nullable": true @@ -521,13 +525,15 @@ "value": { "anyOf": [ { - "type": "boolean" + "type": "boolean", + "nullable": true }, { - "type": "integer" - }, - {} - ] + "type": "integer", + "nullable": true + } + ], + "nullable": true }, "source": { "type": "string", From 2309592aa98dcd7fcd5fd9ec1c49f3912227363f Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 06:51:06 +0300 Subject: [PATCH 3/9] fix: require capability acceptance for CLI publication --- contracts/abprops-capabilities.json | 3 ++- scripts/prepare-release.mjs | 10 ++++++++- scripts/release-evidence.mjs | 35 +++++++++++++++++++++++++++++ scripts/release-evidence.test.mjs | 23 ++++++++++++++++++- 4 files changed, 68 insertions(+), 3 deletions(-) diff --git a/contracts/abprops-capabilities.json b/contracts/abprops-capabilities.json index be376188..2f230cbb 100644 --- a/contracts/abprops-capabilities.json +++ b/contracts/abprops-capabilities.json @@ -568,5 +568,6 @@ "additionalProperties": false } } - } + }, + "family": "messaging" } diff --git a/scripts/prepare-release.mjs b/scripts/prepare-release.mjs index 271320d6..f124c2b2 100644 --- a/scripts/prepare-release.mjs +++ b/scripts/prepare-release.mjs @@ -5,7 +5,11 @@ import {createHash} from 'node:crypto' import {execFileSync} from 'node:child_process' import {mkdirSync, readFileSync, writeFileSync} from 'node:fs' import {join, resolve} from 'node:path' -import {registryEvidence, validateSdk} from './release-evidence.mjs' +import { + registryEvidence, + validateSdk, + validateFeatureContractPins, +} from './release-evidence.mjs' try { assert.equal( @@ -24,12 +28,16 @@ try { 'SDK publication with matching API acceptance must finish first', ) validateSdk(sdk.provenance, sdk.version, channel) + validateFeatureContractPins(sdk.provenance, [ + JSON.parse(readFileSync('contracts/abprops-capabilities.json')), + ]) const files = execFileSync('git', [ 'ls-files', '-z', 'src', 'bin', 'scripts', + 'contracts', 'skills', 'package.json', 'package-lock.json', diff --git a/scripts/release-evidence.mjs b/scripts/release-evidence.mjs index dc5890aa..244daf85 100644 --- a/scripts/release-evidence.mjs +++ b/scripts/release-evidence.mjs @@ -57,6 +57,41 @@ export function validateSdk(provenance, version, channel) { } return provenance } +export function validateFeatureContractPins(provenance, pins) { + const receipt = provenance.acceptance + for (const pin of pins) { + assert.equal( + pin.repository, + receipt.repository, + 'Feature contract repository mismatch', + ) + assert.ok( + ['messaging', 'platform'].includes(pin.family), + 'Feature contract family is required', + ) + assert.notEqual( + pin.published, + false, + 'Feature contract is held from publication', + ) + assert.equal( + pin.sourceCommit, + receipt.sourceSha, + 'Feature contract source is not accepted', + ) + assert.equal( + pin.sourcePath, + receipt.contracts[pin.family]?.sourcePath, + 'Feature contract source path mismatch', + ) + assert.equal( + pin.sourceSha256, + receipt.contracts[pin.family]?.sha256, + 'Feature contract hash mismatch', + ) + } +} + export async function registryEvidence(name, tag, output) { assert.ok(['@polymorfa/sdk', '@polymorfa/cli'].includes(name)) const response = await fetch( diff --git a/scripts/release-evidence.test.mjs b/scripts/release-evidence.test.mjs index 12b863ce..251fdee7 100644 --- a/scripts/release-evidence.test.mjs +++ b/scripts/release-evidence.test.mjs @@ -1,6 +1,6 @@ import {test} from 'node:test' import assert from 'node:assert/strict' -import {validateSdk} from './release-evidence.mjs' +import {validateSdk, validateFeatureContractPins} from './release-evidence.mjs' const sdk = { schemaVersion: 1, repository: 'polymorfa/sdks', @@ -61,3 +61,24 @@ test('an installed SDK or healthy API without native route acceptance is insuffi ), ) }) + +test('capability commands cannot publish against unrelated SDK acceptance', () => { + const pin = { + repository: sdk.acceptance.repository, + family: 'messaging', + published: true, + sourceCommit: sdk.acceptance.sourceSha, + sourcePath: sdk.acceptance.contracts.messaging.sourcePath, + sourceSha256: sdk.acceptance.contracts.messaging.sha256, + } + validateFeatureContractPins(sdk, [pin]) + for (const delta of [ + {family: 'platform'}, + {family: undefined}, + {published: false}, + {sourceCommit: 'f'.repeat(40)}, + {sourceSha256: sdk.acceptance.contracts.platform.sha256}, + {sourcePath: sdk.acceptance.contracts.platform.sourcePath}, + ]) + assert.throws(() => validateFeatureContractPins(sdk, [{...pin, ...delta}])) +}) From 26ca5678b04865c59b2d39d0cbb589405a7837a5 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 06:57:21 +0300 Subject: [PATCH 4/9] docs: pin the same rebased capability contract as SDK --- README.md | 2 +- contracts/abprops-capabilities.json | 38 ++++++++++++++++++++++++++--- 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index ed1cb1a4..9b5a4f91 100644 --- a/README.md +++ b/README.md @@ -2583,5 +2583,5 @@ WhatsApp configuration. They do not grant poll-editing permissions. The API must include the group-capabilities contract. The capability extension is pinned in `contracts/abprops-capabilities.json` to -API source `98d1a5703813e22381c8e49612dfc2135869650f`. This is the same scoped revision consumed by the SDK; +API source `7aafb98390d163b5319eed163a5186df9f38e19e`. This is the same scoped revision consumed by the SDK; other command contracts and the published SDK dependency keep their recorded revisions. diff --git a/contracts/abprops-capabilities.json b/contracts/abprops-capabilities.json index 2f230cbb..1ebba888 100644 --- a/contracts/abprops-capabilities.json +++ b/contracts/abprops-capabilities.json @@ -1,8 +1,8 @@ { "repository": "polymorfa/polymorfa", - "sourceCommit": "98d1a5703813e22381c8e49612dfc2135869650f", + "sourceCommit": "7aafb98390d163b5319eed163a5186df9f38e19e", "sourcePath": "apps/api/docs/openapi.json", - "sourceSha256": "1342405a5c2ed20038076bacb5c326153a86cfddc8eaebd4c2f4dcea49049eb9", + "sourceSha256": "5ee46592f918aeee05fbf264e080a71b5afbdfe5c64f5a966a0fb3c159fb5f09", "published": false, "scope": "Group capability read and canonical session capability webhook only. Other SDK contracts retain their recorded source revisions.", "openapi": "3.0.3", @@ -39,7 +39,7 @@ ], "responses": { "200": { - "description": "Account-bound stored group capabilities. Unknown until a successful group sync. Requires current group membership and the enrolled number-capabilities beta.", + "description": "Account-bound stored group capabilities. Unknown until successful number and group syncs for the linked account. Requires current group membership and the enrolled number-capabilities beta.", "content": { "application/json": { "schema": { @@ -218,6 +218,38 @@ "$ref": "#/components/headers/X-RateLimit-Reset" } } + }, + "503": { + "description": "The stored capability reader, identity resolution or command dispatch is temporarily unavailable.", + "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": { From 1c5a52f3df643e52f22cef533869769d89c8d2b0 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 09:29:48 +0300 Subject: [PATCH 5/9] Pin integrated capability contract alongside current main --- README.md | 2 +- contracts/abprops-capabilities.json | 124 ++++++++++++++++++++++------ 2 files changed, 102 insertions(+), 24 deletions(-) diff --git a/README.md b/README.md index 3d491ff3..9ddfae36 100644 --- a/README.md +++ b/README.md @@ -2612,5 +2612,5 @@ WhatsApp configuration. They do not grant poll-editing permissions. The API must include the group-capabilities contract. The capability extension is pinned in `contracts/abprops-capabilities.json` to -API source `7aafb98390d163b5319eed163a5186df9f38e19e`. This is the same scoped revision consumed by the SDK; +API source `205369ff8f5ecb71dd085a190d8ec80aa2b9e483`. This is the same scoped revision consumed by the SDK; other command contracts and the published SDK dependency keep their recorded revisions. diff --git a/contracts/abprops-capabilities.json b/contracts/abprops-capabilities.json index 1ebba888..1afa6681 100644 --- a/contracts/abprops-capabilities.json +++ b/contracts/abprops-capabilities.json @@ -1,8 +1,8 @@ { "repository": "polymorfa/polymorfa", - "sourceCommit": "7aafb98390d163b5319eed163a5186df9f38e19e", + "sourceCommit": "205369ff8f5ecb71dd085a190d8ec80aa2b9e483", "sourcePath": "apps/api/docs/openapi.json", - "sourceSha256": "5ee46592f918aeee05fbf264e080a71b5afbdfe5c64f5a966a0fb3c159fb5f09", + "sourceSha256": "dcee7d1cba358d9127d87ff0d9eaf0496adc36a77dd4ef0088e5feb1e9c9e47a", "published": false, "scope": "Group capability read and canonical session capability webhook only. Other SDK contracts retain their recorded source revisions.", "openapi": "3.0.3", @@ -10,7 +10,9 @@ "/messaging/{session}/groups/{id}/capabilities": { "get": { "operationId": "getGroupCapabilities", - "tags": ["Groups"], + "tags": [ + "Groups" + ], "summary": "Read stored WhatsApp group capabilities", "security": [ { @@ -255,7 +257,9 @@ "x-mint": { "content": "Linked Device API groups:read" }, - "x-protocols": ["Linked Device API"], + "x-protocols": [ + "Linked Device API" + ], "description": "**Protocols:** Linked Device API\n\n**Required scope:** `groups:read`" } } @@ -267,20 +271,28 @@ "properties": { "success": { "type": "boolean", - "enum": [true] + "enum": [ + true + ] }, "data": { "$ref": "#/components/schemas/GroupCapabilities" } }, - "required": ["success", "data"] + "required": [ + "success", + "data" + ] }, "GroupCapabilities": { "type": "object", "properties": { "status": { "type": "string", - "enum": ["synced", "unknown"] + "enum": [ + "synced", + "unknown" + ] }, "syncedAt": { "type": "string", @@ -307,12 +319,16 @@ }, "kind": { "type": "string", - "enum": ["feature"] + "enum": [ + "feature" + ] }, "unit": { "nullable": true, "type": "string", - "enum": [null] + "enum": [ + null + ] }, "value": { "type": "boolean", @@ -321,15 +337,30 @@ "source": { "type": "string", "nullable": true, - "enum": ["server", "client_default", null] + "enum": [ + "server", + "client_default", + null + ] } }, - "required": ["key", "kind", "unit", "value", "source"], + "required": [ + "key", + "kind", + "unit", + "value", + "source" + ], "additionalProperties": false } } }, - "required": ["status", "syncedAt", "checkedAt", "capabilities"], + "required": [ + "status", + "syncedAt", + "checkedAt", + "capabilities" + ], "additionalProperties": false }, "PublicError": { @@ -461,12 +492,20 @@ "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"] + "required": [ + "type", + "code", + "message", + "param", + "request_id" + ] }, "data": { "type": "string", "nullable": true, - "enum": [null], + "enum": [ + null + ], "description": "Always null for a failed request." }, "docs": { @@ -474,7 +513,11 @@ "format": "uri" } }, - "required": ["error", "data", "docs"] + "required": [ + "error", + "data", + "docs" + ] }, "SessionCapabilitiesUpdatedEvent": { "type": "object", @@ -497,14 +540,22 @@ }, "event": { "type": "string", - "enum": ["session.capabilities_updated"], + "enum": [ + "session.capabilities_updated" + ], "description": "Event type" }, "payload": { "$ref": "#/components/schemas/SessionCapabilitiesUpdatedPayload" } }, - "required": ["id", "session", "timestamp", "event", "payload"] + "required": [ + "id", + "session", + "timestamp", + "event", + "payload" + ] }, "SessionCapabilitiesUpdatedPayload": { "type": "object", @@ -519,7 +570,10 @@ }, "status": { "type": "string", - "enum": ["synced", "unknown"] + "enum": [ + "synced", + "unknown" + ] }, "syncedAt": { "type": "string", @@ -534,7 +588,11 @@ "accountType": { "type": "string", "nullable": true, - "enum": ["business", "personal", null] + "enum": [ + "business", + "personal", + null + ] }, "capabilities": { "type": "array", @@ -547,12 +605,21 @@ }, "kind": { "type": "string", - "enum": ["feature", "limit"] + "enum": [ + "feature", + "limit" + ] }, "unit": { "type": "string", "nullable": true, - "enum": ["seconds", "count", "characters", "members", null] + "enum": [ + "seconds", + "count", + "characters", + "members", + null + ] }, "value": { "anyOf": [ @@ -570,10 +637,21 @@ "source": { "type": "string", "nullable": true, - "enum": ["server", "client_default", "account_type", null] + "enum": [ + "server", + "client_default", + "account_type", + null + ] } }, - "required": ["key", "kind", "unit", "value", "source"], + "required": [ + "key", + "kind", + "unit", + "value", + "source" + ], "additionalProperties": false }, "maxItems": 100 From c55b70b9350469c5f020ae038c381570198cbae8 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 09:31:00 +0300 Subject: [PATCH 6/9] Format capability contract snapshot for CI --- contracts/abprops-capabilities.json | 120 +++++----------------------- 1 file changed, 21 insertions(+), 99 deletions(-) diff --git a/contracts/abprops-capabilities.json b/contracts/abprops-capabilities.json index 1afa6681..3575cdd0 100644 --- a/contracts/abprops-capabilities.json +++ b/contracts/abprops-capabilities.json @@ -10,9 +10,7 @@ "/messaging/{session}/groups/{id}/capabilities": { "get": { "operationId": "getGroupCapabilities", - "tags": [ - "Groups" - ], + "tags": ["Groups"], "summary": "Read stored WhatsApp group capabilities", "security": [ { @@ -257,9 +255,7 @@ "x-mint": { "content": "Linked Device API groups:read" }, - "x-protocols": [ - "Linked Device API" - ], + "x-protocols": ["Linked Device API"], "description": "**Protocols:** Linked Device API\n\n**Required scope:** `groups:read`" } } @@ -271,28 +267,20 @@ "properties": { "success": { "type": "boolean", - "enum": [ - true - ] + "enum": [true] }, "data": { "$ref": "#/components/schemas/GroupCapabilities" } }, - "required": [ - "success", - "data" - ] + "required": ["success", "data"] }, "GroupCapabilities": { "type": "object", "properties": { "status": { "type": "string", - "enum": [ - "synced", - "unknown" - ] + "enum": ["synced", "unknown"] }, "syncedAt": { "type": "string", @@ -319,16 +307,12 @@ }, "kind": { "type": "string", - "enum": [ - "feature" - ] + "enum": ["feature"] }, "unit": { "nullable": true, "type": "string", - "enum": [ - null - ] + "enum": [null] }, "value": { "type": "boolean", @@ -337,30 +321,15 @@ "source": { "type": "string", "nullable": true, - "enum": [ - "server", - "client_default", - null - ] + "enum": ["server", "client_default", null] } }, - "required": [ - "key", - "kind", - "unit", - "value", - "source" - ], + "required": ["key", "kind", "unit", "value", "source"], "additionalProperties": false } } }, - "required": [ - "status", - "syncedAt", - "checkedAt", - "capabilities" - ], + "required": ["status", "syncedAt", "checkedAt", "capabilities"], "additionalProperties": false }, "PublicError": { @@ -492,20 +461,12 @@ "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" - ] + "required": ["type", "code", "message", "param", "request_id"] }, "data": { "type": "string", "nullable": true, - "enum": [ - null - ], + "enum": [null], "description": "Always null for a failed request." }, "docs": { @@ -513,11 +474,7 @@ "format": "uri" } }, - "required": [ - "error", - "data", - "docs" - ] + "required": ["error", "data", "docs"] }, "SessionCapabilitiesUpdatedEvent": { "type": "object", @@ -540,22 +497,14 @@ }, "event": { "type": "string", - "enum": [ - "session.capabilities_updated" - ], + "enum": ["session.capabilities_updated"], "description": "Event type" }, "payload": { "$ref": "#/components/schemas/SessionCapabilitiesUpdatedPayload" } }, - "required": [ - "id", - "session", - "timestamp", - "event", - "payload" - ] + "required": ["id", "session", "timestamp", "event", "payload"] }, "SessionCapabilitiesUpdatedPayload": { "type": "object", @@ -570,10 +519,7 @@ }, "status": { "type": "string", - "enum": [ - "synced", - "unknown" - ] + "enum": ["synced", "unknown"] }, "syncedAt": { "type": "string", @@ -588,11 +534,7 @@ "accountType": { "type": "string", "nullable": true, - "enum": [ - "business", - "personal", - null - ] + "enum": ["business", "personal", null] }, "capabilities": { "type": "array", @@ -605,21 +547,12 @@ }, "kind": { "type": "string", - "enum": [ - "feature", - "limit" - ] + "enum": ["feature", "limit"] }, "unit": { "type": "string", "nullable": true, - "enum": [ - "seconds", - "count", - "characters", - "members", - null - ] + "enum": ["seconds", "count", "characters", "members", null] }, "value": { "anyOf": [ @@ -637,21 +570,10 @@ "source": { "type": "string", "nullable": true, - "enum": [ - "server", - "client_default", - "account_type", - null - ] + "enum": ["server", "client_default", "account_type", null] } }, - "required": [ - "key", - "kind", - "unit", - "value", - "source" - ], + "required": ["key", "kind", "unit", "value", "source"], "additionalProperties": false }, "maxItems": 100 From 140a00b6af1c7cd3eb89c8f711e1757460fde772 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 09:47:17 +0300 Subject: [PATCH 7/9] Place capability command documentation in the Groups section --- README.md | 32 ++++++++++++++++---------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/README.md b/README.md index 9ddfae36..8d249c91 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,22 @@ order info editing, messaging, member add, join approval. If one fails, the error names the settings that were already applied. Removing participants requires `--yes`. +### Stored group capabilities + +Use `polymorfa group capabilities GROUP_ID --session SESSION` to read the +linked number's stored group configuration. Add `--json` for the complete +response. This read requires `groups:read`, current group membership, and +number-capabilities beta enrollment. Unknown configuration stays `unknown`; +the command does not open the group chat or fetch its configuration. + +The keys `polls.endTime`, `polls.hideVoters`, and `polls.creatorEdit` describe +WhatsApp configuration. They do not grant poll-editing permissions. The API +must include the group-capabilities contract. + +The capability extension is pinned in `contracts/abprops-capabilities.json` to +API source `205369ff8f5ecb71dd085a190d8ec80aa2b9e483`. This is the same scoped revision consumed by the SDK; +other command contracts and the published SDK dependency keep their recorded revisions. + ## Messages Send text and perform the complete set of source-defined Messaging actions: @@ -2598,19 +2614,3 @@ an API deployment exposing Analytics are separate requirements. ## Package release workflow CLI development and release PRs target `main`. Nightly packages use the `dev` npm tag after matching staging API acceptance; stable packages follow production acceptance. Each package pins an exact accepted SDK version. See [the release guide](RELEASING.md). - -### Stored group capabilities - -Use `polymorfa group capabilities GROUP_ID --session SESSION` to read the -linked number's stored group configuration. Add `--json` for the complete -response. This read requires `groups:read`, current group membership, and -number-capabilities beta enrollment. Unknown configuration stays `unknown`; -the command does not open the group chat or fetch its configuration. - -The keys `polls.endTime`, `polls.hideVoters`, and `polls.creatorEdit` describe -WhatsApp configuration. They do not grant poll-editing permissions. The API -must include the group-capabilities contract. - -The capability extension is pinned in `contracts/abprops-capabilities.json` to -API source `205369ff8f5ecb71dd085a190d8ec80aa2b9e483`. This is the same scoped revision consumed by the SDK; -other command contracts and the published SDK dependency keep their recorded revisions. From 31585973aec2d7091efbf4ef45d7e085d8615e7c Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 10:29:46 +0300 Subject: [PATCH 8/9] chore(contracts): reconcile latest backend base --- contracts/abprops-capabilities.json | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/contracts/abprops-capabilities.json b/contracts/abprops-capabilities.json index 3575cdd0..8aa0afee 100644 --- a/contracts/abprops-capabilities.json +++ b/contracts/abprops-capabilities.json @@ -1,8 +1,8 @@ { "repository": "polymorfa/polymorfa", - "sourceCommit": "205369ff8f5ecb71dd085a190d8ec80aa2b9e483", + "sourceCommit": "45c810c100ffa8379d12260184ee3b8fdfe9d84e", "sourcePath": "apps/api/docs/openapi.json", - "sourceSha256": "dcee7d1cba358d9127d87ff0d9eaf0496adc36a77dd4ef0088e5feb1e9c9e47a", + "sourceSha256": "53b2c76e344052152b3fc627a478adcd79130b7876d528aac8056b13e555b127", "published": false, "scope": "Group capability read and canonical session capability webhook only. Other SDK contracts retain their recorded source revisions.", "openapi": "3.0.3", From 6dea3ac6b2c0e963fa37b4d8f0103ff26e5bdec3 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 10 Oct 2026 10:52:18 +0300 Subject: [PATCH 9/9] chore(contracts): pin merged AB props backend --- contracts/abprops-capabilities.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/contracts/abprops-capabilities.json b/contracts/abprops-capabilities.json index 8aa0afee..c3055b60 100644 --- a/contracts/abprops-capabilities.json +++ b/contracts/abprops-capabilities.json @@ -1,6 +1,6 @@ { "repository": "polymorfa/polymorfa", - "sourceCommit": "45c810c100ffa8379d12260184ee3b8fdfe9d84e", + "sourceCommit": "2f9efad933b3a7a455f73245a566c827ac004608", "sourcePath": "apps/api/docs/openapi.json", "sourceSha256": "53b2c76e344052152b3fc627a478adcd79130b7876d528aac8056b13e555b127", "published": false,