diff --git a/README.md b/README.md index 90d448e9..a8167eec 100644 --- a/README.md +++ b/README.md @@ -2480,6 +2480,28 @@ Use `function invocation list FUNCTION_UUID` and `function invocation view FUNCT Function, deployment, secret and invocation lists accept `--limit 1..100` and `--before UUID`. Deployment lists return metadata without source, including with `--json`. `function deployment view` exposes source with `--json`; secret reads always return metadata. +### Optional STOP confirmations + +STOP acknowledgements are disabled by default and require beta access to enable. +Read the team UUID and revision before changing the preference: + +```bash +polymorfa optout confirmations view +polymorfa optout confirmations summary --json +polymorfa optout confirmations update --team-id TEAM_UUID --revision 0 --no-enabled +``` + +To opt in, use the observed team/revision with `--enabled --locale en`, +`--locale pt-BR`, or `--locale es`. No language is inferred. Reads require a +team key with `campaigns:read`; saves require `campaigns:manage`. Disabling +remains available after beta withdrawal. + +Preference saves never retry automatically. A stale revision returns an error; +read settings again after a conflict or uncertain save. The outcome summary +covers 30 days. Provider acceptance does not confirm delivery, and unknown +acknowledgements are never resent. Suppression remains recorded after any +acknowledgement failure. + ### Call reaction and hand commands `calls react CALL --connection-id CONNECTION --emoji '👍'` sends a reaction; diff --git a/package-lock.json b/package-lock.json index 1e5187e9..068d9b9f 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20261005230914", + "@polymorfa/sdk": "0.1.0-dev.20261005233122", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,9 +997,9 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20261005230914", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261005230914.tgz", - "integrity": "sha512-PaY2sBAbOcJgv3/5pPqguVK3CC6rL9HwhfWT+EN1tSIN1GDCXmsERVHPrBayaUfcUKGEWZeO9cS7xjq3yO1vGw==", + "version": "0.1.0-dev.20261005233122", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20261005233122.tgz", + "integrity": "sha512-zxtkOFHJbJ1+DX6L32MaLr1DzOUWsW/VKWtSObeb1ERrHboiLNeIKQp+I2IOpxX3LaQSTr76AI0g0pDyajyc5A==", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index 59ae1e94..d8a63169 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20261005230914", + "@polymorfa/sdk": "0.1.0-dev.20261005233122", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -300,6 +300,9 @@ "function:invocation": { "description": "Invoke Functions once and inspect metadata receipts." }, + "optout:confirmations": { + "description": "Read and configure optional STOP acknowledgements (beta). Disabled by default." + }, "cloud": { "description": "Official API Number operations (beta). Team enrollment and scopes apply." }, diff --git a/src/commands/optout/confirmations/summary.ts b/src/commands/optout/confirmations/summary.ts new file mode 100644 index 00000000..c5b1efb9 --- /dev/null +++ b/src/commands/optout/confirmations/summary.ts @@ -0,0 +1,17 @@ +import {BaseCommand} from '../../../base-command.js' +import { + readConfirmationSummary, + renderConfirmationSummary, +} from '../../../services/stop-confirmations.js' + +export default class ConfirmationSummary extends BaseCommand { + static override description = + 'Read 30-day STOP acknowledgement outcome counts. Provider acceptance does not confirm delivery.' + async run(): Promise { + const {flags} = await this.parse(ConfirmationSummary) + const result = await readConfirmationSummary( + await this.platformClient(flags), + ) + this.writeValue(result, renderConfirmationSummary(result.data), flags) + } +} diff --git a/src/commands/optout/confirmations/update.ts b/src/commands/optout/confirmations/update.ts new file mode 100644 index 00000000..94f7db6e --- /dev/null +++ b/src/commands/optout/confirmations/update.ts @@ -0,0 +1,50 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../base-command.js' +import { + confirmationPreference, + renderConfirmationSettings, + updateConfirmationSettings, +} from '../../../services/stop-confirmations.js' + +export default class ConfirmationSettingsUpdate extends BaseCommand { + static override description = + 'Save an optional STOP acknowledgement preference using the observed team and revision. Enabling requires beta access and an explicit language. This write is never retried automatically; read settings after an uncertain save.' + static override examples = [ + '<%= config.bin %> <%= command.id %> --team-id TEAM_UUID --revision 0 --no-enabled', + '<%= config.bin %> <%= command.id %> --team-id TEAM_UUID --revision 1 --enabled --locale pt-BR', + ] + static override flags = { + enabled: Flags.boolean({ + allowNo: true, + required: true, + description: + 'Explicitly enable acknowledgements or pass --no-enabled to disable. Suppression stays in place.', + }), + locale: Flags.string({ + options: ['en', 'pt-BR', 'es'], + description: + 'Acknowledgement language; required when enabling. No language is inferred.', + }), + revision: Flags.integer({ + required: true, + min: 0, + max: 2147483646, + description: + 'Observed settings revision from confirmations view. Stale revisions fail with 409.', + }), + 'team-id': Flags.string({ + required: true, + description: + 'Team UUID returned by confirmations view; must match the authenticated team.', + }), + } + async run(): Promise { + const {flags} = await this.parse(ConfirmationSettingsUpdate) + const body = confirmationPreference(flags) + const result = await updateConfirmationSettings( + await this.platformClient(flags), + body, + ) + this.writeValue(result, renderConfirmationSettings(result.data), flags) + } +} diff --git a/src/commands/optout/confirmations/view.ts b/src/commands/optout/confirmations/view.ts new file mode 100644 index 00000000..111f6ad7 --- /dev/null +++ b/src/commands/optout/confirmations/view.ts @@ -0,0 +1,17 @@ +import {BaseCommand} from '../../../base-command.js' +import { + readConfirmationSettings, + renderConfirmationSettings, +} from '../../../services/stop-confirmations.js' + +export default class ConfirmationSettingsView extends BaseCommand { + static override description = + 'Read the team’s optional STOP acknowledgement preference. Disabled by default; enabling requires beta access.' + async run(): Promise { + const {flags} = await this.parse(ConfirmationSettingsView) + const result = await readConfirmationSettings( + await this.platformClient(flags), + ) + this.writeValue(result, renderConfirmationSettings(result.data), flags) + } +} diff --git a/src/services/stop-confirmations.ts b/src/services/stop-confirmations.ts new file mode 100644 index 00000000..70a47f74 --- /dev/null +++ b/src/services/stop-confirmations.ts @@ -0,0 +1,154 @@ +import { + PolymorfaValidationError, + type ClientInstance, + type StopConfirmationSettings, + type StopConfirmationSummary, + type UpdateStopConfirmationSettingsRequest, + type ResponseMetadata, +} from '@polymorfa/sdk' +import {z} from 'zod' + +const Locale = z.enum(['en', 'pt-BR', 'es']) +const Preference = z + .object({ + teamId: z.uuid(), + enabled: z.boolean(), + locale: Locale.nullable(), + revision: z.number().int().min(0).max(2147483646), + }) + .strict() + .refine( + (value) => !value.enabled || value.locale !== null, + 'Select --locale when enabling confirmations.', + ) +const Settings = z + .object({ + teamId: z.uuid(), + enabled: z.boolean(), + locale: Locale.nullable(), + revision: z.number().int().nonnegative(), + textRevision: z.literal(1), + acknowledgement: z.string().nullable(), + updatedAt: z.number().nullable(), + }) + .strict() +const Summary = z + .object({ + enabled: z.boolean(), + counts: z + .object({ + queued: z.number().int().nonnegative(), + admitted: z.number().int().nonnegative(), + completed: z.number().int().nonnegative(), + failed: z.number().int().nonnegative(), + unknown: z.number().int().nonnegative(), + }) + .strict(), + }) + .strict() + +type Client = ClientInstance<'organization'> +function parseResponse( + schema: z.ZodType, + value: unknown, + message: string, +): T { + const parsed = schema.safeParse(value) + if (!parsed.success) + throw new PolymorfaValidationError(message, { + code: 'invalid_confirmation_response', + }) + return parsed.data +} +const UNEXPECTED_READ = + 'The STOP confirmation response did not match the expected shape. Update the CLI.' +function result(data: T, metadata: ResponseMetadata) { + return { + data, + metadata: { + attempts: metadata.attempts, + status: metadata.status, + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + ...(metadata.apiVersion === undefined + ? {} + : {apiVersion: metadata.apiVersion}), + }, + } +} +export async function readConfirmationSettings(client: Client) { + const response = await client.optOuts.getConfirmationSettings() + return result( + parseResponse(Settings, response.data.data, UNEXPECTED_READ), + response.metadata, + ) +} +export async function readConfirmationSummary(client: Client) { + const response = await client.optOuts.getConfirmationSummary() + return result( + parseResponse(Summary, response.data.data, UNEXPECTED_READ), + response.metadata, + ) +} +export function confirmationPreference(flags: { + enabled: boolean + 'team-id': string + revision: number + locale?: string | undefined +}): UpdateStopConfirmationSettingsRequest { + const parsed = Preference.safeParse({ + teamId: flags['team-id'], + enabled: flags.enabled, + locale: flags.locale ?? null, + revision: flags.revision, + }) + if (!parsed.success) + throw new PolymorfaValidationError( + 'Invalid STOP confirmation preference. Check the team UUID, revision, and language.', + {code: 'invalid_confirmation_preference'}, + ) + return parsed.data as UpdateStopConfirmationSettingsRequest +} +export async function updateConfirmationSettings( + client: Client, + body: UpdateStopConfirmationSettingsRequest, +) { + const response = await client.optOuts.updateConfirmationSettings(body, { + maxNetworkRetries: 0, + }) + // The save already ran; an unexpected body does not prove it failed. + return result( + parseResponse( + Settings, + response.data.data, + 'The STOP confirmation response did not match the expected shape. Run `polymorfa optout confirmations view` to check whether the preference was saved.', + ), + response.metadata, + ) +} +export function renderConfirmationSettings( + settings: StopConfirmationSettings, +): string { + return [ + `Team\t${settings.teamId}`, + `Enabled\t${settings.enabled}`, + `Language\t${settings.locale ?? 'Not selected'}`, + `Revision\t${settings.revision}`, + `Acknowledgement\t${settings.acknowledgement ?? 'Not selected'}`, + ].join('\n') +} +export function renderConfirmationSummary( + summary: StopConfirmationSummary, +): string { + return [ + `Enabled\t${summary.enabled}`, + 'Outcomes in the last 30 days', + `Queued\t${summary.counts.queued}`, + `Admitted\t${summary.counts.admitted}`, + `Provider accepted\t${summary.counts.completed}`, + `Skipped or rejected\t${summary.counts.failed}`, + `Unknown\t${summary.counts.unknown}`, + 'Provider acceptance does not confirm delivery. Unknown outcomes must not be resent; suppression remains recorded.', + ].join('\n') +} diff --git a/test/commands/stop-confirmations.test.ts b/test/commands/stop-confirmations.test.ts new file mode 100644 index 00000000..cad2b45b --- /dev/null +++ b/test/commands/stop-confirmations.test.ts @@ -0,0 +1,158 @@ +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const teamId = '019d0000-0000-7000-8000-000000000002' +const settings = { + teamId, + enabled: false, + locale: null, + revision: 0, + textRevision: 1, + acknowledgement: null, + updatedAt: null, +} +let server: TestHttpServer | undefined +afterEach(async () => { + await server?.close() + server = undefined +}) +function run(command: string) { + return withEnvironment( + { + POLYMORFA_API_KEY: `pmfa_${'A'.repeat(72)}`, + POLYMORFA_BASE_URL: server!.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + () => runCommand(command), + ) +} +describe('STOP confirmation commands', () => { + test('reads the exact team and revision with no implicit enable', async () => { + server = await startHttpServer({body: {data: settings}}) + const result = await run('optout confirmations view') + expect(result.error).toBeUndefined() + expect(result.stdout).toContain(`Team\t${teamId}`) + expect(result.stdout).toContain('Enabled\tfalse') + expect( + server.requests.map((request) => [request.method, request.url]), + ).toEqual([['GET', '/platform/optouts/confirmation-settings']]) + }) + test('returns a machine-readable summary without labelling acceptance as delivery', async () => { + const summary = { + enabled: false, + counts: {queued: 0, admitted: 1, completed: 2, failed: 3, unknown: 4}, + } + server = await startHttpServer({body: {data: summary}}) + const result = await run('optout confirmations summary --json') + expect(result.error).toBeUndefined() + expect((JSON.parse(result.stdout) as {data: unknown}).data).toEqual(summary) + expect(server.requests[0]?.url).toBe( + '/platform/optouts/confirmation-summary', + ) + }) + test('saves the supplied team, revision and explicit locale once', async () => { + server = await startHttpServer({ + body: { + data: { + ...settings, + enabled: true, + locale: 'pt-BR', + revision: 3, + acknowledgement: 'Confirmed', + }, + }, + }) + const result = await run( + `optout confirmations update --team-id ${teamId} --revision 2 --enabled --locale pt-BR`, + ) + expect(result.error).toBeUndefined() + expect(server.requests).toHaveLength(1) + expect(server.requests[0]?.method).toBe('PUT') + expect(server.requests[0]?.url).toBe( + '/platform/optouts/confirmation-settings', + ) + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + teamId, + revision: 2, + enabled: true, + locale: 'pt-BR', + }) + }) + test('disables explicitly without a locale', async () => { + server = await startHttpServer({body: {data: {...settings, revision: 3}}}) + const result = await run( + `optout confirmations update --team-id ${teamId} --revision 2 --no-enabled`, + ) + expect(result.error).toBeUndefined() + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + teamId, + revision: 2, + enabled: false, + locale: null, + }) + }) + test.each([ + `--team-id ${teamId} --revision 0 --enabled`, + `--team-id ${teamId} --revision -1 --no-enabled`, + `--team-id ${teamId} --revision 2147483647 --no-enabled`, + `--team-id ${teamId} --revision 0 --enabled --locale fr`, + `--team-id bad --revision 0 --no-enabled`, + `--team-id ${teamId} --revision 0`, + ])( + 'rejects malformed or incomplete preference %s before HTTP', + async (args) => { + server = await startHttpServer({body: {data: settings}}) + const result = await run(`optout confirmations update ${args} --json`) + expect(result.error).toBeDefined() + expect((result.error as {oclif?: {exit?: number}}).oclif?.exit).toBe(2) + expect(JSON.parse(result.stderr)).toMatchObject({ + error: {category: 'validation'}, + }) + expect(server.requests).toHaveLength(0) + }, + ) + test('reports an unexpected save response as validation and points to view', async () => { + server = await startHttpServer({body: {data: {teamId, enabled: 'yes'}}}) + const result = await run( + `optout confirmations update --team-id ${teamId} --revision 2 --no-enabled --json`, + ) + expect((result.error as {oclif?: {exit?: number}}).oclif?.exit).toBe(2) + const body = JSON.parse(result.stderr) as { + error: {category: string; message: string} + } + expect(body.error.category).toBe('validation') + expect(body.error.message).toContain('optout confirmations view') + expect(server.requests).toHaveLength(1) + }) + test.each([403, 409, 503])( + 'reports %i without retrying or claiming success', + async (status) => { + server = await startHttpServer({ + status, + body: { + error: { + code: status === 409 ? 'state_conflict' : 'feature_unavailable', + message: 'Unavailable', + }, + }, + }) + const result = await run( + `optout confirmations update --team-id ${teamId} --revision 0 --no-enabled`, + ) + expect(result.error).toBeDefined() + expect(server.requests).toHaveLength(1) + expect(result.stdout).not.toContain('Enabled\t') + }, + ) + test('rejects malformed settings responses rather than showing a saved preference', async () => { + server = await startHttpServer({body: {data: {enabled: true}}}) + const result = await run('optout confirmations view') + expect(result.error).toBeDefined() + expect(result.stdout).not.toContain('Enabled\ttrue') + }) +})