From 7053b15d3ecc9f959b3b154151d4b467435b7c25 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Sat, 17 Oct 2026 09:39:51 +0300 Subject: [PATCH 1/3] Add campaign contact frequency cap commands --- README.md | 25 +++ src/commands/campaign/create.ts | 7 +- src/commands/campaign/recipients/list.ts | 5 + .../campaign/settings/frequency-cap/get.ts | 16 ++ .../campaign/settings/frequency-cap/remove.ts | 29 ++++ .../campaign/settings/frequency-cap/set.ts | 56 +++++++ src/commands/campaign/update.ts | 42 +++++ src/services/campaign-frequency-cap.ts | 131 +++++++++++++++ src/services/campaigns.ts | 26 ++- test/commands/campaign-frequency-cap.test.ts | 155 ++++++++++++++++++ 10 files changed, 485 insertions(+), 7 deletions(-) create mode 100644 src/commands/campaign/settings/frequency-cap/get.ts create mode 100644 src/commands/campaign/settings/frequency-cap/remove.ts create mode 100644 src/commands/campaign/settings/frequency-cap/set.ts create mode 100644 src/commands/campaign/update.ts create mode 100644 src/services/campaign-frequency-cap.ts create mode 100644 test/commands/campaign-frequency-cap.test.ts diff --git a/README.md b/README.md index efbdc3c2..e4aa5c08 100644 --- a/README.md +++ b/README.md @@ -2642,3 +2642,28 @@ 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). + +### Campaign contact frequency caps (beta) + +```sh +polymorfa campaign settings frequency-cap get +polymorfa campaign settings frequency-cap set --max-per-24-hours 2 --max-per-7-days 5 --revision 0 +polymorfa campaign settings frequency-cap remove --revision 1 +polymorfa campaign create --project-slug launch --input campaign.json --frequency-cap-24-hours 1 +polymorfa campaign update CAMPAIGN_ID --project-slug launch --frequency-cap-24-hours 1 +polymorfa campaign update CAMPAIGN_ID --project-slug launch --remove-frequency-cap +polymorfa campaign recipients list CAMPAIGN_ID --project-slug launch --status skipped --reason frequency_cap +``` + +Read the revision before setting or removing team limits. A stale write returns +409; read again and reconcile before retrying. Set replaces both windows; an +omitted window means no limit. Team settings require a team API key with +`campaigns:read` or `campaigns:manage`. New limits require beta access and can +return `403 feature_unavailable`; removal does not require beta access. +Campaign flags replace the campaign cap and keep the team cap in force. +Campaign analytics shows `Skipped by frequency cap`; recipient output includes +`frequency_cap`. Changing limits never retries settled skips. + +This implementation keeps the published SDK pin unchanged. Release waits for a +matching SDK prerelease and accepted API contract; source support does not imply +package publication or beta availability. diff --git a/src/commands/campaign/create.ts b/src/commands/campaign/create.ts index 7b31018e..50fbb12a 100644 --- a/src/commands/campaign/create.ts +++ b/src/commands/campaign/create.ts @@ -1,3 +1,7 @@ +import { + frequencyCapFlags, + frequencyCapInput, +} from '../../services/campaign-frequency-cap.js' import {Flags} from '@oclif/core' import {readJsonInput} from '../../api/request.js' @@ -10,6 +14,7 @@ export default class CampaignCreate extends BaseCommand { 'Create a campaign from one JSON object. Optional beta fields include followUp: one receipt-timed follow-up ({delaySeconds, expirySeconds, message: {type: "text", text} or {type: "template", templateId}}); teams that are not enrolled receive 403 feature_unavailable.' static override flags = { + ...frequencyCapFlags, 'idempotency-key': Flags.string({ description: 'Idempotency-Key header. Generated per invocation when omitted; reuse an explicit key across process retries (24-hour retention).', @@ -29,7 +34,7 @@ export default class CampaignCreate extends BaseCommand { const result = await createCampaign( await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}), flags['project-slug'], - await readJsonInput(flags.input), + frequencyCapInput(await readJsonInput(flags.input), flags), {idempotencyKey: idempotencyKey(flags['idempotency-key'])}, ) this.writeValue(result, renderCampaign(result.data), flags) diff --git a/src/commands/campaign/recipients/list.ts b/src/commands/campaign/recipients/list.ts index 9a7354d2..217cf00f 100644 --- a/src/commands/campaign/recipients/list.ts +++ b/src/commands/campaign/recipients/list.ts @@ -21,6 +21,10 @@ export default class CampaignRecipientsList extends BaseCommand { ] static override flags = { + reason: Flags.string({ + description: + 'Filter by recipient failure reason, including frequency_cap.', + }), cursor: Flags.string({description: 'Continue from this opaque cursor.'}), limit: Flags.integer({ description: 'Request this many recipients. The API defaults to 25.', @@ -45,6 +49,7 @@ export default class CampaignRecipientsList extends BaseCommand { flags['project-slug'], args.campaign, { + ...(flags.reason === undefined ? {} : {reason: flags.reason}), ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), ...(flags.limit === undefined ? {} : {limit: flags.limit}), ...(flags.status === undefined diff --git a/src/commands/campaign/settings/frequency-cap/get.ts b/src/commands/campaign/settings/frequency-cap/get.ts new file mode 100644 index 00000000..33c5b1ff --- /dev/null +++ b/src/commands/campaign/settings/frequency-cap/get.ts @@ -0,0 +1,16 @@ +import {BaseCommand} from '../../../../base-command.js' +import { + getFrequencyCap, + renderFrequencyCap, +} from '../../../../services/campaign-frequency-cap.js' +export default class FrequencyCapGet extends BaseCommand { + static override description = + 'Read team contact frequency caps and their revision.' + + async run(): Promise { + const {flags} = await this.parse(FrequencyCapGet) + + const result = await getFrequencyCap(await this.platformClient(flags)) + this.writeValue(result, renderFrequencyCap(result.data), flags) + } +} diff --git a/src/commands/campaign/settings/frequency-cap/remove.ts b/src/commands/campaign/settings/frequency-cap/remove.ts new file mode 100644 index 00000000..83d715e8 --- /dev/null +++ b/src/commands/campaign/settings/frequency-cap/remove.ts @@ -0,0 +1,29 @@ +import {Flags} from '@oclif/core' +import {BaseCommand} from '../../../../base-command.js' +import { + updateFrequencyCap, + renderFrequencyCap, +} from '../../../../services/campaign-frequency-cap.js' +export default class FrequencyCapRemove extends BaseCommand { + static override description = + 'Remove both team limits, including without beta access. Requires the last read revision.' + static override flags = { + revision: Flags.integer({ + required: true, + min: 0, + max: 2147483646, + description: + 'Revision from get. On 409, read again and reconcile before retrying.', + }), + } + async run(): Promise { + const {flags} = await this.parse(FrequencyCapRemove) + + const result = await updateFrequencyCap(await this.platformClient(flags), { + revision: flags.revision, + maxPer24Hours: null, + maxPer7Days: null, + }) + this.writeValue(result, renderFrequencyCap(result.data), flags) + } +} diff --git a/src/commands/campaign/settings/frequency-cap/set.ts b/src/commands/campaign/settings/frequency-cap/set.ts new file mode 100644 index 00000000..68407a8f --- /dev/null +++ b/src/commands/campaign/settings/frequency-cap/set.ts @@ -0,0 +1,56 @@ +import {Flags} from '@oclif/core' +import {PolymorfaValidationError} from '@polymorfa/sdk' +import {BaseCommand} from '../../../../base-command.js' +import { + updateFrequencyCap, + renderFrequencyCap, +} from '../../../../services/campaign-frequency-cap.js' +export default class FrequencyCapSet extends BaseCommand { + static override description = + 'Replace team limits (beta). Omitted windows become unlimited. Stale revisions return 409; no automatic retry.' + static override flags = { + revision: Flags.integer({ + required: true, + min: 0, + max: 2147483646, + description: + 'Revision from get. On 409, read again and reconcile before retrying.', + }), + 'max-per-24-hours': Flags.integer({ + min: 1, + max: 50, + description: 'Rolling 24-hour limit; omitted means no limit.', + }), + 'max-per-7-days': Flags.integer({ + min: 1, + max: 200, + description: 'Rolling 7-day limit; omitted means no limit.', + }), + } + async run(): Promise { + const {flags} = await this.parse(FrequencyCapSet) + if ( + flags['max-per-24-hours'] === undefined && + flags['max-per-7-days'] === undefined + ) + throw new PolymorfaValidationError( + 'Set at least one window, or use remove.', + {code: 'invalid_frequency_cap'}, + ) + if ( + flags['max-per-24-hours'] !== undefined && + flags['max-per-7-days'] !== undefined && + flags['max-per-24-hours'] > flags['max-per-7-days'] + ) + throw new PolymorfaValidationError( + 'The 24-hour limit cannot exceed the 7-day limit.', + {code: 'invalid_frequency_cap'}, + ) + const result = await updateFrequencyCap(await this.platformClient(flags), { + revision: flags.revision, + maxPer24Hours: flags['max-per-24-hours'] ?? null, + maxPer7Days: flags['max-per-7-days'] ?? null, + }) + this.writeValue(result, renderFrequencyCap(result.data), flags) + } +} diff --git a/src/commands/campaign/update.ts b/src/commands/campaign/update.ts new file mode 100644 index 00000000..75e5b656 --- /dev/null +++ b/src/commands/campaign/update.ts @@ -0,0 +1,42 @@ +import {Args, Flags} from '@oclif/core' +import {BaseCommand} from '../../base-command.js' +import {readJsonInput} from '../../api/request.js' +import { + frequencyCapFlags, + frequencyCapInput, + updateCampaign, +} from '../../services/campaign-frequency-cap.js' +import {renderCampaign} from '../../services/campaigns.js' +export default class CampaignUpdate extends BaseCommand { + static override description = + 'Update a draft or paused campaign. Cap flags replace the campaign cap; omitted windows have no campaign limit.' + static override args = { + campaign: Args.string({required: true, description: 'Campaign ID.'}), + } + static override flags = { + ...frequencyCapFlags, + input: Flags.string({ + description: 'Campaign changes as JSON from file or stdin (-).', + }), + 'project-slug': Flags.string({ + required: true, + description: 'Project slug in the Messaging API path.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(CampaignUpdate) + const body = frequencyCapInput( + flags.input === undefined ? {} : await readJsonInput(flags.input), + flags, + ) + if (Object.keys(body).length === 0) + this.error('Pass --input or a frequency cap flag.') + const result = await updateCampaign( + await this.messagingClient(flags, {serverApiKeyOnly: 'Campaign'}), + flags['project-slug'], + args.campaign, + body, + ) + this.writeValue(result, renderCampaign(result.data), flags) + } +} diff --git a/src/services/campaign-frequency-cap.ts b/src/services/campaign-frequency-cap.ts new file mode 100644 index 00000000..a53ca77b --- /dev/null +++ b/src/services/campaign-frequency-cap.ts @@ -0,0 +1,131 @@ +import {Flags} from '@oclif/core' +import { + type ClientInstance, + type MessagingClient, + PolymorfaValidationError, +} from '@polymorfa/sdk' + +// Local additive types match the SDK frequency-cap PR. Keep the registry pin +// until a prerelease with matching API acceptance is available. +export interface CampaignFrequencyCap { + readonly maxPer24Hours: number | null + readonly maxPer7Days: number | null +} +export interface CampaignFrequencyCapSettings extends CampaignFrequencyCap { + readonly teamId: string + readonly revision: number + readonly updatedAt: number | null +} +export interface UpdateCampaignFrequencyCapInput extends CampaignFrequencyCap { + readonly revision: number +} +export const frequencyCapFlags = { + 'frequency-cap-24-hours': Flags.integer({ + min: 1, + max: 50, + description: 'Per-contact rolling 24-hour limit (beta).', + }), + 'frequency-cap-7-days': Flags.integer({ + min: 1, + max: 200, + description: 'Per-contact rolling 7-day limit (beta).', + }), + 'remove-frequency-cap': Flags.boolean({ + description: 'Remove the campaign cap; the team cap still applies.', + exclusive: ['frequency-cap-24-hours', 'frequency-cap-7-days'], + }), +} +export function frequencyCapInput( + input: unknown, + flags: { + 'frequency-cap-24-hours'?: number | undefined + 'frequency-cap-7-days'?: number | undefined + 'remove-frequency-cap'?: boolean | undefined + }, +): Record { + if (!input || typeof input !== 'object' || Array.isArray(input)) + throw new PolymorfaValidationError('Campaign input must be one object.', { + code: 'invalid_campaign_input', + }) + const daily = flags['frequency-cap-24-hours'] + const weekly = flags['frequency-cap-7-days'] + if (flags['remove-frequency-cap']) return {...input, frequencyCap: null} + if (daily === undefined && weekly === undefined) return {...input} + if (Object.hasOwn(input, 'frequencyCap')) + throw new PolymorfaValidationError( + 'Use cap flags or the input frequencyCap field, not both.', + {code: 'invalid_frequency_cap'}, + ) + if (daily !== undefined && weekly !== undefined && daily > weekly) + throw new PolymorfaValidationError( + 'The 24-hour limit cannot exceed the 7-day limit.', + {code: 'invalid_frequency_cap'}, + ) + return { + ...input, + frequencyCap: { + maxPer24Hours: daily ?? null, + maxPer7Days: weekly ?? null, + } satisfies CampaignFrequencyCap, + } +} +export async function getFrequencyCap(client: ClientInstance<'organization'>) { + const response = await client.raw.request<{ + data: CampaignFrequencyCapSettings + }>({method: 'GET', path: '/platform/campaign-settings/frequency-cap'}) + return {data: response.data.data, metadata: response.metadata} +} +export async function updateFrequencyCap( + client: ClientInstance<'organization'>, + body: UpdateCampaignFrequencyCapInput, +) { + const response = await client.raw.request<{ + data: CampaignFrequencyCapSettings + }>({ + method: 'PUT', + path: '/platform/campaign-settings/frequency-cap', + body, + maxNetworkRetries: 0, + }) + return {data: response.data.data, metadata: response.metadata} +} +export function renderFrequencyCap(cap: CampaignFrequencyCapSettings): string { + return [ + `Per contact, rolling 24 hours\t${cap.maxPer24Hours ?? 'No limit'}`, + `Per contact, rolling 7 days\t${cap.maxPer7Days ?? 'No limit'}`, + `Revision\t${cap.revision}`, + ].join('\n') +} +export async function updateCampaign( + client: MessagingClient, + projectSlug: string, + campaignId: string, + input: unknown, +) { + const response = await client.raw.request<{ + data: import('@polymorfa/sdk').Campaign + }>({ + method: 'PATCH', + path: `/messaging/projects/${encodeURIComponent(projectSlug)}/campaigns/${encodeURIComponent(campaignId)}`, + body: input, + maxNetworkRetries: 0, + }) + return {data: response.data.data, metadata: response.metadata} +} +export function renderFrequencyCapAnalytics(analytics: object): string[] { + const cap = (analytics as {frequencyCap?: unknown}).frequencyCap + if (cap === undefined || cap === null) return [] + const count = + typeof cap === 'object' + ? (cap as {skippedRecipientCount?: unknown}).skippedRecipientCount + : undefined + return [ + `Skipped by frequency cap\t${typeof count === 'number' && Number.isSafeInteger(count) && count >= 0 ? count : 'Unreadable; use --json'}`, + ] +} +export function frequencyCapRecipientCell(recipient: object): string[] { + return (recipient as {failureReason?: unknown}).failureReason === + 'frequency_cap' + ? ['frequency_cap'] + : [] +} diff --git a/src/services/campaigns.ts b/src/services/campaigns.ts index b00eeeff..9152c819 100644 --- a/src/services/campaigns.ts +++ b/src/services/campaigns.ts @@ -1,3 +1,7 @@ +import { + renderFrequencyCapAnalytics, + frequencyCapRecipientCell, +} from './campaign-frequency-cap.js' import { followUpCell, renderFollowUpAnalytics, @@ -136,13 +140,21 @@ export async function listCampaignRecipients( client: MessagingClient, projectSlug: string, campaignId: string, - params: ListCampaignRecipientsParams, + params: ListCampaignRecipientsParams & {readonly reason?: string}, ): Promise> { - const response = await client.campaigns.listRecipients( - projectSlug, - campaignId, - params, - ) + const response = await client.raw.request<{ + data: readonly CampaignRecipient[] + page: CampaignRecipientPage + }>({ + method: 'GET', + path: `/messaging/projects/${encodeURIComponent(projectSlug)}/campaigns/${encodeURIComponent(campaignId)}/recipients`, + query: { + ...(params.status === undefined ? {} : {status: params.status}), + ...(params.reason === undefined ? {} : {reason: params.reason}), + ...(params.cursor === undefined ? {} : {cursor: params.cursor}), + ...(params.limit === undefined ? {} : {limit: params.limit}), + }, + }) return result( {data: response.data.data, page: response.data.page}, response.metadata, @@ -240,6 +252,7 @@ export function renderCampaignAnalytics(analytics: CampaignAnalytics): string { `Responded\t${analytics.respondedCount}`, `Response rate\t${analytics.responseRate}`, ...renderFollowUpAnalytics(analytics), + ...renderFrequencyCapAnalytics(analytics), ].join('\n') } @@ -300,6 +313,7 @@ export function renderCampaignRecipients( recipient.status, String(recipient.attempts), recipient.lastError ?? '', + ...frequencyCapRecipientCell(recipient), formatCampaignTime(recipient.sentAt), ...(followUps ? [followUpCell(recipient)] : []), ].join('\t'), diff --git a/test/commands/campaign-frequency-cap.test.ts b/test/commands/campaign-frequency-cap.test.ts new file mode 100644 index 00000000..e66e9f60 --- /dev/null +++ b/test/commands/campaign-frequency-cap.test.ts @@ -0,0 +1,155 @@ +import {runCommand} from '@oclif/test' +import {afterEach, expect, test} from 'vitest' +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' +import { + renderFrequencyCapAnalytics, + frequencyCapInput, +} from '../../src/services/campaign-frequency-cap.js' +import {renderCampaignRecipients} from '../../src/services/campaigns.js' +let server: TestHttpServer | undefined +afterEach(async () => { + await server?.close() + server = undefined +}) +const settings = { + teamId: 'team', + maxPer24Hours: 2, + maxPer7Days: 5, + revision: 3, + updatedAt: 1800000000000, +} +function run(command: string) { + return withEnvironment( + { + POLYMORFA_API_KEY: `pmfa_${'A'.repeat(72)}`, + POLYMORFA_BASE_URL: server!.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + () => runCommand(command), + ) +} +test('get shows team windows and revision', async () => { + server = await startHttpServer({body: {data: settings}}) + const result = await run('campaign settings frequency-cap get') + expect(result.error).toBeUndefined() + expect(result.stdout).toContain('Revision\t3') + expect(server.requests[0]).toMatchObject({ + method: 'GET', + url: '/platform/campaign-settings/frequency-cap', + }) +}) +test.each([ + ['set --max-per-24-hours 2 --max-per-7-days 5', 2, 5], + ['set --max-per-7-days 5', null, 5], + ['remove', null, null], +] as const)( + '%s writes the supplied revision and both windows', + async (command, daily, weekly) => { + server = await startHttpServer({body: {data: settings}}) + const result = await run( + `campaign settings frequency-cap ${command} --revision 3 --json`, + ) + expect(result.error).toBeUndefined() + expect(server.requests[0]?.method).toBe('PUT') + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + maxPer24Hours: daily, + maxPer7Days: weekly, + revision: 3, + }) + }, +) +test.each([ + [409, 'conflict'], + [403, 'feature_unavailable'], +] as const)('surfaces %s without repeating the write', async (status, code) => { + server = await startHttpServer({ + status, + body: {error: {code, message: 'Read and reconcile or obtain beta access'}}, + }) + const result = await run( + 'campaign settings frequency-cap set --max-per-24-hours 2 --revision 3 --json', + ) + expect(result.error).toBeDefined() + expect(result.stdout + result.stderr).toContain(code) + expect(server.requests).toHaveLength(1) +}) +test('rejects invalid or missing limits before HTTP', async () => { + server = await startHttpServer({body: {data: settings}}) + for (const flags of [ + '--max-per-24-hours 6 --max-per-7-days 5', + '--max-per-24-hours 51', + '', + ]) { + expect( + (await run(`campaign settings frequency-cap set --revision 3 ${flags}`)) + .error, + ).toBeDefined() + } + expect(server.requests).toHaveLength(0) +}) +test('campaign update sends a cap and explicit removal without retry', async () => { + server = await startHttpServer({ + body: { + success: true, + data: {id: 'c', frequencyCap: null, scheduledAt: null}, + }, + }) + const changed = await run( + 'campaign update c --project-slug p --frequency-cap-24-hours 2 --json', + ) + expect(changed.error).toBeUndefined() + expect(JSON.parse(server.requests[0]!.body)).toEqual({ + frequencyCap: {maxPer24Hours: 2, maxPer7Days: null}, + }) + const removed = await run( + 'campaign update c --project-slug p --remove-frequency-cap --json', + ) + expect(removed.error).toBeUndefined() + expect(server.requests[1]?.url).toBe('/messaging/projects/p/campaigns/c') + expect(JSON.parse(server.requests[1]!.body)).toEqual({frequencyCap: null}) +}) +test('cap flags preserve create fields and refuse conflicting JSON', () => { + expect( + frequencyCapInput({name: 'Launch'}, {'frequency-cap-24-hours': 2}), + ).toEqual({ + name: 'Launch', + frequencyCap: {maxPer24Hours: 2, maxPer7Days: null}, + }) + expect(() => + frequencyCapInput({frequencyCap: null}, {'frequency-cap-24-hours': 2}), + ).toThrow('Use cap flags') +}) +test('recipient reason filter reaches the API and human output shows cap skips', async () => { + server = await startHttpServer({ + body: {success: true, data: [], page: {hasMore: false, nextCursor: null}}, + }) + const result = await run( + 'campaign recipients list c --project-slug p --status skipped --reason frequency_cap --json', + ) + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toContain('reason=frequency_cap') + expect( + renderCampaignRecipients({ + data: [ + { + id: 'r', + phone: '+15550000000', + status: 'skipped', + attempts: 0, + lastError: 'frequency_cap_exceeded', + failureReason: 'frequency_cap', + sentAt: null, + } as unknown as import('@polymorfa/sdk').CampaignRecipient, + ], + page: {hasMore: false, nextCursor: null}, + }), + ).toContain('frequency_cap') + expect( + renderFrequencyCapAnalytics({frequencyCap: {skippedRecipientCount: 4}}), + ).toEqual(['Skipped by frequency cap\t4']) + expect(renderFrequencyCapAnalytics({})).toEqual([]) +}) From 0d29b935993f4ed16f9a7b9f5039cb22d9c9e1ed Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 21 Oct 2026 10:38:27 +0300 Subject: [PATCH 2/3] fix: patch MCP dependency to pass production security audit --- package-lock.json | 18 ++++++++++-------- package.json | 2 +- 2 files changed, 11 insertions(+), 9 deletions(-) diff --git a/package-lock.json b/package-lock.json index 2da282d3..d4c1a04c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9,7 +9,7 @@ "version": "0.1.0-dev.0", "license": "MIT", "dependencies": { - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.3.1", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/sdk": "0.1.0-dev.20261004024706", @@ -708,9 +708,10 @@ "dev": true }, "node_modules/@modelcontextprotocol/core": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.0.0.tgz", - "integrity": "sha512-pJCEwGG7Lfr/+PQp9ZTwKXNeO5wzbfKL7H3MYpCorM4oFBoQrdjnBgEoqG+RjhsvS1FKrDbKux+M1HhlnGWqcA==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/core/-/core-2.3.1.tgz", + "integrity": "sha512-laVmIhPGpWi7rG9q+0UigblRN0Hws/Yjlb6qLFtVqryXVLkc4h+yN8z731POD2LnAGQlFe7/tXhUm7Bi0YB91g==", + "license": "Apache-2.0", "dependencies": { "zod": "^4.2.0" }, @@ -719,11 +720,12 @@ } }, "node_modules/@modelcontextprotocol/server": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.0.0.tgz", - "integrity": "sha512-YhHWdHfpFMQfd0prsEnxKeS3Qz3ytIGmsS0sth4KDjnacIT7hxk6hXHkJ9KysxlkvTM+WZAtQbbcUhdoP4Hvtw==", + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@modelcontextprotocol/server/-/server-2.3.1.tgz", + "integrity": "sha512-e59MfWuSssj6DoQ96JCTqdW1AXT5/XtnTl0WWd6MU1Ka434fFUGGosCLJbGNySDhHGvxQWoRWK137FXYFkYNWw==", + "license": "Apache-2.0", "dependencies": { - "@modelcontextprotocol/core": "2.0.0", + "@modelcontextprotocol/core": "2.3.1", "zod": "^4.2.0" }, "engines": { diff --git a/package.json b/package.json index 97f2c5b9..780699f0 100644 --- a/package.json +++ b/package.json @@ -30,7 +30,7 @@ "verify": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run build && npm pack --dry-run --ignore-scripts && npm run audit:prod" }, "dependencies": { - "@modelcontextprotocol/server": "2.0.0", + "@modelcontextprotocol/server": "2.3.1", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", "@polymorfa/sdk": "0.1.0-dev.20261004024706", From eb88aa7147ec42b65a0433db067dfab6fd8c1b06 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 21 Oct 2026 10:55:47 +0300 Subject: [PATCH 3/3] fix: reject inline cap conflicts before campaign cap removal --- src/services/campaign-frequency-cap.ts | 9 +++- test/commands/campaign-frequency-cap.test.ts | 53 ++++++++++++++++++++ test/commands/campaigns.test.ts | 3 +- 3 files changed, 62 insertions(+), 3 deletions(-) diff --git a/src/services/campaign-frequency-cap.ts b/src/services/campaign-frequency-cap.ts index a53ca77b..d2e7a1e1 100644 --- a/src/services/campaign-frequency-cap.ts +++ b/src/services/campaign-frequency-cap.ts @@ -49,13 +49,18 @@ export function frequencyCapInput( }) const daily = flags['frequency-cap-24-hours'] const weekly = flags['frequency-cap-7-days'] - if (flags['remove-frequency-cap']) return {...input, frequencyCap: null} - if (daily === undefined && weekly === undefined) return {...input} + if ( + !flags['remove-frequency-cap'] && + daily === undefined && + weekly === undefined + ) + return {...input} if (Object.hasOwn(input, 'frequencyCap')) throw new PolymorfaValidationError( 'Use cap flags or the input frequencyCap field, not both.', {code: 'invalid_frequency_cap'}, ) + if (flags['remove-frequency-cap']) return {...input, frequencyCap: null} if (daily !== undefined && weekly !== undefined && daily > weekly) throw new PolymorfaValidationError( 'The 24-hour limit cannot exceed the 7-day limit.', diff --git a/test/commands/campaign-frequency-cap.test.ts b/test/commands/campaign-frequency-cap.test.ts index e66e9f60..eef9d455 100644 --- a/test/commands/campaign-frequency-cap.test.ts +++ b/test/commands/campaign-frequency-cap.test.ts @@ -1,3 +1,6 @@ +import {mkdir, mkdtemp, rm, writeFile} from 'node:fs/promises' +import {homedir} from 'node:os' +import {join} from 'node:path' import {runCommand} from '@oclif/test' import {afterEach, expect, test} from 'vitest' import { @@ -10,10 +13,13 @@ import { frequencyCapInput, } from '../../src/services/campaign-frequency-cap.js' import {renderCampaignRecipients} from '../../src/services/campaigns.js' +let directory: string | undefined let server: TestHttpServer | undefined afterEach(async () => { await server?.close() server = undefined + if (directory) await rm(directory, {recursive: true, force: true}) + directory = undefined }) const settings = { teamId: 'team', @@ -153,3 +159,50 @@ test('recipient reason filter reaches the API and human output shows cap skips', ).toEqual(['Skipped by frequency cap\t4']) expect(renderFrequencyCapAnalytics({})).toEqual([]) }) + +test.each([null, {maxPer24Hours: 2, maxPer7Days: 5}])( + 'removal rejects an inline cap (%j) with the existing conflict error', + (frequencyCap) => { + expect(() => + frequencyCapInput({frequencyCap}, {'remove-frequency-cap': true}), + ).toThrow('Use cap flags or the input frequencyCap field, not both.') + try { + frequencyCapInput({frequencyCap}, {'remove-frequency-cap': true}) + } catch (error) { + expect(error).toMatchObject({code: 'invalid_frequency_cap'}) + } + expect( + frequencyCapInput({name: 'Launch'}, {'remove-frequency-cap': true}), + ).toEqual({name: 'Launch', frequencyCap: null}) + }, +) + +test.each(['create', 'update c'])( + '%s rejects inline cap plus removal before HTTP', + async (command) => { + const base = join( + homedir(), + '.polymorfa-agent-work/campaigns-overnight-20261021/cli-test-inputs', + ) + await mkdir(base, {recursive: true}) + directory = await mkdtemp(join(base, 'cap-conflict-')) + const file = join(directory, 'campaign.json') + await writeFile( + file, + JSON.stringify({ + name: 'Launch', + frequencyCap: {maxPer24Hours: 2, maxPer7Days: null}, + }), + ) + server = await startHttpServer({body: {data: settings}}) + const result = await run( + `campaign ${command} --project-slug p --input ${file} --remove-frequency-cap --json`, + ) + expect(result.error).toBeDefined() + expect(result.stdout + result.stderr).toContain('invalid_frequency_cap') + expect(result.stdout + result.stderr).toContain( + 'Use cap flags or the input frequencyCap field, not both.', + ) + expect(server.requests).toHaveLength(0) + }, +) diff --git a/test/commands/campaigns.test.ts b/test/commands/campaigns.test.ts index 41c71447..c42c87f8 100644 --- a/test/commands/campaigns.test.ts +++ b/test/commands/campaigns.test.ts @@ -57,7 +57,7 @@ describe('Messaging campaign commands', () => { const result = await apiKeyEnvironment(() => runCommand( - `campaign create --project-slug project_1 --input ${input} ${key === undefined ? '' : `--idempotency-key ${key}`} --json`, + `campaign create --project-slug project_1 --input ${input} --frequency-cap-24-hours 2 ${key === undefined ? '' : `--idempotency-key ${key}`} --json`, ), ) @@ -67,6 +67,7 @@ describe('Messaging campaign commands', () => { recipientListId: 'recipients_1', senderConfig: {session: 'session_1'}, templateId: 'template_1', + frequencyCap: {maxPer24Hours: 2, maxPer7Days: null}, }) expect(server.requests[0]).toMatchObject({ method: 'POST',