From c7ad5e5601fa1fed6ce5899ebe8ca9131c948cce Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 16 Sep 2026 12:01:14 +0300 Subject: [PATCH 1/5] feat(bansafe): add complete CLI support Replays the net BanSafe CLI change from codex/bansafe-health (previous head 206260e) onto dev 2dea634. Conflicted unified-client adaptations keep dev's versions; @polymorfa/sdk is pinned to polymorfa/sdks@289c921. --- README.md | 58 ++ docs/development/bansafe-sdk-integration.md | 54 ++ src/commands/bansafe/claim/list.ts | 39 + src/commands/bansafe/claim/view.ts | 16 + src/commands/bansafe/collection/list.ts | 29 + src/commands/bansafe/enforcement/list.ts | 30 + src/commands/bansafe/finding/list.ts | 39 + src/commands/bansafe/health-action/list.ts | 42 + src/commands/bansafe/health-policy/update.ts | 36 + src/commands/bansafe/health-policy/view.ts | 20 + src/commands/bansafe/health/history.ts | 39 + src/commands/bansafe/health/list.ts | 28 + src/commands/bansafe/health/view.ts | 20 + src/commands/bansafe/incident/list.ts | 25 + src/commands/bansafe/incident/report.ts | 36 + src/commands/bansafe/incident/retract.ts | 31 + .../bansafe/insurance-evidence/update.ts | 34 + .../bansafe/insurance-evidence/view.ts | 22 + src/commands/bansafe/safe-mode/update.ts | 48 ++ src/commands/bansafe/safe-mode/view.ts | 36 + src/commands/bansafe/signal/list.ts | 13 + src/commands/bansafe/telemetry/history.ts | 45 + src/commands/bansafe/telemetry/view.ts | 21 + src/commands/bansafe/warmup/update.ts | 31 + src/commands/bansafe/warmup/view.ts | 19 + src/services/bansafe.ts | 772 ++++++++++++++++++ test/commands/bansafe.test.ts | 484 +++++++++++ 27 files changed, 2067 insertions(+) create mode 100644 docs/development/bansafe-sdk-integration.md create mode 100644 src/commands/bansafe/claim/list.ts create mode 100644 src/commands/bansafe/claim/view.ts create mode 100644 src/commands/bansafe/collection/list.ts create mode 100644 src/commands/bansafe/enforcement/list.ts create mode 100644 src/commands/bansafe/finding/list.ts create mode 100644 src/commands/bansafe/health-action/list.ts create mode 100644 src/commands/bansafe/health-policy/update.ts create mode 100644 src/commands/bansafe/health-policy/view.ts create mode 100644 src/commands/bansafe/health/history.ts create mode 100644 src/commands/bansafe/health/list.ts create mode 100644 src/commands/bansafe/health/view.ts create mode 100644 src/commands/bansafe/incident/list.ts create mode 100644 src/commands/bansafe/incident/report.ts create mode 100644 src/commands/bansafe/incident/retract.ts create mode 100644 src/commands/bansafe/insurance-evidence/update.ts create mode 100644 src/commands/bansafe/insurance-evidence/view.ts create mode 100644 src/commands/bansafe/safe-mode/update.ts create mode 100644 src/commands/bansafe/safe-mode/view.ts create mode 100644 src/commands/bansafe/signal/list.ts create mode 100644 src/commands/bansafe/telemetry/history.ts create mode 100644 src/commands/bansafe/telemetry/view.ts create mode 100644 src/commands/bansafe/warmup/update.ts create mode 100644 src/commands/bansafe/warmup/view.ts create mode 100644 src/services/bansafe.ts create mode 100644 test/commands/bansafe.test.ts diff --git a/README.md b/README.md index 1917dc86..c13073b3 100644 --- a/README.md +++ b/README.md @@ -502,6 +502,64 @@ 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. 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..487b1c93 --- /dev/null +++ b/docs/development/bansafe-sdk-integration.md @@ -0,0 +1,54 @@ +# 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 verified integrated Platform contract SHA-256 is +`d529a62ddcc8e5b661b90db7ca9a36dddc30ca48a6a016c4f6b46e0fd471b9df`. + +```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 machine-permitted BanSafe operations in the SDK +coverage ledger: + +| 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..5628da69 --- /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 cents\t${claim.amountCents}`, + `Measured cents\t${claim.measuredCents}`, + `Cap cents\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..b529a0df --- /dev/null +++ b/test/commands/bansafe.test.ts @@ -0,0 +1,484 @@ +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('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, + updatedAt: '2026-09-08T00:00:00.000Z', + 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 +} From d50ee531afa96c818eab2e9293d9c9a3cd2f68a3 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 16 Sep 2026 12:01:14 +0300 Subject: [PATCH 2/5] fix(bansafe): label claim amounts as credit quantities The API at 1600403 returns claim measured, cap, and amount values as decimal credit quantities. Text output now labels them as credits, the Health policy fixture drops a field the contract does not define, and the integration note records the reconciled SDK and API pins. --- README.md | 4 +- docs/development/bansafe-sdk-integration.md | 13 ++++-- src/services/bansafe.ts | 6 +-- test/commands/bansafe.test.ts | 49 ++++++++++++++++++++- 4 files changed, 63 insertions(+), 9 deletions(-) diff --git a/README.md b/README.md index c13073b3..f985bf17 100644 --- a/README.md +++ b/README.md @@ -556,7 +556,9 @@ 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. Use `--json` to retain explanations, +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. diff --git a/docs/development/bansafe-sdk-integration.md b/docs/development/bansafe-sdk-integration.md index 487b1c93..0aba51c5 100644 --- a/docs/development/bansafe-sdk-integration.md +++ b/docs/development/bansafe-sdk-integration.md @@ -4,8 +4,11 @@ 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 verified integrated Platform contract SHA-256 is -`d529a62ddcc8e5b661b90db7ca9a36dddc30ca48a6a016c4f6b46e0fd471b9df`. +The pinned SDK revision is `polymorfa/sdks@289c921793a71f52c7fa85e941f36c45fa72e653`. +Its coverage ledger is reconciled against the merged API contracts at +`polymorfa/polymorfa@1600403db9dcf7996dbbb882375b1165859829d3` (Platform +snapshot SHA-256 +`c3c0d1107e8403c1ddc28649ca397eef35d82cd40be1674b3dc2ba5525a3590e`). ```shell export POLYMORFA_SDK_CHECKOUT=/absolute/path/to/bansafe-health-sdk @@ -30,8 +33,10 @@ 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 machine-permitted BanSafe operations in the SDK -coverage ledger: +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 | | ------------------------------------------------------------ | ---------------------------------------------------------- | diff --git a/src/services/bansafe.ts b/src/services/bansafe.ts index 5628da69..e42d5b5e 100644 --- a/src/services/bansafe.ts +++ b/src/services/bansafe.ts @@ -472,9 +472,9 @@ export function renderClaim(claim: BanSafeClaim): string { `Session\t${claim.session}`, `Status\t${claim.status}`, `Verdict\t${claim.verdict}`, - `Amount cents\t${claim.amountCents}`, - `Measured cents\t${claim.measuredCents}`, - `Cap cents\t${claim.capCents}`, + `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}`, diff --git a/test/commands/bansafe.test.ts b/test/commands/bansafe.test.ts index b529a0df..57f0e315 100644 --- a/test/commands/bansafe.test.ts +++ b/test/commands/bansafe.test.ts @@ -122,6 +122,54 @@ describe('BanSafe commands', () => { }) }) + 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()) @@ -401,7 +449,6 @@ function policy() { sessionAction: 'slow_down', slowDownMps: 0.5, threshold: 30, - updatedAt: '2026-09-08T00:00:00.000Z', version: 4, webhookNotification: false, } From 7cf04a470fed2aaaa9077458a443fff17a920c2e Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 16 Sep 2026 15:31:59 +0300 Subject: [PATCH 3/5] chore(deps): pin SDK BanSafe head rebased on dev --- docs/development/bansafe-sdk-integration.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/docs/development/bansafe-sdk-integration.md b/docs/development/bansafe-sdk-integration.md index 0aba51c5..3ada23b9 100644 --- a/docs/development/bansafe-sdk-integration.md +++ b/docs/development/bansafe-sdk-integration.md @@ -4,11 +4,11 @@ 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@289c921793a71f52c7fa85e941f36c45fa72e653`. -Its coverage ledger is reconciled against the merged API contracts at -`polymorfa/polymorfa@1600403db9dcf7996dbbb882375b1165859829d3` (Platform +The pinned SDK revision is `polymorfa/sdks@0eeae656e129907fa0b6b87f7c2199279530f22a`. +Its coverage ledger is reconciled against the API contracts at +`polymorfa/polymorfa@aca849cda44ad8582d7ae87489404d2483173a53` (Platform snapshot SHA-256 -`c3c0d1107e8403c1ddc28649ca397eef35d82cd40be1674b3dc2ba5525a3590e`). +`2a30e8e0af8902703609f8ed0f5bb2c108b39c5c11863d5ebd8907e56a9f25d2`). ```shell export POLYMORFA_SDK_CHECKOUT=/absolute/path/to/bansafe-health-sdk From 792153a5c7955ad99b9e30c64a24a235e30bd715 Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Wed, 16 Sep 2026 18:57:17 +0300 Subject: [PATCH 4/5] chore(deps): pin SDK dev at the merged BanSafe change --- docs/development/bansafe-sdk-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development/bansafe-sdk-integration.md b/docs/development/bansafe-sdk-integration.md index 3ada23b9..a9f6a5e2 100644 --- a/docs/development/bansafe-sdk-integration.md +++ b/docs/development/bansafe-sdk-integration.md @@ -4,7 +4,7 @@ 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@0eeae656e129907fa0b6b87f7c2199279530f22a`. +The pinned SDK revision is `polymorfa/sdks@07b6a8abd774b7d103af465baa299bbb05295d8e`. Its coverage ledger is reconciled against the API contracts at `polymorfa/polymorfa@aca849cda44ad8582d7ae87489404d2483173a53` (Platform snapshot SHA-256 From b1895fee8be76fb8036af88fb5bc3852802dcb7d Mon Sep 17 00:00:00 2001 From: Rajeh Taher Date: Thu, 17 Sep 2026 00:05:19 +0300 Subject: [PATCH 5/5] docs: record the SDK dev pin after the cloud-launch merge --- docs/development/bansafe-sdk-integration.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/development/bansafe-sdk-integration.md b/docs/development/bansafe-sdk-integration.md index a9f6a5e2..fe4075b2 100644 --- a/docs/development/bansafe-sdk-integration.md +++ b/docs/development/bansafe-sdk-integration.md @@ -4,7 +4,7 @@ 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@07b6a8abd774b7d103af465baa299bbb05295d8e`. +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