diff --git a/README.md b/README.md index 92baf218..0c239b0e 100644 --- a/README.md +++ b/README.md @@ -862,6 +862,30 @@ optional address lookup stops after five seconds without retrying and respects a shorter `--timeout`. If it fails or times out, the command keeps the password on screen and exits successfully. +### Call usage and limits + +When Calls access is enabled for a team, Polymorfa measures the calls its numbers host. Usage is measured, not charged. +Commands require `sessions:read`; a project token reads only its own project's +usage and needs `--project` or a project-bound profile. `usage gates` needs a +team API key, because gate state covers the whole team. + +```shell +polymorfa usage summary +polymorfa usage summary --period 2026-09 --session support +polymorfa usage records --call 3EB0C431C26A1916E07A +polymorfa usage records --session support --meter call.duration --limit 50 +polymorfa usage records --session support --cursor +polymorfa usage gates --session support +``` + +`usage summary` totals one calendar month (UTC) by meter and by number, and +defaults to the current month; without `--project`, each number row also shows +its project. `usage records` lists records newest first with each record's +project, and prints the cursor for the next page. `usage gates` shows each gate's mode, +limit, usage this month and how often an action went over the limit in the last +30 days: in `record` mode nothing is refused, and in `enforce` mode an action +over the limit exits with code 4 (`gate_limit_reached`). + ## Session call rejection and stable identities `call reject` is a separate command for the Linked Device session route. It diff --git a/package-lock.json b/package-lock.json index 4bd54b5c..43bccdd0 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260923124417", + "@polymorfa/sdk": "0.1.0-dev.20260923132258", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, @@ -997,10 +997,9 @@ } }, "node_modules/@polymorfa/sdk": { - "version": "0.1.0-dev.20260923124417", - "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923124417.tgz", - "integrity": "sha512-mwcZVuYgFmLJeTSMT12I4YhFFUVKrdXzO+N4LEPAVNIstIxXiC8K8AB4eEmMIzOsShVndcINyj/BZfoTahdPlw==", - "license": "MIT", + "version": "0.1.0-dev.20260923132258", + "resolved": "https://registry.npmjs.org/@polymorfa/sdk/-/sdk-0.1.0-dev.20260923132258.tgz", + "integrity": "sha512-ol2nSNl0tfRJQOAk1kywPwMu3+7KJxO1pVVlZj5S6Akj2+mnyKzjSgSU1EVHIObDLY9LLgg/kkTSeVbBmf0FGg==", "engines": { "node": ">=20" } diff --git a/package.json b/package.json index fefad1fb..4034f73b 100644 --- a/package.json +++ b/package.json @@ -33,7 +33,7 @@ "@modelcontextprotocol/server": "2.0.0", "@napi-rs/keyring": "1.3.0", "@oclif/core": "^4.13.5", - "@polymorfa/sdk": "0.1.0-dev.20260923124417", + "@polymorfa/sdk": "0.1.0-dev.20260923132258", "jq-wasm": "3.0.0-jq-1.8.2", "zod": "4.2.0" }, diff --git a/src/commands/usage/gates.ts b/src/commands/usage/gates.ts new file mode 100644 index 00000000..cb7f5687 --- /dev/null +++ b/src/commands/usage/gates.ts @@ -0,0 +1,40 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import { + createUsageResource, + listUsageGates, + renderUsageGates, +} from '../../services/usage.js' + +export default class UsageGates extends BaseCommand { + static override description = + 'List your usage gates with their mode, limit, usage this month and how often an action went over the limit in the last 30 days. In record mode a gate never refuses an action; in enforce mode an action over the limit fails with gate_limit_reached. Pass --session to include limits that apply per number. Gate state covers the whole team, so this needs a team API key; project API keys are refused. A limit that cannot be checked fails the action with 503 in either mode.' + + static override examples = [ + '<%= config.bin %> usage gates', + '<%= config.bin %> usage gates --session support --json', + ] + + static override flags = { + session: Flags.string({ + description: 'Include limits that apply to one number (session name).', + }), + } + + static override summary = 'List usage gates and their limits.' + + async run(): Promise { + const {flags} = await this.parse(UsageGates) + const result = await listUsageGates( + createUsageResource( + await this.developerEnvironment(flags, {inferOwner: true}), + ), + { + ...(flags.project === undefined ? {} : {project: flags.project}), + ...(flags.session === undefined ? {} : {session: flags.session}), + }, + ) + this.writeValue(result, renderUsageGates(result.data), flags) + } +} diff --git a/src/commands/usage/records.ts b/src/commands/usage/records.ts new file mode 100644 index 00000000..b60d3914 --- /dev/null +++ b/src/commands/usage/records.ts @@ -0,0 +1,56 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import { + createUsageResource, + listUsageRecords, + renderUsageRecords, +} from '../../services/usage.js' + +export default class UsageRecords extends BaseCommand { + static override description = + 'List usage records, newest first. Filter by call, number, meter or calendar month. A call has one call.duration record, and an outgoing Cloud API call also a call.cloud_pulses record. Pass the printed cursor to read the next page.' + + static override examples = [ + '<%= config.bin %> usage records --call 3EB0C431C26A1916E07A', + '<%= config.bin %> usage records --session support --meter call.duration --limit 50 --json', + ] + + static override flags = { + call: Flags.string({description: 'One call, by call ID.'}), + cursor: Flags.string({description: 'Continue from a previous page.'}), + limit: Flags.integer({ + description: 'Records per page, 1 to 100.', + max: 100, + min: 1, + }), + meter: Flags.string({ + description: 'One meter, for example call.duration.', + }), + period: Flags.string({ + description: 'Calendar month in UTC as YYYY-MM.', + }), + session: Flags.string({description: 'Limit to one number (session name).'}), + } + + static override summary = 'List usage records.' + + async run(): Promise { + const {flags} = await this.parse(UsageRecords) + const result = await listUsageRecords( + createUsageResource( + await this.developerEnvironment(flags, {inferOwner: true}), + ), + { + ...(flags.call === undefined ? {} : {callId: flags.call}), + ...(flags.cursor === undefined ? {} : {cursor: flags.cursor}), + ...(flags.limit === undefined ? {} : {limit: flags.limit}), + ...(flags.meter === undefined ? {} : {meter: flags.meter}), + ...(flags.period === undefined ? {} : {period: flags.period}), + ...(flags.project === undefined ? {} : {project: flags.project}), + ...(flags.session === undefined ? {} : {session: flags.session}), + }, + ) + this.writeValue(result, renderUsageRecords(result.data), flags) + } +} diff --git a/src/commands/usage/summary.ts b/src/commands/usage/summary.ts new file mode 100644 index 00000000..a4c003a0 --- /dev/null +++ b/src/commands/usage/summary.ts @@ -0,0 +1,42 @@ +import {Flags} from '@oclif/core' + +import {BaseCommand} from '../../base-command.js' +import { + createUsageResource, + getUsageSummary, + renderUsageSummary, +} from '../../services/usage.js' + +export default class UsageSummary extends BaseCommand { + static override description = + 'Show metered usage for one calendar month (UTC), by meter and by number. Usage is measured, not charged: no credits are deducted for it. Requires sessions:read. A project token reads only its own project.' + + static override examples = [ + '<%= config.bin %> usage summary', + '<%= config.bin %> usage summary --period 2026-09 --session support --json', + ] + + static override flags = { + period: Flags.string({ + description: 'Calendar month in UTC as YYYY-MM. Defaults to this month.', + }), + session: Flags.string({description: 'Limit to one number (session name).'}), + } + + static override summary = 'Show metered usage for a month.' + + async run(): Promise { + const {flags} = await this.parse(UsageSummary) + const result = await getUsageSummary( + createUsageResource( + await this.developerEnvironment(flags, {inferOwner: true}), + ), + { + ...(flags.period === undefined ? {} : {period: flags.period}), + ...(flags.project === undefined ? {} : {project: flags.project}), + ...(flags.session === undefined ? {} : {session: flags.session}), + }, + ) + this.writeValue(result, renderUsageSummary(result.data), flags) + } +} diff --git a/src/services/usage.ts b/src/services/usage.ts new file mode 100644 index 00000000..da0f3fa8 --- /dev/null +++ b/src/services/usage.ts @@ -0,0 +1,221 @@ +import { + Client, + PolymorfaConfigurationError, + PolymorfaValidationError, + type ResponseMetadata, + type UsageGateList, + type UsageMeter, + type UsageRecordPage, + type UsageResource, + type UsageSummary, +} from '@polymorfa/sdk' + +import type {DeveloperEnvironment} from '../context/environment.js' + +export interface UsageResult { + readonly data: T + readonly metadata: { + readonly apiVersion?: string + readonly attempts: number + readonly requestId?: string + readonly status: number + } +} + +export interface UsageFilters { + readonly callId?: string + readonly cursor?: string + readonly limit?: number + readonly meter?: string + readonly period?: string + readonly project?: string + readonly session?: string +} + +const PERIOD = /^\d{4}-(0[1-9]|1[0-2])$/ + +export function createUsageResource( + environment: DeveloperEnvironment, +): UsageResource { + const options = { + baseUrl: environment.baseUrl, + ...(environment.apiVersion === undefined + ? {} + : {apiVersion: environment.apiVersion}), + ...(environment.timeoutMs === undefined + ? {} + : {timeoutMs: environment.timeoutMs}), + } + const projectId = environment.context.projectId + if (environment.context.principalType === 'project_token') { + if (!projectId) { + throw new PolymorfaConfigurationError( + 'A project token requires --project or a project-bound profile.', + 'project', + ) + } + return new Client({ + ...options, + credential: {type: 'projectToken', value: environment.credential}, + projectId, + }).usage + } + const client = new Client({ + ...options, + credential: {type: 'organizationApiKey', value: environment.credential}, + }) + return environment.context.owner === 'project' && projectId + ? client.project(projectId).usage + : client.usage +} + +export function validatePeriod(period?: string): string | undefined { + if (period === undefined) return undefined + if (!PERIOD.test(period)) { + throw new PolymorfaValidationError( + 'Use a calendar month in the form YYYY-MM, for example 2026-09.', + {code: 'invalid_usage_period'}, + ) + } + return period +} + +export async function getUsageSummary( + usage: UsageResource, + filters: UsageFilters, +): Promise> { + const period = validatePeriod(filters.period) + const response = await usage.summary({ + ...(period === undefined ? {} : {period}), + ...(filters.project === undefined ? {} : {projectId: filters.project}), + ...(filters.session === undefined ? {} : {session: filters.session}), + }) + return result(response.data, response.metadata) +} + +export async function listUsageRecords( + usage: UsageResource, + filters: UsageFilters, +): Promise> { + const period = validatePeriod(filters.period) + const response = await usage.listRecords({ + ...(filters.callId === undefined ? {} : {callId: filters.callId}), + ...(filters.cursor === undefined ? {} : {cursor: filters.cursor}), + ...(filters.limit === undefined ? {} : {limit: filters.limit}), + // Keep accepting future meter names at the CLI boundary. The API validates + // names the installed SDK has not typed yet. + ...(filters.meter === undefined + ? {} + : {meter: filters.meter as UsageMeter}), + ...(period === undefined ? {} : {period}), + ...(filters.project === undefined ? {} : {projectId: filters.project}), + ...(filters.session === undefined ? {} : {session: filters.session}), + }) + return result(response.data, response.metadata) +} + +export async function listUsageGates( + usage: UsageResource, + filters: UsageFilters, +): Promise> { + const response = await usage.listGates({ + ...(filters.project === undefined ? {} : {projectId: filters.project}), + ...(filters.session === undefined ? {} : {session: filters.session}), + }) + return result(response.data, response.metadata) +} + +export function renderUsageSummary(summary: UsageSummary): string { + const lines = [ + `Period\t${summary.period}`, + `Project\t${summary.projectId ?? 'every project'}`, + `Number\t${summary.session ?? 'every number'}`, + `Charged\t${summary.billingEnabled ? 'yes' : 'no (measured only)'}`, + ] + lines.push( + summary.meters.length === 0 + ? 'No usage recorded for this period.' + : summary.meters + .map( + (total) => + `${total.meter}\t${total.quantity} ${total.unit}\t${total.records} records\t${total.keySource}`, + ) + .join('\n'), + ) + if (summary.numbers.length > 0) { + // Without a project, numbers from several projects can share a session + // name, so each row names its project. + const crossProject = summary.projectId === null + lines.push( + 'Numbers', + ...summary.numbers.map((number) => + [ + number.session, + ...(crossProject ? [number.projectId ?? '-'] : []), + number.meters + .map((total) => `${total.meter} ${total.quantity} ${total.unit}`) + .join(', '), + ].join('\t'), + ), + ) + } + if (summary.numbersTruncated) lines.push('More than 100 numbers had usage.') + return lines.join('\n') +} + +export function renderUsageRecords(page: UsageRecordPage): string { + if (page.records.length === 0) return 'No usage records found.' + const rows = page.records.map((record) => + [ + record.occurredAt, + record.meter, + `${record.quantity} ${record.unit}`, + record.session ?? '-', + record.projectId ?? '-', + record.sourceId, + record.pricingState, + `rev ${record.revision}`, + ].join('\t'), + ) + if (page.nextCursor !== null) { + rows.push(`Next page\t--cursor ${page.nextCursor}`) + } + return rows.join('\n') +} + +export function renderUsageGates(list: UsageGateList): string { + if (list.gates.length === 0) return 'No usage gates found.' + return list.gates + .map((gate) => + [ + gate.key, + gate.mode, + gate.active ? 'checked' : 'not checked yet', + `limit ${limitLabel(gate.limit)}`, + `used ${gate.used ?? '-'}`, + `over limit last 30 days ${gate.decisions.wouldBlock + gate.decisions.blocked}`, + ].join('\t'), + ) + .join('\n') +} + +function limitLabel(limit: null | number): string { + if (limit === null) return 'per number (pass --session)' + return limit < 0 ? 'none' : String(limit) +} + +function result(data: T, metadata: ResponseMetadata): UsageResult { + return { + data, + metadata: { + attempts: metadata.attempts, + status: metadata.status, + ...(metadata.requestId === undefined + ? {} + : {requestId: metadata.requestId}), + ...(metadata.apiVersion === undefined + ? {} + : {apiVersion: metadata.apiVersion}), + }, + } +} diff --git a/test/commands/usage.test.ts b/test/commands/usage.test.ts new file mode 100644 index 00000000..1aa474c1 --- /dev/null +++ b/test/commands/usage.test.ts @@ -0,0 +1,305 @@ +import {runCommand} from '@oclif/test' +import {afterEach, describe, expect, test} from 'vitest' + +import { + startHttpSequenceServer, + withEnvironment, + type TestHttpServer, +} from '../helpers/http-server.js' + +const API_KEY = + 'pmfa_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' +const PROJECT_TOKEN = + 'pmfa_pt_AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' + +const PROJECT_A = '22222222-2222-4222-8222-222222222222' +const PROJECT_B = '33333333-3333-4333-8333-333333333333' + +const meterTotal = { + keySource: 'none', + meter: 'call.duration', + quantity: 5400, + records: 42, + unit: 'second', +} +const summary = { + billingEnabled: false, + end: '2026-10-01T00:00:00.000Z', + meters: [meterTotal], + numbers: [ + {meters: [meterTotal], projectId: PROJECT_A, session: 'support'}, + {meters: [meterTotal], projectId: PROJECT_B, session: 'support'}, + ], + numbersTruncated: false, + period: '2026-09', + projectId: null, + session: null, + start: '2026-09-01T00:00:00.000Z', +} +const record = { + dimensions: { + connections: 1, + direction: 'outbound', + origin: 'direct', + participants: 1, + sipLegs: 0, + upstream: 'linked_device', + video: false, + }, + id: '11111111-1111-4111-8111-111111111111', + keySource: 'none', + meter: 'call.duration', + occurredAt: '2026-09-19T10:00:00.000Z', + pricedCredits: null, + pricingState: 'unpriced', + projectId: PROJECT_A, + quantity: 42, + revision: 1, + session: 'support', + sourceId: 'CALL-1', + sourceKind: 'call', + unit: 'second', +} +const gates = { + gates: [ + { + active: true, + decisions: {blocked: 0, evaluationError: 0, wouldBlock: 3}, + key: 'calls.outbound_monthly', + kind: 'quota', + limit: 50, + mode: 'record', + overLimit: false, + subject: 'number', + unit: 'call', + used: 4, + }, + { + active: false, + decisions: {blocked: 0, evaluationError: 0, wouldBlock: 0}, + key: 'voice.flows', + kind: 'capability', + limit: 1, + mode: 'record', + overLimit: false, + subject: 'team', + unit: null, + used: null, + }, + ], + session: 'support', +} + +describe('usage commands', () => { + let server: TestHttpServer | undefined + + afterEach(async () => { + await server?.close() + server = undefined + }) + + test('summarizes a month and lists a call record page', async () => { + server = await startHttpSequenceServer([ + {body: {data: summary, success: true}}, + { + body: { + data: {nextCursor: 'cursor-2', records: [record]}, + success: true, + }, + }, + ]) + + const summarized = await apiKey(() => + runCommand('usage summary --period 2026-09'), + ) + const listed = await apiKey(() => + runCommand('usage records --call CALL-1 --limit 10'), + ) + + expect(summarized.error).toBeUndefined() + expect(summarized.stdout).toContain('Period\t2026-09') + expect(summarized.stdout).toContain('Charged\tno (measured only)') + expect(summarized.stdout).toContain( + 'call.duration\t5400 second\t42 records', + ) + expect(summarized.stdout).toContain( + `support\t${PROJECT_A}\tcall.duration 5400 second`, + ) + expect(summarized.stdout).toContain( + `support\t${PROJECT_B}\tcall.duration 5400 second`, + ) + expect(listed.stdout).toContain( + `call.duration\t42 second\tsupport\t${PROJECT_A}\tCALL-1`, + ) + expect(listed.stdout).toContain('Next page\t--cursor cursor-2') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/usage?period=2026-09', + 'GET /platform/usage/records?callId=CALL-1&limit=10', + ]) + }) + + test('omits the per-number project when the summary covers one project', async () => { + server = await startHttpSequenceServer([ + { + body: { + data: { + ...summary, + numbers: [ + {meters: [meterTotal], projectId: PROJECT_A, session: 'support'}, + ], + projectId: PROJECT_A, + }, + success: true, + }, + }, + ]) + + const summarized = await apiKey(() => + runCommand(`usage summary --project ${PROJECT_A}`), + ) + + expect(summarized.error).toBeUndefined() + expect(summarized.stdout).toContain(`Project\t${PROJECT_A}`) + expect(summarized.stdout).toContain('support\tcall.duration 5400 second') + expect(summarized.stdout).not.toContain(`support\t${PROJECT_A}`) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + `GET /platform/usage?projectId=${PROJECT_A}`, + ]) + }) + + test('uses a project token only for its explicit project', async () => { + server = await startHttpSequenceServer([ + {body: {data: {...summary, projectId: PROJECT_A}, success: true}}, + {body: {data: {nextCursor: null, records: [record]}, success: true}}, + ]) + + const missing = await projectToken(() => runCommand('usage summary')) + const summarized = await projectToken(() => + runCommand(`usage summary --project ${PROJECT_A}`), + ) + const listed = await projectToken(() => + runCommand(`usage records --project ${PROJECT_A}`), + ) + + expect(exitCode(missing.error)).toBe(2) + expect(missing.stderr).toContain('--project') + expect(summarized.error).toBeUndefined() + expect(listed.error).toBeUndefined() + expect(server.requests.map(({url}) => url)).toEqual([ + `/platform/usage?projectId=${PROJECT_A}`, + `/platform/usage/records?projectId=${PROJECT_A}`, + ]) + expect(server.requests.map(({headers}) => headers.authorization)).toEqual([ + `Bearer ${PROJECT_TOKEN}`, + `Bearer ${PROJECT_TOKEN}`, + ]) + }) + + test('lists gates with their mode and limit', async () => { + server = await startHttpSequenceServer([ + {body: {data: gates, success: true}}, + ]) + + const listed = await apiKey(() => + runCommand('usage gates --session support'), + ) + + expect(listed.error).toBeUndefined() + expect(listed.stdout).toContain( + 'calls.outbound_monthly\trecord\tchecked\tlimit 50\tused 4\tover limit last 30 days 3', + ) + expect(listed.stdout).toContain('voice.flows\trecord\tnot checked yet') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/gates?session=support', + ]) + }) + + test('keeps typed SDK data and response metadata in JSON output', async () => { + server = await startHttpSequenceServer([ + { + body: {data: {nextCursor: null, records: [record]}, success: true}, + headers: {'x-request-id': 'usage-request-1'}, + }, + ]) + + const listed = await apiKey(() => + runCommand('usage records --meter call.duration --json'), + ) + + expect(listed.error).toBeUndefined() + expect(JSON.parse(listed.stdout)).toMatchObject({ + data: {nextCursor: null, records: [record]}, + metadata: {attempts: 1, requestId: 'usage-request-1', status: 200}, + }) + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/usage/records?meter=call.duration', + ]) + }) + + test('reports gate credential refusal without presenting an empty list', async () => { + server = await startHttpSequenceServer([ + { + body: { + error: { + code: 'permission_denied', + message: 'Gate state needs a team API key.', + type: 'permission_error', + }, + success: false, + }, + status: 403, + }, + ]) + + const denied = await apiKey(() => runCommand('usage gates')) + + expect(exitCode(denied.error)).toBe(3) + expect(denied.stderr).toContain('Gate state needs a team API key.') + expect(denied.stdout).toBe('') + expect(server.requests.map(({method, url}) => `${method} ${url}`)).toEqual([ + 'GET /platform/gates', + ]) + }) + + test('refuses a malformed period before any request', async () => { + server = await startHttpSequenceServer([ + {body: {data: summary, success: true}}, + ]) + + const failed = await apiKey(() => + runCommand('usage summary --period 2026-9'), + ) + + expect(exitCode(failed.error)).toBe(2) + expect(server.requests).toHaveLength(0) + }) + + async function apiKey(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: API_KEY, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + }, + run, + ) + } + + async function projectToken(run: () => Promise): Promise { + if (server === undefined) throw new Error('Test server was not started.') + return withEnvironment( + { + POLYMORFA_API_KEY: undefined, + POLYMORFA_BASE_URL: server.baseUrl, + POLYMORFA_CLIENT_TOKEN: undefined, + POLYMORFA_PROJECT_TOKEN: PROJECT_TOKEN, + }, + run, + ) + } +}) + +function exitCode(error: unknown): number | undefined { + return (error as {oclif?: {exit?: number}} | undefined)?.oclif?.exit +}