diff --git a/README.md b/README.md index 1229fb65..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: diff --git a/contracts/abprops-capabilities.json b/contracts/abprops-capabilities.json new file mode 100644 index 00000000..c3055b60 --- /dev/null +++ b/contracts/abprops-capabilities.json @@ -0,0 +1,605 @@ +{ + "repository": "polymorfa/polymorfa", + "sourceCommit": "2f9efad933b3a7a455f73245a566c827ac004608", + "sourcePath": "apps/api/docs/openapi.json", + "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", + "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 successful number and group syncs for the linked account. 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" + } + } + }, + "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": { + "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": { + "nullable": true, + "type": "string", + "enum": [null] + }, + "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", + "nullable": true + }, + { + "type": "integer", + "nullable": true + } + ], + "nullable": true + }, + "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 + } + } + }, + "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}])) +}) 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},