diff --git a/README.md b/README.md index 1917dc86..f985bf17 100644 --- a/README.md +++ b/README.md @@ -502,6 +502,66 @@ order-history command. The source exposes no catalog-independent list, product or collection search/history, binary upload, resumability, or Business App support for project credentials, client tokens, or Cloud API sessions. +## BanSafe + +Inspect current and historical Health, the bounded signal catalogue, collected +telemetry, collection evidence, and durable Health actions with a Platform +server API key: + +```shell +polymorfa bansafe health list --project project_123 +polymorfa bansafe health view session_123 +polymorfa bansafe health history session_123 --since 2026-09-01T00:00:00Z +polymorfa bansafe signal list +polymorfa bansafe telemetry view session_123 +polymorfa bansafe telemetry history session_123 --limit 20 +polymorfa bansafe collection list --project project_123 +polymorfa bansafe health-action list --project project_123 --status failed +polymorfa bansafe health-policy view --project project_123 +polymorfa bansafe health-policy update \ + --project project_123 \ + --input health-policy.json \ + --idempotency-key health-policy-v4 +polymorfa bansafe finding list --project project_123 --status open +polymorfa bansafe enforcement list --project project_123 +polymorfa bansafe incident list --project project_123 +polymorfa bansafe incident report \ + --session session_123 \ + --idempotency-key customer-incident-123 +polymorfa bansafe incident retract incident_123 \ + --idempotency-key retract-incident-123 +polymorfa bansafe claim list --project project_123 +polymorfa bansafe claim view claim_123 +polymorfa bansafe safe-mode view --project project_123 +polymorfa bansafe safe-mode view session_123 +polymorfa bansafe safe-mode update --project project_123 \ + --input safe-mode.json --idempotency-key safe-mode-v2 +polymorfa bansafe warmup view --project project_123 +polymorfa bansafe warmup update --project project_123 \ + --input warmup.json --idempotency-key warmup-v2 +polymorfa bansafe insurance-evidence view --project project_123 +polymorfa bansafe insurance-evidence update --project project_123 \ + --input insurance-evidence.json --idempotency-key insurance-evidence-v2 +``` + +Collection output reports observed ingestion state: `fresh`, `stale`, +`not_collected`, or `unsupported`. It does not infer whether collection is +configured. Telemetry history returns bounded snapshots and explicit +per-signal measurement state. Missing signals are not reported as healthy +measurements. + +Health policy updates replace the complete policy and require its current +version. The CLI validates action and notification combinations before sending +the request. Health policy, Safe Mode, warmup, and insurance-evidence updates +require an idempotency key. Safe Mode and warmup updates accept bounded JSON +patches. Customer incident reports and retractions also require an idempotency key. Finding +acknowledgement and enforcement appeals remain dashboard-only and are not +available through server-key commands. Claim `amountCents`, `measuredCents`, and +`capCents` are credit quantities with up to six decimal places, not cash minor +units; text output labels them as credits. Use `--json` to retain explanations, +observed account state, collection metadata, action outcomes, cursors, and +request metadata. + ## Platform access and security Organization server API keys can inspect the credential, membership, audit, diff --git a/docs/development/bansafe-sdk-integration.md b/docs/development/bansafe-sdk-integration.md new file mode 100644 index 00000000..fe4075b2 --- /dev/null +++ b/docs/development/bansafe-sdk-integration.md @@ -0,0 +1,59 @@ +# BanSafe SDK integration check + +The BanSafe CLI commands depend on the matching typed SDK revision pinned in +`package.json`. During coordinated SDK and CLI development, verify both +worktrees without replacing that immutable dependency in either manifest: + +The pinned SDK revision is `polymorfa/sdks@8803f0554314214519971cb3306e0c0efe21cb76`. +Its coverage ledger is reconciled against the API contracts at +`polymorfa/polymorfa@aca849cda44ad8582d7ae87489404d2483173a53` (Platform +snapshot SHA-256 +`2a30e8e0af8902703609f8ed0f5bb2c108b39c5c11863d5ebd8907e56a9f25d2`). + +```shell +export POLYMORFA_SDK_CHECKOUT=/absolute/path/to/bansafe-health-sdk +export POLYMORFA_CLI_CHECKOUT=/absolute/path/to/bansafe-health-cli + +cd "$POLYMORFA_SDK_CHECKOUT" +npm install --ignore-scripts +npm run build +npm run typecheck +npx vitest run packages/typescript/test/bansafe.test.ts + +cd "$POLYMORFA_CLI_CHECKOUT" +npm install --ignore-scripts +npm install --no-save --package-lock=false "$POLYMORFA_SDK_CHECKOUT" +npm run typecheck +npx vitest run test/commands/bansafe.test.ts +npm run build +``` + +Before publishing the CLI, confirm that the pinned SDK revision remains +available, run `npm ci`, and run `npm run verify` from a fresh checkout. A +local-path install is integration evidence only; it is not a publishable +dependency. + +The CLI mapping covers all 25 Platform BanSafe operations in the SDK coverage +ledger. The SDK also covers the 10 equivalent Messaging settings routes through +`MessagingClient.banSafe`; the CLI uses the Platform routes for those settings +because BanSafe commands authenticate with an organization server API key. + +| SDK operations | CLI commands | +| ------------------------------------------------------------ | ---------------------------------------------------------- | +| `banSafe.listHealth`, `getHealth`, `listHealthHistory` | `bansafe health list`, `view`, `history` | +| `banSafe.listSignals` | `bansafe signal list` | +| `banSafe.getTelemetry`, `listTelemetryHistory` | `bansafe telemetry view`, `history` | +| `banSafe.listCollection` | `bansafe collection list` | +| `banSafe.listHealthActions` | `bansafe health-action list` | +| `banSafe.listFindings` | `bansafe finding list` | +| `banSafe.listEnforcement` | `bansafe enforcement list` | +| `banSafe.listIncidents`, `createIncident`, `retractIncident` | `bansafe incident list`, `report`, `retract` | +| `banSafe.listClaims`, `getClaim` | `bansafe claim list`, `view` | +| `projects.getSafeMode`, `updateSafeMode` | `bansafe safe-mode view`, `update` with `--project` | +| `sessions.getSafeMode`, `updateSafeMode` | `bansafe safe-mode view`, `update` with a session argument | +| `projects.getWarmupPlan`, `updateWarmupPlan` | `bansafe warmup view`, `update` | +| `projects.getHealthPolicy`, `updateHealthPolicy` | `bansafe health-policy view`, `update` | +| `projects.getInsuranceEvidence`, `updateInsuranceEvidence` | `bansafe insurance-evidence view`, `update` | + +Finding acknowledgement and enforcement appeal are dashboard-identity +operations. They are intentionally absent from both the server SDK and CLI. diff --git a/src/commands/bansafe/claim/list.ts b/src/commands/bansafe/claim/list.ts new file mode 100644 index 00000000..bd0065c5 --- /dev/null +++ b/src/commands/bansafe/claim/list.ts @@ -0,0 +1,39 @@ +import {Flags} from '@oclif/core' +import type {BanSafeClaimStatus} from '@polymorfa/sdk' + +import {BaseCommand} from '../../../base-command.js' +import {listClaims, renderClaims} from '../../../services/bansafe.js' + +export default class BanSafeClaimList extends BaseCommand { + static override description = + 'List BanSafe attribution claims for one project.' + static override flags = { + cursor: Flags.string({description: 'Continue from an opaque page cursor.'}), + limit: Flags.integer({max: 100, min: 1}), + project: Flags.string({description: 'Project ID.', required: true}), + session: Flags.string({description: 'Match one session ID or name.'}), + status: Flags.string({ + options: [ + 'filed', + 'under_review', + 'approved', + 'denied', + 'paid', + 'reversed', + ], + }), + } + async run(): Promise { + const {flags} = await this.parse(BanSafeClaimList) + const result = await listClaims(await this.platformClient(flags), { + projectId: flags.project, + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.session === undefined ? {} : {session: flags.session}), + ...(flags.status === undefined + ? {} + : {status: flags.status as BanSafeClaimStatus}), + }) + this.writeValue(result, renderClaims(result.data), flags) + } +} diff --git a/src/commands/bansafe/claim/view.ts b/src/commands/bansafe/claim/view.ts new file mode 100644 index 00000000..bf0ff3a4 --- /dev/null +++ b/src/commands/bansafe/claim/view.ts @@ -0,0 +1,16 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {getClaim, renderClaim} from '../../../services/bansafe.js' + +export default class BanSafeClaimView extends BaseCommand { + static override args = { + claim: Args.string({description: 'Claim ID.', required: true}), + } + static override description = 'View one BanSafe attribution claim.' + async run(): Promise { + const {args, flags} = await this.parse(BanSafeClaimView) + const result = await getClaim(await this.platformClient(flags), args.claim) + this.writeValue(result, renderClaim(result.data), flags) + } +} diff --git a/src/commands/bansafe/collection/list.ts b/src/commands/bansafe/collection/list.ts new file mode 100644 index 00000000..2e94915d --- /dev/null +++ b/src/commands/bansafe/collection/list.ts @@ -0,0 +1,29 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {listCollection, renderCollection} from '../../../services/bansafe.js' + +export default class BanSafeCollectionList extends BaseCommand { + static override description = + 'List BanSafe telemetry collection status for one project.' + + static override flags = { + cursor: Flags.string({description: 'Continue from an opaque page cursor.'}), + limit: Flags.integer({ + description: 'Maximum sessions to return (default 25).', + max: 100, + min: 1, + }), + project: Flags.string({description: 'Project ID.', required: true}), + } + + async run(): Promise { + const {flags} = await this.parse(BanSafeCollectionList) + const result = await listCollection(await this.platformClient(flags), { + projectId: flags.project, + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + }) + this.writeValue(result, renderCollection(result.data), flags) + } +} diff --git a/src/commands/bansafe/enforcement/list.ts b/src/commands/bansafe/enforcement/list.ts new file mode 100644 index 00000000..ed3fb20b --- /dev/null +++ b/src/commands/bansafe/enforcement/list.ts @@ -0,0 +1,30 @@ +import {Flags} from '@oclif/core' +import type {BanSafeEnforcementRung} from '@polymorfa/sdk' + +import {BaseCommand} from '../../../base-command.js' +import {listEnforcement, renderEnforcement} from '../../../services/bansafe.js' + +export default class BanSafeEnforcementList extends BaseCommand { + static override description = + 'List recorded BanSafe enforcement for one project.' + static override flags = { + cursor: Flags.string({description: 'Continue from an opaque page cursor.'}), + limit: Flags.integer({max: 100, min: 1}), + project: Flags.string({description: 'Project ID.', required: true}), + rung: Flags.string({ + options: ['none', 'notify', 'throttle', 'block_cold', 'suspend'], + }), + } + async run(): Promise { + const {flags} = await this.parse(BanSafeEnforcementList) + const result = await listEnforcement(await this.platformClient(flags), { + projectId: flags.project, + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.rung === undefined + ? {} + : {rung: flags.rung as BanSafeEnforcementRung}), + }) + this.writeValue(result, renderEnforcement(result.data), flags) + } +} diff --git a/src/commands/bansafe/finding/list.ts b/src/commands/bansafe/finding/list.ts new file mode 100644 index 00000000..eedf6b18 --- /dev/null +++ b/src/commands/bansafe/finding/list.ts @@ -0,0 +1,39 @@ +import {Flags} from '@oclif/core' +import type {BanSafeFindingSeverity, BanSafeFindingStatus} from '@polymorfa/sdk' + +import {BaseCommand} from '../../../base-command.js' +import {listFindings, renderFindings} from '../../../services/bansafe.js' + +export default class BanSafeFindingList extends BaseCommand { + static override description = + 'List measured BanSafe findings for one project.' + static override flags = { + cursor: Flags.string({description: 'Continue from an opaque page cursor.'}), + limit: Flags.integer({max: 100, min: 1}), + project: Flags.string({description: 'Project ID.', required: true}), + session: Flags.string({description: 'Match one session ID or name.'}), + severity: Flags.string({options: ['info', 'warning', 'critical']}), + status: Flags.string({options: ['open', 'acknowledged', 'resolved']}), + } + async run(): Promise { + const {flags} = await this.parse(BanSafeFindingList) + const result = await listFindings(await this.platformClient(flags), { + projectId: flags.project, + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.session === undefined ? {} : {session: flags.session}), + ...(flags.severity === undefined + ? {} + : {severity: flags.severity as BanSafeFindingSeverity}), + ...(flags.status === undefined + ? {} + : { + status: flags.status as Exclude< + BanSafeFindingStatus, + 'not_measured' + >, + }), + }) + this.writeValue(result, renderFindings(result.data), flags) + } +} diff --git a/src/commands/bansafe/health-action/list.ts b/src/commands/bansafe/health-action/list.ts new file mode 100644 index 00000000..642ec528 --- /dev/null +++ b/src/commands/bansafe/health-action/list.ts @@ -0,0 +1,42 @@ +import {Flags} from '@oclif/core' +import type {BanSafeHealthActionStatus} from '@polymorfa/sdk' + +import {BaseCommand} from '../../../base-command.js' +import { + listHealthActions, + renderHealthActions, +} from '../../../services/bansafe.js' + +export default class BanSafeHealthActionList extends BaseCommand { + static override description = + 'List durable BanSafe Health actions for one project.' + + static override flags = { + cursor: Flags.string({description: 'Continue from an opaque page cursor.'}), + limit: Flags.integer({ + description: 'Maximum actions to return (default 25).', + max: 100, + min: 1, + }), + project: Flags.string({description: 'Project ID.', required: true}), + session: Flags.string({description: 'Match one session ID or name.'}), + status: Flags.string({ + description: 'Match one action status.', + options: ['pending', 'running', 'succeeded', 'failed', 'cancelled'], + }), + } + + async run(): Promise { + const {flags} = await this.parse(BanSafeHealthActionList) + const result = await listHealthActions(await this.platformClient(flags), { + projectId: flags.project, + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.session === undefined ? {} : {session: flags.session}), + ...(flags.status === undefined + ? {} + : {status: flags.status as BanSafeHealthActionStatus}), + }) + this.writeValue(result, renderHealthActions(result.data), flags) + } +} diff --git a/src/commands/bansafe/health-policy/update.ts b/src/commands/bansafe/health-policy/update.ts new file mode 100644 index 00000000..d83feb42 --- /dev/null +++ b/src/commands/bansafe/health-policy/update.ts @@ -0,0 +1,36 @@ +import {Flags} from '@oclif/core' + +import {readJsonInput} from '../../../api/request.js' +import {BaseCommand} from '../../../base-command.js' +import { + renderHealthPolicy, + updateHealthPolicy, +} from '../../../services/bansafe.js' + +export default class BanSafeHealthPolicyUpdate extends BaseCommand { + static override description = + 'Replace the BanSafe Health rule for one project.' + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Identify retries of this convergent policy update.', + required: true, + }), + input: Flags.string({ + description: + 'Read the complete policy JSON object from a file or standard input (-).', + required: true, + }), + project: Flags.string({description: 'Project ID.', required: true}), + } + + async run(): Promise { + const {flags} = await this.parse(BanSafeHealthPolicyUpdate) + const result = await updateHealthPolicy( + await this.platformClient(flags), + flags.project, + await readJsonInput(flags.input), + {idempotencyKey: flags['idempotency-key']}, + ) + this.writeValue(result, renderHealthPolicy(result.data), flags) + } +} diff --git a/src/commands/bansafe/health-policy/view.ts b/src/commands/bansafe/health-policy/view.ts new file mode 100644 index 00000000..6593e8c4 --- /dev/null +++ b/src/commands/bansafe/health-policy/view.ts @@ -0,0 +1,20 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {getHealthPolicy, renderHealthPolicy} from '../../../services/bansafe.js' + +export default class BanSafeHealthPolicyView extends BaseCommand { + static override description = 'View the BanSafe Health rule for one project.' + static override flags = { + project: Flags.string({description: 'Project ID.', required: true}), + } + + async run(): Promise { + const {flags} = await this.parse(BanSafeHealthPolicyView) + const result = await getHealthPolicy( + await this.platformClient(flags), + flags.project, + ) + this.writeValue(result, renderHealthPolicy(result.data), flags) + } +} diff --git a/src/commands/bansafe/health/history.ts b/src/commands/bansafe/health/history.ts new file mode 100644 index 00000000..1db493a3 --- /dev/null +++ b/src/commands/bansafe/health/history.ts @@ -0,0 +1,39 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + listHealthHistory, + renderHealthHistory, +} from '../../../services/bansafe.js' + +export default class BanSafeHealthHistory extends BaseCommand { + static override args = { + session: Args.string({description: 'Session ID or name.', required: true}), + } + static override description = + 'List recorded BanSafe Health evaluations for one session.' + + static override flags = { + limit: Flags.integer({ + description: 'Maximum evaluations to return (default 720).', + max: 720, + min: 1, + }), + since: Flags.string({ + description: 'Include evaluations at or after this ISO 8601 instant.', + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(BanSafeHealthHistory) + const result = await listHealthHistory( + await this.platformClient(flags), + args.session, + { + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.since === undefined ? {} : {since: flags.since}), + }, + ) + this.writeValue(result, renderHealthHistory(result.data), flags) + } +} diff --git a/src/commands/bansafe/health/list.ts b/src/commands/bansafe/health/list.ts new file mode 100644 index 00000000..3926f998 --- /dev/null +++ b/src/commands/bansafe/health/list.ts @@ -0,0 +1,28 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {listHealth, renderHealthList} from '../../../services/bansafe.js' + +export default class BanSafeHealthList extends BaseCommand { + static override description = 'List current BanSafe Health for one project.' + + static override flags = { + cursor: Flags.string({description: 'Continue from an opaque page cursor.'}), + limit: Flags.integer({ + description: 'Maximum sessions to return (default 25).', + max: 100, + min: 1, + }), + project: Flags.string({description: 'Project ID.', required: true}), + } + + async run(): Promise { + const {flags} = await this.parse(BanSafeHealthList) + const result = await listHealth(await this.platformClient(flags), { + projectId: flags.project, + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + }) + this.writeValue(result, renderHealthList(result.data), flags) + } +} diff --git a/src/commands/bansafe/health/view.ts b/src/commands/bansafe/health/view.ts new file mode 100644 index 00000000..cac2f026 --- /dev/null +++ b/src/commands/bansafe/health/view.ts @@ -0,0 +1,20 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {getHealth, renderHealth} from '../../../services/bansafe.js' + +export default class BanSafeHealthView extends BaseCommand { + static override args = { + session: Args.string({description: 'Session ID or name.', required: true}), + } + static override description = 'View current BanSafe Health for one session.' + + async run(): Promise { + const {args, flags} = await this.parse(BanSafeHealthView) + const result = await getHealth( + await this.platformClient(flags), + args.session, + ) + this.writeValue(result, renderHealth(result.data), flags) + } +} diff --git a/src/commands/bansafe/incident/list.ts b/src/commands/bansafe/incident/list.ts new file mode 100644 index 00000000..7eb6d132 --- /dev/null +++ b/src/commands/bansafe/incident/list.ts @@ -0,0 +1,25 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {listIncidents, renderIncidents} from '../../../services/bansafe.js' + +export default class BanSafeIncidentList extends BaseCommand { + static override description = + 'List recorded BanSafe incidents for one project.' + static override flags = { + cursor: Flags.string({description: 'Continue from an opaque page cursor.'}), + limit: Flags.integer({max: 100, min: 1}), + project: Flags.string({description: 'Project ID.', required: true}), + session: Flags.string({description: 'Match one session ID or name.'}), + } + async run(): Promise { + const {flags} = await this.parse(BanSafeIncidentList) + const result = await listIncidents(await this.platformClient(flags), { + projectId: flags.project, + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.session === undefined ? {} : {session: flags.session}), + }) + this.writeValue(result, renderIncidents(result.data), flags) + } +} diff --git a/src/commands/bansafe/incident/report.ts b/src/commands/bansafe/incident/report.ts new file mode 100644 index 00000000..2662f55c --- /dev/null +++ b/src/commands/bansafe/incident/report.ts @@ -0,0 +1,36 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {reportIncident} from '../../../services/bansafe.js' + +export default class BanSafeIncidentReport extends BaseCommand { + static override description = 'Report a customer-observed BanSafe incident.' + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Identify retries of this incident report.', + required: true, + }), + note: Flags.string({description: 'Optional incident context.'}), + 'occurred-at': Flags.string({description: 'Observed ISO 8601 instant.'}), + session: Flags.string({description: 'Session ID or name.', required: true}), + } + async run(): Promise { + const {flags} = await this.parse(BanSafeIncidentReport) + const result = await reportIncident( + await this.platformClient(flags), + { + session: flags.session, + ...(flags.note === undefined ? {} : {note: flags.note}), + ...(flags['occurred-at'] === undefined + ? {} + : {occurredAt: flags['occurred-at']}), + }, + {idempotencyKey: flags['idempotency-key']}, + ) + this.writeValue( + result, + `Recorded BanSafe incident ${result.data.incidentId}.`, + flags, + ) + } +} diff --git a/src/commands/bansafe/incident/retract.ts b/src/commands/bansafe/incident/retract.ts new file mode 100644 index 00000000..4bc5347c --- /dev/null +++ b/src/commands/bansafe/incident/retract.ts @@ -0,0 +1,31 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {retractIncident} from '../../../services/bansafe.js' + +export default class BanSafeIncidentRetract extends BaseCommand { + static override args = { + incident: Args.string({description: 'Incident ID.', required: true}), + } + static override description = + 'Retract one customer-reported BanSafe incident.' + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Identify retries of this retraction.', + required: true, + }), + } + async run(): Promise { + const {args, flags} = await this.parse(BanSafeIncidentRetract) + const result = await retractIncident( + await this.platformClient(flags), + args.incident, + {idempotencyKey: flags['idempotency-key']}, + ) + this.writeValue( + result, + `Retracted BanSafe incident ${args.incident}.`, + flags, + ) + } +} diff --git a/src/commands/bansafe/insurance-evidence/update.ts b/src/commands/bansafe/insurance-evidence/update.ts new file mode 100644 index 00000000..88ed6ae7 --- /dev/null +++ b/src/commands/bansafe/insurance-evidence/update.ts @@ -0,0 +1,34 @@ +import {Flags} from '@oclif/core' + +import {readJsonInput} from '../../../api/request.js' +import {BaseCommand} from '../../../base-command.js' +import { + renderInsuranceEvidence, + updateInsuranceEvidence, +} from '../../../services/bansafe.js' + +export default class BanSafeInsuranceEvidenceUpdate extends BaseCommand { + static override description = 'Update project insurance evidence collection.' + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Identify retries of this update.', + required: true, + }), + input: Flags.string({ + description: + 'Read an insurance-evidence JSON object from a file or standard input (-).', + required: true, + }), + project: Flags.string({description: 'Project ID.', required: true}), + } + async run(): Promise { + const {flags} = await this.parse(BanSafeInsuranceEvidenceUpdate) + const result = await updateInsuranceEvidence( + await this.platformClient(flags), + flags.project, + await readJsonInput(flags.input), + {idempotencyKey: flags['idempotency-key']}, + ) + this.writeValue(result, renderInsuranceEvidence(result.data), flags) + } +} diff --git a/src/commands/bansafe/insurance-evidence/view.ts b/src/commands/bansafe/insurance-evidence/view.ts new file mode 100644 index 00000000..3e1ff58a --- /dev/null +++ b/src/commands/bansafe/insurance-evidence/view.ts @@ -0,0 +1,22 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + getInsuranceEvidence, + renderInsuranceEvidence, +} from '../../../services/bansafe.js' + +export default class BanSafeInsuranceEvidenceView extends BaseCommand { + static override description = 'View project insurance evidence collection.' + static override flags = { + project: Flags.string({description: 'Project ID.', required: true}), + } + async run(): Promise { + const {flags} = await this.parse(BanSafeInsuranceEvidenceView) + const result = await getInsuranceEvidence( + await this.platformClient(flags), + flags.project, + ) + this.writeValue(result, renderInsuranceEvidence(result.data), flags) + } +} diff --git a/src/commands/bansafe/safe-mode/update.ts b/src/commands/bansafe/safe-mode/update.ts new file mode 100644 index 00000000..cb09368e --- /dev/null +++ b/src/commands/bansafe/safe-mode/update.ts @@ -0,0 +1,48 @@ +import {Args, Flags} from '@oclif/core' + +import {readJsonInput} from '../../../api/request.js' +import {BaseCommand} from '../../../base-command.js' +import { + renderSafeMode, + updateProjectSafeMode, + updateSessionSafeMode, +} from '../../../services/bansafe.js' + +export default class BanSafeSafeModeUpdate extends BaseCommand { + static override args = { + session: Args.string({description: 'Session ID or name.'}), + } + static override description = 'Update project or session Safe Mode settings.' + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Identify retries of this update.', + required: true, + }), + input: Flags.string({ + description: + 'Read a Safe Mode JSON patch from a file or standard input (-).', + required: true, + }), + project: Flags.string({ + description: 'Project ID; required when session is omitted.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(BanSafeSafeModeUpdate) + if (args.session === undefined && flags.project === undefined) + this.error('Provide a session argument or --project.', {exit: 2}) + const client = await this.platformClient(flags) + const input = await readJsonInput(flags.input) + const options = {idempotencyKey: flags['idempotency-key']} + const result = + args.session === undefined + ? await updateProjectSafeMode( + client, + flags.project as string, + input, + options, + ) + : await updateSessionSafeMode(client, args.session, input, options) + this.writeValue(result, renderSafeMode(result.data), flags) + } +} diff --git a/src/commands/bansafe/safe-mode/view.ts b/src/commands/bansafe/safe-mode/view.ts new file mode 100644 index 00000000..0f81cc45 --- /dev/null +++ b/src/commands/bansafe/safe-mode/view.ts @@ -0,0 +1,36 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + getProjectSafeMode, + getSessionSafeMode, + renderSafeMode, +} from '../../../services/bansafe.js' + +export default class BanSafeSafeModeView extends BaseCommand { + static override args = { + session: Args.string({description: 'Session ID or name.'}), + } + static override description = 'View project or effective session Safe Mode.' + static override flags = { + project: Flags.string({ + description: 'Project ID; required when session is omitted.', + }), + } + async run(): Promise { + const {args, flags} = await this.parse(BanSafeSafeModeView) + if (args.session === undefined && flags.project === undefined) + this.error('Provide a session argument or --project.', {exit: 2}) + const result = + args.session === undefined + ? await getProjectSafeMode( + await this.platformClient(flags), + flags.project as string, + ) + : await getSessionSafeMode( + await this.platformClient(flags), + args.session, + ) + this.writeValue(result, renderSafeMode(result.data), flags) + } +} diff --git a/src/commands/bansafe/signal/list.ts b/src/commands/bansafe/signal/list.ts new file mode 100644 index 00000000..906df11f --- /dev/null +++ b/src/commands/bansafe/signal/list.ts @@ -0,0 +1,13 @@ +import {BaseCommand} from '../../../base-command.js' +import {listSignals, renderSignals} from '../../../services/bansafe.js' + +export default class BanSafeSignalList extends BaseCommand { + static override description = + 'List the bounded BanSafe telemetry signal catalogue.' + + async run(): Promise { + const {flags} = await this.parse(BanSafeSignalList) + const result = await listSignals(await this.platformClient(flags)) + this.writeValue(result, renderSignals(result.data), flags) + } +} diff --git a/src/commands/bansafe/telemetry/history.ts b/src/commands/bansafe/telemetry/history.ts new file mode 100644 index 00000000..c8c69ab7 --- /dev/null +++ b/src/commands/bansafe/telemetry/history.ts @@ -0,0 +1,45 @@ +import {Args, Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import { + listTelemetryHistory, + renderTelemetryHistory, +} from '../../../services/bansafe.js' + +export default class BanSafeTelemetryHistory extends BaseCommand { + static override args = { + session: Args.string({description: 'Session ID or name.', required: true}), + } + static override description = + 'List bounded BanSafe telemetry snapshots for one session.' + + static override flags = { + cursor: Flags.string({description: 'Continue from an opaque page cursor.'}), + limit: Flags.integer({ + description: 'Maximum snapshots to return (default 10).', + max: 50, + min: 1, + }), + since: Flags.string({ + description: 'Include snapshots at or after this ISO 8601 instant.', + }), + until: Flags.string({ + description: 'Include snapshots at or before this ISO 8601 instant.', + }), + } + + async run(): Promise { + const {args, flags} = await this.parse(BanSafeTelemetryHistory) + const result = await listTelemetryHistory( + await this.platformClient(flags), + args.session, + { + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.since === undefined ? {} : {since: flags.since}), + ...(flags.until === undefined ? {} : {until: flags.until}), + }, + ) + this.writeValue(result, renderTelemetryHistory(result.data), flags) + } +} diff --git a/src/commands/bansafe/telemetry/view.ts b/src/commands/bansafe/telemetry/view.ts new file mode 100644 index 00000000..cd248d4d --- /dev/null +++ b/src/commands/bansafe/telemetry/view.ts @@ -0,0 +1,21 @@ +import {Args} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {getTelemetry, renderTelemetry} from '../../../services/bansafe.js' + +export default class BanSafeTelemetryView extends BaseCommand { + static override args = { + session: Args.string({description: 'Session ID or name.', required: true}), + } + static override description = + 'View the latest bounded BanSafe telemetry for one session.' + + async run(): Promise { + const {args, flags} = await this.parse(BanSafeTelemetryView) + const result = await getTelemetry( + await this.platformClient(flags), + args.session, + ) + this.writeValue(result, renderTelemetry(result.data), flags) + } +} diff --git a/src/commands/bansafe/warmup/update.ts b/src/commands/bansafe/warmup/update.ts new file mode 100644 index 00000000..73a0269c --- /dev/null +++ b/src/commands/bansafe/warmup/update.ts @@ -0,0 +1,31 @@ +import {Flags} from '@oclif/core' + +import {readJsonInput} from '../../../api/request.js' +import {BaseCommand} from '../../../base-command.js' +import {renderWarmupPlan, updateWarmupPlan} from '../../../services/bansafe.js' + +export default class BanSafeWarmupUpdate extends BaseCommand { + static override description = 'Update the warmup plan for one project.' + static override flags = { + 'idempotency-key': Flags.string({ + description: 'Identify retries of this update.', + required: true, + }), + input: Flags.string({ + description: + 'Read a warmup-plan JSON patch from a file or standard input (-).', + required: true, + }), + project: Flags.string({description: 'Project ID.', required: true}), + } + async run(): Promise { + const {flags} = await this.parse(BanSafeWarmupUpdate) + const result = await updateWarmupPlan( + await this.platformClient(flags), + flags.project, + await readJsonInput(flags.input), + {idempotencyKey: flags['idempotency-key']}, + ) + this.writeValue(result, renderWarmupPlan(result.data), flags) + } +} diff --git a/src/commands/bansafe/warmup/view.ts b/src/commands/bansafe/warmup/view.ts new file mode 100644 index 00000000..cdcbdd4c --- /dev/null +++ b/src/commands/bansafe/warmup/view.ts @@ -0,0 +1,19 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../../base-command.js' +import {getWarmupPlan, renderWarmupPlan} from '../../../services/bansafe.js' + +export default class BanSafeWarmupView extends BaseCommand { + static override description = 'View the warmup plan for one project.' + static override flags = { + project: Flags.string({description: 'Project ID.', required: true}), + } + async run(): Promise { + const {flags} = await this.parse(BanSafeWarmupView) + const result = await getWarmupPlan( + await this.platformClient(flags), + flags.project, + ) + this.writeValue(result, renderWarmupPlan(result.data), flags) + } +} diff --git a/src/services/bansafe.ts b/src/services/bansafe.ts new file mode 100644 index 00000000..e42d5b5e --- /dev/null +++ b/src/services/bansafe.ts @@ -0,0 +1,772 @@ +import { + type BanSafeClaim, + type BanSafeCollectionSession, + type BanSafeEnforcementSummary, + type BanSafeFinding, + type BanSafeHealthAction, + type BanSafeHealthHistory, + type BanSafeIncident, + type BanSafeNumber, + type BanSafeNumberDetail, + type BanSafeSignalDefinition, + type BanSafeTelemetryDetail, + type BanSafeTelemetrySnapshot, + type ClientInstance as OrganizationClient, + type CursorPageMetadata, + type ListBanSafeClaimsParams, + type ListBanSafeCollectionParams, + type ListBanSafeEnforcementParams, + type ListBanSafeFindingsParams, + type ListBanSafeHealthActionsParams, + type ListBanSafeHealthHistoryParams, + type ListBanSafeHealthParams, + type ListBanSafeIncidentsParams, + type ListBanSafeTelemetryHistoryParams, + PolymorfaValidationError, + type ProjectHealthPolicy, + type ProjectInsuranceEvidence, + type ProjectSafeMode, + type ProjectWarmupPlan, + type ReportBanSafeIncidentRequest, + type RequestOptions, + type ResponseMetadata, + type SessionSafeMode, + type UpdateProjectHealthPolicyRequest, + type UpdateProjectInsuranceEvidenceRequest, + type UpdateProjectSafeModeRequest, + type UpdateProjectWarmupPlanRequest, + type UpdateSessionSafeModeRequest, +} from '@polymorfa/sdk' + +export interface BanSafeResult { + readonly data: T + readonly metadata: ResultMetadata +} + +export interface BanSafePageResult extends BanSafeResult { + readonly page: CursorPageMetadata +} + +interface ResultMetadata { + readonly apiVersion?: string + readonly attempts: number + readonly requestId?: string + readonly status: number +} + +export async function listHealth( + client: OrganizationClient, + params: ListBanSafeHealthParams, +): Promise> { + const response = await client.banSafe.listHealth(params) + return pageResult(response.data, response.metadata) +} + +export async function getHealth( + client: OrganizationClient, + session: string, +): Promise> { + const response = await client.banSafe.getHealth(session) + return result(response.data.data, response.metadata) +} + +export async function listHealthHistory( + client: OrganizationClient, + session: string, + params: ListBanSafeHealthHistoryParams, +): Promise> { + const response = await client.banSafe.listHealthHistory(session, params) + return result(response.data.data, response.metadata) +} + +export async function listSignals( + client: OrganizationClient, +): Promise> { + const response = await client.banSafe.listSignals() + return result(response.data.data, response.metadata) +} + +export async function getTelemetry( + client: OrganizationClient, + session: string, +): Promise> { + const response = await client.banSafe.getTelemetry(session) + return result(response.data.data, response.metadata) +} + +export async function listTelemetryHistory( + client: OrganizationClient, + session: string, + params: ListBanSafeTelemetryHistoryParams, +): Promise> { + const response = await client.banSafe.listTelemetryHistory(session, params) + return pageResult(response.data, response.metadata) +} + +export async function listCollection( + client: OrganizationClient, + params: ListBanSafeCollectionParams, +): Promise> { + const response = await client.banSafe.listCollection(params) + return pageResult(response.data, response.metadata) +} + +export async function listFindings( + client: OrganizationClient, + params: ListBanSafeFindingsParams, +): Promise> { + const response = await client.banSafe.listFindings(params) + return pageResult(response.data, response.metadata) +} + +export async function listEnforcement( + client: OrganizationClient, + params: ListBanSafeEnforcementParams, +): Promise> { + const response = await client.banSafe.listEnforcement(params) + return pageResult(response.data, response.metadata) +} + +export async function listIncidents( + client: OrganizationClient, + params: ListBanSafeIncidentsParams, +): Promise> { + const response = await client.banSafe.listIncidents(params) + return pageResult(response.data, response.metadata) +} + +export async function listClaims( + client: OrganizationClient, + params: ListBanSafeClaimsParams, +): Promise> { + const response = await client.banSafe.listClaims(params) + return pageResult(response.data, response.metadata) +} + +export async function getClaim( + client: OrganizationClient, + claimId: string, +): Promise> { + const response = await client.banSafe.getClaim(claimId) + return result(response.data.data, response.metadata) +} + +export async function reportIncident( + client: OrganizationClient, + body: ReportBanSafeIncidentRequest, + options: RequestOptions, +) { + const response = await client.banSafe.createIncident(body, options) + return result(response.data.data, response.metadata) +} + +export async function retractIncident( + client: OrganizationClient, + incidentId: string, + options: RequestOptions, +) { + const response = await client.banSafe.retractIncident(incidentId, options) + return result(response.data.data, response.metadata) +} + +export async function getProjectSafeMode( + client: OrganizationClient, + projectId: string, +) { + const response = await client.projects.getSafeMode(projectId) + return result(response.data.data, response.metadata) +} + +export async function updateProjectSafeMode( + client: OrganizationClient, + projectId: string, + input: unknown, + options: RequestOptions, +): Promise> { + const response = await client.projects.updateSafeMode( + projectId, + safeModeInput(input, false) as UpdateProjectSafeModeRequest, + options, + ) + return result(response.data.data, response.metadata) +} + +export async function getSessionSafeMode( + client: OrganizationClient, + session: string, +) { + const response = await client.sessions.getSafeMode(session) + return result(response.data.data, response.metadata) +} + +export async function updateSessionSafeMode( + client: OrganizationClient, + session: string, + input: unknown, + options: RequestOptions, +): Promise> { + const response = await client.sessions.updateSafeMode( + session, + safeModeInput(input, true), + options, + ) + return result(response.data.data, response.metadata) +} + +export async function getWarmupPlan( + client: OrganizationClient, + projectId: string, +) { + const response = await client.projects.getWarmupPlan(projectId) + return result(response.data.data, response.metadata) +} + +export async function getInsuranceEvidence( + client: OrganizationClient, + projectId: string, +) { + const response = await client.projects.getInsuranceEvidence(projectId) + return result(response.data.data, response.metadata) +} + +export async function updateInsuranceEvidence( + client: OrganizationClient, + projectId: string, + input: unknown, + options: RequestOptions, +): Promise> { + const response = await client.projects.updateInsuranceEvidence( + projectId, + insuranceEvidenceInput(input), + options, + ) + return result(response.data.data, response.metadata) +} + +export async function updateWarmupPlan( + client: OrganizationClient, + projectId: string, + input: unknown, + options: RequestOptions, +): Promise> { + const response = await client.projects.updateWarmupPlan( + projectId, + warmupInput(input), + options, + ) + return result(response.data.data, response.metadata) +} + +export async function listHealthActions( + client: OrganizationClient, + params: ListBanSafeHealthActionsParams, +): Promise> { + const response = await client.banSafe.listHealthActions(params) + return pageResult(response.data, response.metadata) +} + +export async function getHealthPolicy( + client: OrganizationClient, + projectId: string, +): Promise> { + const response = await client.projects.getHealthPolicy(projectId) + return result(response.data.data, response.metadata) +} + +export async function updateHealthPolicy( + client: OrganizationClient, + projectId: string, + input: unknown, + options: RequestOptions = {}, +): Promise> { + const response = await client.projects.updateHealthPolicy( + projectId, + healthPolicyInput(input), + options, + ) + return result(response.data.data, response.metadata) +} + +export function renderHealthList(numbers: readonly BanSafeNumber[]): string { + if (numbers.length === 0) return 'No BanSafe Health records found.' + return numbers + .map((number) => + [ + number.session, + number.phoneNumber, + number.health ?? 'unknown', + number.band, + number.healthSource, + ].join('\t'), + ) + .join('\n') +} + +export function renderHealth(number: BanSafeNumberDetail): string { + const explanation = number.healthExplanation + return [ + `Session\t${number.session}`, + `Phone\t${number.phoneNumber}`, + `Health\t${number.health ?? 'unknown'}`, + `Band\t${number.band}`, + `Source\t${number.healthSource}`, + `Estimator\t${number.healthEstimatorVersion ?? ''}`, + `Reliability\t${number.healthReliability}`, + `Coverage\t${number.healthFeatureCoverage ?? 'unknown'}`, + `Observed state\t${number.observedAccountState?.state ?? 'not observed'}`, + `Collection groups\t${explanation?.measuredGroups.join(', ') ?? 'unavailable'}`, + `Missing groups\t${explanation?.missingGroups.join(', ') ?? 'unavailable'}`, + `Findings\t${number.findings.length}`, + ].join('\n') +} + +export function renderHealthHistory(history: BanSafeHealthHistory): string { + if (history.points.length === 0) + return `No Health history found for ${history.session}.` + return history.points + .map((point) => + [ + point.healthEvaluatedAt, + point.health ?? 'unknown', + point.band ?? 'unknown', + point.healthSource, + point.healthFeatureCoverage ?? 'unknown', + ].join('\t'), + ) + .join('\n') +} + +export function renderSignals( + signals: readonly BanSafeSignalDefinition[], +): string { + if (signals.length === 0) return 'No BanSafe telemetry signals are available.' + return signals + .map((signal) => + [signal.key, signal.group, signal.kind, signal.unit, signal.label].join( + '\t', + ), + ) + .join('\n') +} + +export function renderTelemetry(telemetry: BanSafeTelemetryDetail): string { + const snapshot = telemetry.snapshot + const measured = + snapshot?.signals.filter((signal) => signal.measured).length ?? 0 + return [ + `Session\t${telemetry.session}`, + `Collection\t${telemetry.collection.state}`, + `Latest flush\t${telemetry.collection.latestFlushedAt ?? 'none'}`, + `Fresh until\t${telemetry.collection.freshUntil ?? 'none'}`, + `Record version\t${telemetry.collection.recordVersion ?? 'none'}`, + `Collector version\t${telemetry.collection.collectorVersion ?? 'none'}`, + `Partial\t${telemetry.collection.partial ?? 'unknown'}`, + `Signals measured\t${snapshot == null ? '0/0' : `${measured}/${snapshot.signals.length}`}`, + ].join('\n') +} + +export function renderTelemetryHistory( + snapshots: readonly BanSafeTelemetrySnapshot[], +): string { + if (snapshots.length === 0) return 'No BanSafe telemetry snapshots found.' + return snapshots + .map((snapshot) => { + const measured = snapshot.signals.filter( + (signal) => signal.measured, + ).length + return [ + snapshot.flushedAt, + snapshot.partial ? 'partial' : 'complete', + `${measured}/${snapshot.signals.length} measured`, + ].join('\t') + }) + .join('\n') +} + +export function renderCollection( + sessions: readonly BanSafeCollectionSession[], +): string { + if (sessions.length === 0) return 'No project sessions found.' + return sessions + .map((session) => + [ + session.session, + session.collection.state, + session.collection.latestFlushedAt ?? '', + session.collection.collectorVersion ?? '', + session.collection.droppedRecords ?? '', + ].join('\t'), + ) + .join('\n') +} + +export function renderFindings(findings: readonly BanSafeFinding[]): string { + if (findings.length === 0) return 'No BanSafe findings found.' + return findings + .map((finding) => + [ + finding.session, + finding.key, + finding.status, + finding.severity ?? '', + finding.occurrences, + finding.lastSeenAt ?? '', + ].join('\t'), + ) + .join('\n') +} + +export function renderEnforcement( + records: readonly BanSafeEnforcementSummary[], +): string { + if (records.length === 0) return 'No BanSafe enforcement records found.' + return records + .map((record) => + [ + record.session, + record.rung, + record.state, + record.health ?? 'unknown', + record.source, + record.startedAt, + ].join('\t'), + ) + .join('\n') +} + +export function renderIncidents(incidents: readonly BanSafeIncident[]): string { + if (incidents.length === 0) return 'No BanSafe incidents found.' + return incidents + .map((incident) => + [ + incident.id, + incident.session, + incident.kind, + incident.source, + incident.startedAt, + incident.closedAt ?? '', + ].join('\t'), + ) + .join('\n') +} + +export function renderClaims(claims: readonly BanSafeClaim[]): string { + if (claims.length === 0) return 'No BanSafe claims found.' + return claims + .map((claim) => + [ + claim.id, + claim.session, + claim.status, + claim.verdict, + claim.amountCents, + claim.createdAt, + ].join('\t'), + ) + .join('\n') +} + +export function renderClaim(claim: BanSafeClaim): string { + return [ + `Claim\t${claim.id}`, + `Session\t${claim.session}`, + `Status\t${claim.status}`, + `Verdict\t${claim.verdict}`, + `Amount credits\t${claim.amountCents}`, + `Measured credits\t${claim.measuredCents}`, + `Cap credits\t${claim.capCents}`, + `Window\t${claim.windowStart} to ${claim.windowEnd}`, + `Summary\t${claim.summary}`, + `Reason\t${claim.reason}`, + ].join('\n') +} + +export function renderSafeMode( + mode: ProjectSafeMode | SessionSafeMode, +): string { + const settings = 'effective' in mode ? mode.effective : mode.ceiling + return [ + `Project\t${mode.projectId}`, + ...('session' in mode ? [`Session\t${mode.session}`] : []), + `Presence\t${settings.presence}`, + `Typing\t${settings.typing}`, + `Reads\t${settings.reads}`, + `Pacing\t${settings.pacing}`, + `Online hours\t${settings.onlineStart}-${settings.onlineEnd}`, + `Entitled\t${mode.entitled}`, + ...('mismatch' in mode ? [`Applied mismatch\t${mode.mismatch}`] : []), + ].join('\n') +} + +export function renderWarmupPlan(plan: ProjectWarmupPlan): string { + return [ + `Project\t${plan.projectId}`, + `Enabled\t${plan.plan.enabled}`, + `Days\t${plan.plan.warmupDays}`, + `Daily start\t${plan.plan.dailyStart}`, + `Ceiling\t${plan.ceiling}`, + `Entitled\t${plan.entitled}`, + ].join('\n') +} + +export function renderInsuranceEvidence( + evidence: ProjectInsuranceEvidence, +): string { + return [ + `Project\t${evidence.projectId}`, + `Collection enabled\t${evidence.enabled}`, + `Ban insurance included\t${evidence.banInsuranceIncluded}`, + ].join('\n') +} + +export function renderHealthActions( + actions: readonly BanSafeHealthAction[], +): string { + if (actions.length === 0) return 'No BanSafe Health actions found.' + return actions + .map((action) => + [ + action.id, + action.session, + action.mode, + action.action, + action.status, + action.outcome ?? '', + `${action.health}<${action.threshold}`, + action.createdAt, + ].join('\t'), + ) + .join('\n') +} + +export function renderHealthPolicy(policy: ProjectHealthPolicy): string { + return [ + `Project\t${policy.projectId}`, + `Version\t${policy.version}`, + `Enabled\t${policy.enabled}`, + `Threshold\t${policy.threshold}`, + `Session action\t${policy.sessionAction}`, + `Slowdown messages/second\t${policy.slowDownMps ?? 'off'}`, + `Email notification\t${policy.emailNotification}`, + `Webhook notification\t${policy.webhookNotification}`, + `Email configured\t${policy.integrations.emailConfigured}`, + `Webhook configured\t${policy.integrations.webhookConfigured}`, + ].join('\n') +} + +function healthPolicyInput(input: unknown): UpdateProjectHealthPolicyRequest { + if (!isRecord(input)) + throw invalidPolicy('Health policy input must contain one JSON object.') + const expected = [ + 'version', + 'enabled', + 'threshold', + 'sessionAction', + 'slowDownMps', + 'emailNotification', + 'webhookNotification', + ] + const extras = Object.keys(input).filter((key) => !expected.includes(key)) + if (extras.length > 0) + throw invalidPolicy(`Unknown Health policy field: ${extras[0]}.`) + for (const key of expected) + if (!Object.hasOwn(input, key)) + throw invalidPolicy(`Health policy input requires ${key}.`) + if (!Number.isSafeInteger(input.version) || (input.version as number) < 0) + throw invalidPolicy('Health policy version must be a non-negative integer.') + if ( + typeof input.enabled !== 'boolean' || + typeof input.emailNotification !== 'boolean' || + typeof input.webhookNotification !== 'boolean' + ) + throw invalidPolicy( + 'Health policy enabled and notification fields must be boolean.', + ) + if ( + !Number.isSafeInteger(input.threshold) || + (input.threshold as number) < 0 || + (input.threshold as number) > 100 + ) + throw invalidPolicy( + 'Health policy threshold must be an integer from 0 to 100.', + ) + if ( + typeof input.sessionAction !== 'string' || + !['none', 'stop', 'slow_down', 'log_out'].includes(input.sessionAction) + ) + throw invalidPolicy( + 'Health policy sessionAction must be none, stop, slow_down, or log_out.', + ) + if ( + input.slowDownMps !== null && + (typeof input.slowDownMps !== 'number' || + !Number.isFinite(input.slowDownMps) || + input.slowDownMps <= 0) + ) + throw invalidPolicy( + 'Health policy slowDownMps must be null or a positive number.', + ) + if (input.sessionAction === 'slow_down' && input.slowDownMps === null) + throw invalidPolicy('slow_down requires a positive slowDownMps.') + if (input.sessionAction !== 'slow_down' && input.slowDownMps !== null) + throw invalidPolicy( + 'slowDownMps must be null unless sessionAction is slow_down.', + ) + if ( + input.enabled === true && + input.sessionAction === 'none' && + input.emailNotification === false && + input.webhookNotification === false + ) + throw invalidPolicy( + 'An enabled Health policy must select at least one action or notification.', + ) + return input as unknown as UpdateProjectHealthPolicyRequest +} + +function safeModeInput( + input: unknown, + allowInherit: boolean, +): UpdateProjectSafeModeRequest | UpdateSessionSafeModeRequest { + if (!isRecord(input)) + throw invalidInput('Safe Mode input must contain one JSON object.') + const allowed = [ + 'presence', + 'typing', + 'reads', + 'pacing', + ...(allowInherit ? [] : ['onlineStart', 'onlineEnd']), + ] + validatePatchKeys(input, allowed, 'Safe Mode') + validateChoice( + input, + 'presence', + ['dark', 'online_while_sending', 'online_hours'], + allowInherit, + ) + validateChoice( + input, + 'typing', + ['off', 'before_text', 'before_all'], + allowInherit, + ) + validateChoice( + input, + 'reads', + ['off', 'replied_chats', 'all_inbound'], + allowInherit, + ) + validateChoice( + input, + 'pacing', + ['off', 'jittered', 'conversation'], + allowInherit, + ) + if (!allowInherit) { + for (const key of ['onlineStart', 'onlineEnd']) { + const value = input[key] + if ( + value !== undefined && + (!Number.isSafeInteger(value) || + (value as number) < 0 || + (value as number) > 23) + ) + throw invalidInput(`Safe Mode ${key} must be an integer from 0 to 23.`) + } + } + return input +} + +function warmupInput(input: unknown): UpdateProjectWarmupPlanRequest { + if (!isRecord(input)) + throw invalidInput('Warmup plan input must contain one JSON object.') + validatePatchKeys( + input, + ['enabled', 'warmupDays', 'dailyStart'], + 'Warmup plan', + ) + if (input.enabled !== undefined && typeof input.enabled !== 'boolean') + throw invalidInput('Warmup plan enabled must be boolean.') + for (const key of ['warmupDays', 'dailyStart']) { + const value = input[key] + if ( + value !== undefined && + (!Number.isSafeInteger(value) || (value as number) < 0) + ) + throw invalidInput(`Warmup plan ${key} must be a non-negative integer.`) + } + return input +} + +function insuranceEvidenceInput( + input: unknown, +): UpdateProjectInsuranceEvidenceRequest { + if (!isRecord(input)) + throw invalidInput('Insurance evidence input must contain one JSON object.') + validatePatchKeys(input, ['enabled'], 'Insurance evidence') + if (typeof input.enabled !== 'boolean') + throw invalidInput('Insurance evidence enabled must be boolean.') + return input as unknown as UpdateProjectInsuranceEvidenceRequest +} + +function validatePatchKeys( + input: Readonly>, + allowed: readonly string[], + label: string, +): void { + const keys = Object.keys(input) + if (keys.length === 0) throw invalidInput(`${label} input cannot be empty.`) + const extra = keys.find((key) => !allowed.includes(key)) + if (extra !== undefined) + throw invalidInput(`Unknown ${label} field: ${extra}.`) +} + +function validateChoice( + input: Readonly>, + key: string, + choices: readonly string[], + allowInherit: boolean, +): void { + const value = input[key] + if (value === undefined) return + if (allowInherit && value === 'inherit') return + if (typeof value !== 'string' || !choices.includes(value)) + throw invalidInput(`Invalid Safe Mode ${key}.`) +} + +function pageResult( + envelope: {readonly data: readonly T[]; readonly page: CursorPageMetadata}, + metadata: ResponseMetadata, +): BanSafePageResult { + return {...result(envelope.data, metadata), page: envelope.page} +} + +function result(data: T, metadata: ResponseMetadata): BanSafeResult { + return { + data, + metadata: { + attempts: metadata.attempts, + status: metadata.status, + ...(metadata.apiVersion === undefined + ? {} + : {apiVersion: metadata.apiVersion}), + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + }, + } +} + +function invalidPolicy(message: string): PolymorfaValidationError { + return new PolymorfaValidationError(message, { + code: 'invalid_health_policy_input', + }) +} + +function invalidInput(message: string): PolymorfaValidationError { + return new PolymorfaValidationError(message, {code: 'invalid_bansafe_input'}) +} + +function isRecord(value: unknown): value is Readonly> { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} diff --git a/test/commands/bansafe.test.ts b/test/commands/bansafe.test.ts new file mode 100644 index 00000000..57f0e315 --- /dev/null +++ b/test/commands/bansafe.test.ts @@ -0,0 +1,531 @@ +import {mkdtemp, rm, writeFile} from 'node:fs/promises' +import {tmpdir} from 'node:os' +import {join} from 'node:path' + +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +describe('BanSafe commands', () => { + let directory: string | undefined + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + if (directory !== undefined) + await rm(directory, {force: true, recursive: true}) + directory = undefined + }) + + test.each([ + { + body: {data: [], page: {hasMore: false, nextCursor: null}}, + command: 'bansafe health list --project project_1 --limit 20 --json', + path: '/platform/bansafe/health?projectId=project_1&limit=20', + }, + { + body: {data: [], page: {hasMore: false, nextCursor: null}}, + command: 'bansafe collection list --project project_1 --json', + path: '/platform/bansafe/collection?projectId=project_1', + }, + { + body: {data: [], page: {hasMore: false, nextCursor: null}}, + command: + 'bansafe health-action list --project project_1 --session session_1 --status failed --json', + path: '/platform/bansafe/health-actions?projectId=project_1&session=session_1&status=failed', + }, + { + body: {data: []}, + command: 'bansafe signal list --json', + path: '/platform/bansafe/signals', + }, + ])( + 'uses the typed Platform route for $command', + async ({body, command, path}) => { + server = await startHttpServer({body}) + + const result = await platformEnvironment(() => runCommand(command)) + + expect(result.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({method: 'GET', url: path}) + }, + ) + + test('views current telemetry and requests bounded history filters', async () => { + server = await startHttpServer({ + body: {data: [], page: {hasMore: false, nextCursor: null}}, + }) + + const result = await platformEnvironment(() => + runCommand( + 'bansafe telemetry history session/1 --since 2026-09-01T00:00:00Z --until 2026-09-08T00:00:00Z --cursor next_1 --limit 50 --json', + ), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toBe( + '/platform/bansafe/telemetry/session%2F1/history?cursor=next_1&limit=50&since=2026-09-01T00%3A00%3A00Z&until=2026-09-08T00%3A00%3A00Z', + ) + }) + + test('gets Health history without inventing cursor pagination', async () => { + server = await startHttpServer({ + body: {data: {points: [], session: 'session_1', sessionId: 'id_1'}}, + }) + + const result = await platformEnvironment(() => + runCommand( + 'bansafe health history session_1 --since 2026-09-01T00:00:00Z --limit 720 --json', + ), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toBe( + '/platform/bansafe/health/session_1/history?limit=720&since=2026-09-01T00%3A00%3A00Z', + ) + }) + + test('replaces a complete Health policy with idempotency', async () => { + server = await startHttpServer({body: {data: policy()}}) + const input = await jsonInput('policy.json', { + emailNotification: true, + enabled: true, + sessionAction: 'slow_down', + slowDownMps: 0.5, + threshold: 30, + version: 4, + webhookNotification: false, + }) + + const result = await platformEnvironment(() => + runCommand( + `bansafe health-policy update --project project_1 --input ${input} --idempotency-key policy_4 --json`, + ), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({ + method: 'PUT', + url: '/platform/projects/project_1/health-policy', + }) + expect(server.requests[0]?.headers['idempotency-key']).toBe('policy_4') + expect(JSON.parse(server.requests[0]?.body ?? '')).toMatchObject({ + sessionAction: 'slow_down', + slowDownMps: 0.5, + version: 4, + }) + }) + + test('prints fractional claim credit quantities as credits', async () => { + server = await startHttpServer({ + body: { + data: { + amountCents: 12.345678, + capCents: 50.5, + createdAt: '2026-09-08T00:00:00Z', + decidedAt: null, + evidence: { + attributionRuleVersion: 1, + criticalFindingDays: 0, + deviceEvidence: true, + measuredHours: 24, + otherDevices: 0, + restrictedInWindow: true, + sharedConnection: false, + windowDays: 7, + }, + id: 'claim_1', + incidentId: 'incident_1', + measuredCents: 0.000001, + paidAt: null, + phoneNumber: '+1', + projectId: 'p1', + reason: 'Attributed to Polymorfa.', + session: 's1', + sessionId: 'id1', + status: 'under_review', + summary: 'Restricted inside the claim window.', + verdict: 'ours', + windowEnd: '2026-09-08T00:00:00Z', + windowStart: '2026-09-01T00:00:00Z', + }, + }, + }) + + const result = await platformEnvironment(() => + runCommand('bansafe claim view claim_1'), + ) + + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toBe('/platform/bansafe/claims/claim_1') + expect(result.stdout).toContain('Amount credits\t12.345678') + expect(result.stdout).toContain('Measured credits\t0.000001') + expect(result.stdout).toContain('Cap credits\t50.5') + expect(result.stdout).not.toContain('cents') + }) + + test('requires idempotency for Health policy updates', async () => { + server = await startHttpServer({body: {data: policy()}}) + const input = await jsonInput('policy.json', policy()) + + const result = await platformEnvironment(() => + runCommand( + `bansafe health-policy update --project project_1 --input ${input} --json`, + ), + ) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('Missing required flag idempotency-key') + expect(server.requests).toHaveLength(0) + }) + + test('rejects an incomplete or contradictory Health policy before transport', async () => { + server = await startHttpServer({body: {data: policy()}}) + const input = await jsonInput('invalid-policy.json', { + emailNotification: false, + enabled: true, + sessionAction: 'slow_down', + slowDownMps: null, + threshold: 30, + version: 4, + webhookNotification: false, + }) + + const result = await platformEnvironment(() => + runCommand( + `bansafe health-policy update --project project_1 --input ${input} --idempotency-key policy_invalid --json`, + ), + ) + + expect(exitCode(result.error)).toBe(2) + expect(result.stderr).toContain('slow_down requires a positive slowDownMps') + expect(server.requests).toHaveLength(0) + }) + + test('rejects collection and action limits above the public maximum', async () => { + server = await startHttpServer({ + body: {data: [], page: {hasMore: false, nextCursor: null}}, + }) + + const collection = await platformEnvironment(() => + runCommand( + 'bansafe collection list --project project_1 --limit 101 --json', + ), + ) + const actions = await platformEnvironment(() => + runCommand( + 'bansafe health-action list --project project_1 --limit 101 --json', + ), + ) + + expect(exitCode(collection.error)).toBe(2) + expect(exitCode(actions.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + test.each([ + [ + 'bansafe finding list --project p1 --status open --json', + '/platform/bansafe/findings?projectId=p1&status=open', + ], + [ + 'bansafe enforcement list --project p1 --rung throttle --json', + '/platform/bansafe/enforcement?projectId=p1&rung=throttle', + ], + [ + 'bansafe incident list --project p1 --session s1 --json', + '/platform/bansafe/incidents?projectId=p1&session=s1', + ], + [ + 'bansafe claim list --project p1 --status paid --json', + '/platform/bansafe/claims?projectId=p1&status=paid', + ], + ])( + 'lists existing machine-readable BanSafe resources: %s', + async (command, path) => { + server = await startHttpServer({ + body: {data: [], page: {hasMore: false, nextCursor: null}}, + }) + const result = await platformEnvironment(() => runCommand(command)) + expect(result.error).toBeUndefined() + expect(server.requests[0]?.url).toBe(path) + }, + ) + + test('reports and retracts customer incidents with explicit retry identity', async () => { + server = await startHttpServer({ + body: { + data: { + created: true, + incidentId: 'incident_1', + occurredAt: '2026-09-08T00:00:00Z', + session: 's1', + sessionId: 'id1', + }, + }, + }) + const report = await platformEnvironment(() => + runCommand( + 'bansafe incident report --session s1 --occurred-at 2026-09-08T00:00:00Z --note observed --idempotency-key report_1 --json', + ), + ) + expect(report.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({ + method: 'POST', + url: '/platform/bansafe/incidents', + }) + expect(server.requests[0]?.headers['idempotency-key']).toBe('report_1') + expect(JSON.parse(server.requests[0]?.body ?? '')).toEqual({ + note: 'observed', + occurredAt: '2026-09-08T00:00:00Z', + session: 's1', + }) + + await server.close() + server = await startHttpServer({body: {data: incident()}}) + const retract = await platformEnvironment(() => + runCommand( + 'bansafe incident retract incident/1 --idempotency-key retract_1 --json', + ), + ) + expect(retract.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({ + method: 'POST', + url: '/platform/bansafe/incidents/incident%2F1/retract', + }) + expect(server.requests[0]?.headers['idempotency-key']).toBe('retract_1') + }) + + test('gets and updates project and session Safe Mode through separate typed resources', async () => { + server = await startHttpServer({body: {data: projectSafeMode()}}) + const input = await jsonInput('safe-mode.json', { + onlineEnd: 18, + onlineStart: 9, + pacing: 'jittered', + presence: 'dark', + }) + const project = await platformEnvironment(() => + runCommand( + `bansafe safe-mode update --project p1 --input ${input} --idempotency-key safe_1 --json`, + ), + ) + expect(project.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({ + method: 'PUT', + url: '/platform/projects/p1/safe-mode', + }) + expect(JSON.parse(server.requests[0]?.body ?? '')).toMatchObject({ + onlineEnd: 18, + onlineStart: 9, + }) + + await server.close() + server = await startHttpServer({body: {data: sessionSafeMode()}}) + const sessionInput = await jsonInput('session-safe-mode.json', { + pacing: 'jittered', + presence: 'dark', + }) + const session = await platformEnvironment(() => + runCommand( + `bansafe safe-mode update session/1 --input ${sessionInput} --idempotency-key safe_2 --json`, + ), + ) + expect(session.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({ + method: 'PUT', + url: '/platform/sessions/session%2F1/safe-mode', + }) + }) + + test('updates warmup with a validated patch', async () => { + server = await startHttpServer({body: {data: warmupPlan()}}) + const input = await jsonInput('warmup.json', { + dailyStart: 10, + enabled: true, + warmupDays: 14, + }) + const result = await platformEnvironment(() => + runCommand( + `bansafe warmup update --project p1 --input ${input} --idempotency-key warmup_1 --json`, + ), + ) + expect(result.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({ + method: 'PUT', + url: '/platform/projects/p1/warmup-plan', + }) + expect(server.requests[0]?.headers['idempotency-key']).toBe('warmup_1') + }) + + test('gets and updates insurance evidence settings', async () => { + server = await startHttpServer({ + body: { + data: {banInsuranceIncluded: true, enabled: true, projectId: 'p1'}, + }, + }) + const view = await platformEnvironment(() => + runCommand('bansafe insurance-evidence view --project p1 --json'), + ) + expect(view.error).toBeUndefined() + expect(server.requests[0]?.url).toBe( + '/platform/projects/p1/insurance-evidence', + ) + + await server.close() + server = await startHttpServer({ + body: { + data: {banInsuranceIncluded: true, enabled: false, projectId: 'p1'}, + }, + }) + const input = await jsonInput('insurance.json', {enabled: false}) + const update = await platformEnvironment(() => + runCommand( + `bansafe insurance-evidence update --project p1 --input ${input} --idempotency-key insurance_1 --json`, + ), + ) + expect(update.error).toBeUndefined() + expect(server.requests[0]).toMatchObject({ + method: 'PUT', + url: '/platform/projects/p1/insurance-evidence', + }) + expect(server.requests[0]?.headers['idempotency-key']).toBe('insurance_1') + }) + + test('invalid mutation arguments stop before any live request', async () => { + server = await startHttpServer({body: {data: {}}}) + const missingRetryIdentity = await platformEnvironment(() => + runCommand('bansafe incident report --session s1 --json'), + ) + const missingSafeModeTarget = await platformEnvironment(() => + runCommand('bansafe safe-mode view --json'), + ) + const invalidWarmup = await jsonInput('invalid-warmup.json', { + warmupDays: -1, + }) + const invalidPatch = await platformEnvironment(() => + runCommand( + `bansafe warmup update --project p1 --input ${invalidWarmup} --idempotency-key invalid_1 --json`, + ), + ) + expect(exitCode(missingRetryIdentity.error)).toBe(2) + expect(exitCode(missingSafeModeTarget.error)).toBe(2) + expect(exitCode(invalidPatch.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + async function jsonInput(name: string, value: unknown): Promise { + directory ??= await mkdtemp(join(tmpdir(), 'polymorfa-bansafe-')) + const path = join(directory, name) + await writeFile(path, JSON.stringify(value)) + return path + } + + async function platformEnvironment(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA', + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + run, + ) + } +}) + +function policy() { + return { + emailNotification: true, + enabled: true, + integrations: {emailConfigured: true, webhookConfigured: false}, + projectId: 'project_1', + sessionAction: 'slow_down', + slowDownMps: 0.5, + threshold: 30, + version: 4, + webhookNotification: false, + } +} + +function incident() { + return { + ambiguous: false, + claimId: null, + closedAt: null, + closedBy: null, + createdAt: '2026-09-08T00:00:00Z', + endsAt: null, + id: 'incident_1', + kind: 'customer_report', + note: null, + phoneNumber: '+1', + projectId: 'p1', + reportedBy: 'key_1', + resolution: 'open', + session: 's1', + sessionId: 'id1', + source: 'customer', + startedAt: '2026-09-08T00:00:00Z', + } +} + +function settings() { + return { + onlineEnd: 17, + onlineStart: 9, + pacing: 'jittered', + presence: 'dark', + reads: 'replied_chats', + typing: 'before_text', + } +} + +function projectSafeMode() { + return { + ceiling: settings(), + entitled: true, + entitlementReason: null, + projectId: 'p1', + } +} + +function sessionSafeMode() { + return { + applied: null, + effective: settings(), + entitled: true, + entitlementReason: null, + mismatch: false, + override: { + pacing: 'inherit', + presence: 'inherit', + reads: 'inherit', + typing: 'inherit', + }, + project: settings(), + projectId: 'p1', + session: 's1', + } +} + +function warmupPlan() { + return { + ceiling: 100, + curve: [], + entitled: true, + entitlementReason: null, + plan: {dailyStart: 10, enabled: true, warmupDays: 14}, + projectId: 'p1', + } +} + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +}