From 3b66f3ad6d374bc8d7d800115f54530297162f07 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 22:42:43 +0200 Subject: [PATCH 1/3] feat: add explicit STOP confirmation commands --- README.md | 22 +++ package.json | 3 + src/commands/optout/confirmations/summary.ts | 17 +++ src/commands/optout/confirmations/update.ts | 50 +++++++ src/commands/optout/confirmations/view.ts | 17 +++ src/services/stop-confirmations.ts | 120 ++++++++++++++++ test/commands/stop-confirmations.test.ts | 142 +++++++++++++++++++ 7 files changed, 371 insertions(+) create mode 100644 src/commands/optout/confirmations/summary.ts create mode 100644 src/commands/optout/confirmations/update.ts create mode 100644 src/commands/optout/confirmations/view.ts create mode 100644 src/services/stop-confirmations.ts create mode 100644 test/commands/stop-confirmations.test.ts diff --git a/README.md b/README.md index 349fe3db..9c73259f 100644 --- a/README.md +++ b/README.md @@ -1996,3 +1996,25 @@ Invocation input contains an optional `deploymentId`, `trigger` (`test` or `http Use `function invocation list FUNCTION_UUID` and `function invocation view FUNCTION_UUID INVOCATION_UUID` to reconcile a result. Invocation inputs, responses and customer log text are not retained. `--json` includes the transient result when the initial invocation returns it. The default display prints metadata and HTTP status without response content. An uncertain, failed, running or unavailable invocation, or an HTTP response of 400 or higher, exits with code 8 after printing its receipt. Check customer effects before using a new key unless `retryable` explicitly says execution did not start. 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. diff --git a/package.json b/package.json index 03953d32..b6b5e2ac 100644 --- a/package.json +++ b/package.json @@ -293,6 +293,9 @@ }, "function:invocation": { "description": "Invoke Functions once and inspect metadata receipts." + }, + "optout:confirmations": { + "description": "Read and configure optional STOP acknowledgements (beta). Disabled by default." } } }, 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..8b1f60ff --- /dev/null +++ b/src/services/stop-confirmations.ts @@ -0,0 +1,120 @@ +import { + 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 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(Settings.parse(response.data.data), response.metadata) +} +export async function readConfirmationSummary(client: Client) { + const response = await client.optOuts.getConfirmationSummary() + return result(Summary.parse(response.data.data), response.metadata) +} +export function confirmationPreference(flags: { + enabled: boolean + 'team-id': string + revision: number + locale?: string | undefined +}): UpdateStopConfirmationSettingsRequest { + const value = Preference.parse({ + teamId: flags['team-id'], + enabled: flags.enabled, + locale: flags.locale ?? null, + revision: flags.revision, + }) + return value as UpdateStopConfirmationSettingsRequest +} +export async function updateConfirmationSettings( + client: Client, + body: UpdateStopConfirmationSettingsRequest, +) { + const response = await client.optOuts.updateConfirmationSettings(body, { + maxNetworkRetries: 0, + }) + return result(Settings.parse(response.data.data), 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..63ef3d47 --- /dev/null +++ b/test/commands/stop-confirmations.test.ts @@ -0,0 +1,142 @@ +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}}) + expect( + (await run(`optout confirmations update ${args}`)).error, + ).toBeDefined() + expect(server.requests).toHaveLength(0) + }, + ) + 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') + }) +}) From 89d520ef9a56aa7c8ca19be82fc8b8debc909d10 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 26 Sep 2026 23:33:06 +0200 Subject: [PATCH 2/3] Report malformed STOP preferences as validation errors --- src/services/stop-confirmations.ts | 10 ++++++++-- test/commands/stop-confirmations.test.ts | 9 ++++++--- 2 files changed, 14 insertions(+), 5 deletions(-) diff --git a/src/services/stop-confirmations.ts b/src/services/stop-confirmations.ts index 8b1f60ff..81370e05 100644 --- a/src/services/stop-confirmations.ts +++ b/src/services/stop-confirmations.ts @@ -1,4 +1,5 @@ import { + PolymorfaValidationError, type ClientInstance, type StopConfirmationSettings, type StopConfirmationSummary, @@ -76,13 +77,18 @@ export function confirmationPreference(flags: { revision: number locale?: string | undefined }): UpdateStopConfirmationSettingsRequest { - const value = Preference.parse({ + const parsed = Preference.safeParse({ teamId: flags['team-id'], enabled: flags.enabled, locale: flags.locale ?? null, revision: flags.revision, }) - return value as UpdateStopConfirmationSettingsRequest + 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, diff --git a/test/commands/stop-confirmations.test.ts b/test/commands/stop-confirmations.test.ts index 63ef3d47..ef68b400 100644 --- a/test/commands/stop-confirmations.test.ts +++ b/test/commands/stop-confirmations.test.ts @@ -107,9 +107,12 @@ describe('STOP confirmation commands', () => { 'rejects malformed or incomplete preference %s before HTTP', async (args) => { server = await startHttpServer({body: {data: settings}}) - expect( - (await run(`optout confirmations update ${args}`)).error, - ).toBeDefined() + 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) }, ) From 7d84fe998f07927920f902c4ac099650cbf5154d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Tue, 6 Oct 2026 02:40:11 +0300 Subject: [PATCH 3/3] Report unexpected STOP confirmation responses as validation errors --- src/services/stop-confirmations.ts | 34 +++++++++++++++++++++--- test/commands/stop-confirmations.test.ts | 13 +++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) diff --git a/src/services/stop-confirmations.ts b/src/services/stop-confirmations.ts index 81370e05..70a47f74 100644 --- a/src/services/stop-confirmations.ts +++ b/src/services/stop-confirmations.ts @@ -48,6 +48,20 @@ const Summary = z .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, @@ -65,11 +79,17 @@ function result(data: T, metadata: ResponseMetadata) { } export async function readConfirmationSettings(client: Client) { const response = await client.optOuts.getConfirmationSettings() - return result(Settings.parse(response.data.data), response.metadata) + 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(Summary.parse(response.data.data), response.metadata) + return result( + parseResponse(Summary, response.data.data, UNEXPECTED_READ), + response.metadata, + ) } export function confirmationPreference(flags: { enabled: boolean @@ -97,7 +117,15 @@ export async function updateConfirmationSettings( const response = await client.optOuts.updateConfirmationSettings(body, { maxNetworkRetries: 0, }) - return result(Settings.parse(response.data.data), response.metadata) + // 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, diff --git a/test/commands/stop-confirmations.test.ts b/test/commands/stop-confirmations.test.ts index ef68b400..cad2b45b 100644 --- a/test/commands/stop-confirmations.test.ts +++ b/test/commands/stop-confirmations.test.ts @@ -116,6 +116,19 @@ describe('STOP confirmation commands', () => { 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) => {