@agentguard-run/spend 0.9.0 → 0.10.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/bindings/anthropic.js +18 -0
- package/dist/bindings/anthropic.js.map +1 -1
- package/dist/bindings/bedrock.js +9 -0
- package/dist/bindings/bedrock.js.map +1 -1
- package/dist/byo/index.d.ts +33 -0
- package/dist/byo/index.d.ts.map +1 -0
- package/dist/byo/index.js +344 -0
- package/dist/byo/index.js.map +1 -0
- package/dist/cli/byo.d.ts +4 -0
- package/dist/cli/byo.d.ts.map +1 -0
- package/dist/cli/byo.js +118 -0
- package/dist/cli/byo.js.map +1 -0
- package/dist/cli/init.d.ts.map +1 -1
- package/dist/cli/init.js +5 -0
- package/dist/cli/init.js.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/main.js +14 -1
- package/dist/cli/main.js.map +1 -1
- package/dist/governance.d.ts +77 -0
- package/dist/governance.d.ts.map +1 -0
- package/dist/governance.js +283 -0
- package/dist/governance.js.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -1
- package/dist/index.js.map +1 -1
- package/dist/spend-guard.d.ts +19 -0
- package/dist/spend-guard.d.ts.map +1 -1
- package/dist/spend-guard.js +75 -1
- package/dist/spend-guard.js.map +1 -1
- package/dist/types.d.ts +21 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +15 -2
- package/src/bindings/anthropic.ts +21 -0
- package/src/bindings/bedrock.ts +10 -0
- package/src/byo/index.ts +322 -0
- package/src/cli/byo.ts +87 -0
- package/src/governance.ts +376 -0
|
@@ -0,0 +1,376 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'crypto';
|
|
2
|
+
import { computeCallCents } from './cost-table';
|
|
3
|
+
import { canonicalJson } from './decision-log';
|
|
4
|
+
import type { CallContext, SpendDecision, SpendPolicy } from './types';
|
|
5
|
+
|
|
6
|
+
export interface CircuitBreakerConfig {
|
|
7
|
+
enabled?: boolean;
|
|
8
|
+
repeatThreshold?: number;
|
|
9
|
+
repeat_threshold?: number;
|
|
10
|
+
windowSeconds?: number;
|
|
11
|
+
window_seconds?: number;
|
|
12
|
+
planChurnMax?: number;
|
|
13
|
+
plan_churn_max?: number;
|
|
14
|
+
stepCap?: number;
|
|
15
|
+
step_cap?: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface WorkflowEnvelopeConfig {
|
|
19
|
+
maxUsdPerRun?: number;
|
|
20
|
+
max_usd_per_run?: number;
|
|
21
|
+
maxIterations?: number;
|
|
22
|
+
max_iterations?: number;
|
|
23
|
+
maxTokens?: number;
|
|
24
|
+
max_tokens?: number;
|
|
25
|
+
maxWallClockSeconds?: number;
|
|
26
|
+
max_wall_clock_seconds?: number;
|
|
27
|
+
dailyUsdCap?: number;
|
|
28
|
+
daily_usd_cap?: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export interface ToolGateApproval {
|
|
32
|
+
approved: boolean;
|
|
33
|
+
approver?: string;
|
|
34
|
+
verdict?: string;
|
|
35
|
+
editedArgs?: Record<string, unknown>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface ToolGateConfig {
|
|
39
|
+
enabled?: boolean;
|
|
40
|
+
mode?: 'human' | 'model';
|
|
41
|
+
destructivePatterns?: string[];
|
|
42
|
+
destructive_patterns?: string[];
|
|
43
|
+
escalationModel?: string;
|
|
44
|
+
escalation_model?: string;
|
|
45
|
+
escalationEffort?: 'low' | 'medium' | 'high' | 'xhigh';
|
|
46
|
+
escalation_effort?: 'low' | 'medium' | 'high' | 'xhigh';
|
|
47
|
+
humanApprover?: (input: ToolGateInput) => ToolGateApproval | Promise<ToolGateApproval>;
|
|
48
|
+
modelReviewer?: (input: ToolGateInput) => ToolGateApproval | Promise<ToolGateApproval>;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export interface ToolGateInput {
|
|
52
|
+
toolName: string;
|
|
53
|
+
toolArgs?: Record<string, unknown>;
|
|
54
|
+
workflowId?: string;
|
|
55
|
+
scope?: CallContext['scope'];
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export interface GovernanceBlock {
|
|
59
|
+
kind: 'circuit_breaker' | 'workflow_envelope';
|
|
60
|
+
reason: string;
|
|
61
|
+
projectedCents: number;
|
|
62
|
+
receipt: Record<string, unknown>;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface ToolGateResult {
|
|
66
|
+
approved: boolean;
|
|
67
|
+
matchedPattern: string | null;
|
|
68
|
+
mode: 'human' | 'model';
|
|
69
|
+
decision: SpendDecision;
|
|
70
|
+
editedArgs?: Record<string, unknown>;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface WorkflowState {
|
|
74
|
+
workflowId: string;
|
|
75
|
+
startedAtMs: number;
|
|
76
|
+
spendCents: number;
|
|
77
|
+
iterations: number;
|
|
78
|
+
tokens: number;
|
|
79
|
+
haltedReason?: string;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const DEFAULT_DESTRUCTIVE_PATTERNS = [
|
|
83
|
+
'db.write',
|
|
84
|
+
'db.delete',
|
|
85
|
+
'db.drop',
|
|
86
|
+
'drop table',
|
|
87
|
+
'shell.rm',
|
|
88
|
+
'rm -rf',
|
|
89
|
+
'deploy.*',
|
|
90
|
+
'git push --force',
|
|
91
|
+
'git push -f',
|
|
92
|
+
'payment.*',
|
|
93
|
+
'transfer.*',
|
|
94
|
+
'refund.*',
|
|
95
|
+
'email.send_bulk',
|
|
96
|
+
];
|
|
97
|
+
|
|
98
|
+
const circuitWindows = new Map<string, number[]>();
|
|
99
|
+
const workflowStates = new Map<string, WorkflowState>();
|
|
100
|
+
const dailyWorkflowSpend = new Map<string, number>();
|
|
101
|
+
|
|
102
|
+
export function evaluateGovernanceBeforeDispatch(
|
|
103
|
+
policy: SpendPolicy,
|
|
104
|
+
call: CallContext,
|
|
105
|
+
config: { circuitBreaker?: CircuitBreakerConfig; workflowEnvelope?: WorkflowEnvelopeConfig },
|
|
106
|
+
nowMs = Date.now(),
|
|
107
|
+
): GovernanceBlock | null {
|
|
108
|
+
const circuit = evaluateCircuitBreaker(call, config.circuitBreaker, nowMs);
|
|
109
|
+
if (circuit) return circuit;
|
|
110
|
+
return evaluateWorkflowEnvelope(policy, call, config.workflowEnvelope, nowMs);
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export async function evaluateToolGate(
|
|
114
|
+
policy: SpendPolicy,
|
|
115
|
+
input: ToolGateInput,
|
|
116
|
+
config: ToolGateConfig | undefined,
|
|
117
|
+
): Promise<ToolGateResult> {
|
|
118
|
+
const toolName = String(input.toolName || '').trim();
|
|
119
|
+
if (!toolName) throw new Error('toolName is required');
|
|
120
|
+
const cleanArgs = sanitizeMetadata(input.toolArgs ?? {});
|
|
121
|
+
const enabled = config?.enabled === true;
|
|
122
|
+
const patterns = (config?.destructivePatterns ?? config?.destructive_patterns ?? DEFAULT_DESTRUCTIVE_PATTERNS).filter(Boolean);
|
|
123
|
+
const matchedPattern = enabled ? matchDestructivePattern(toolName, cleanArgs, patterns) : null;
|
|
124
|
+
const mode = config?.mode === 'model' ? 'model' : 'human';
|
|
125
|
+
let approval: ToolGateApproval = { approved: true, approver: 'agentguard:not-required', verdict: 'not_destructive' };
|
|
126
|
+
if (matchedPattern) {
|
|
127
|
+
const callback = mode === 'model' ? config?.modelReviewer : config?.humanApprover;
|
|
128
|
+
approval = callback
|
|
129
|
+
? await callback({ ...input, toolArgs: cleanArgs })
|
|
130
|
+
: { approved: false, approver: mode === 'model' ? config?.escalationModel ?? 'model-reviewer' : 'human', verdict: 'approval_required' };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const approved = matchedPattern ? approval.approved === true : true;
|
|
134
|
+
const receipt = {
|
|
135
|
+
type: 'destructive_tool_gate',
|
|
136
|
+
toolName,
|
|
137
|
+
toolArgs: cleanArgs,
|
|
138
|
+
workflowId: input.workflowId ?? null,
|
|
139
|
+
matchedPattern,
|
|
140
|
+
mode,
|
|
141
|
+
approved,
|
|
142
|
+
approver: approval.approver ?? null,
|
|
143
|
+
reviewerModel: mode === 'model' ? config?.escalationModel ?? config?.escalation_model ?? null : null,
|
|
144
|
+
reviewerEffort: mode === 'model' ? config?.escalationEffort ?? config?.escalation_effort ?? 'high' : null,
|
|
145
|
+
verdict: approval.verdict ?? null,
|
|
146
|
+
};
|
|
147
|
+
const decision = buildGovernanceDecision({
|
|
148
|
+
policy,
|
|
149
|
+
call: {
|
|
150
|
+
provider: 'unknown',
|
|
151
|
+
model: 'tool-call',
|
|
152
|
+
inputTokens: 0,
|
|
153
|
+
outputTokens: 0,
|
|
154
|
+
scope: input.scope ?? { tenantId: 'local' },
|
|
155
|
+
workflowId: input.workflowId,
|
|
156
|
+
toolName,
|
|
157
|
+
toolArgs: cleanArgs,
|
|
158
|
+
},
|
|
159
|
+
action: approved ? 'allow' : 'block',
|
|
160
|
+
projectedCents: 0,
|
|
161
|
+
reason: approved
|
|
162
|
+
? matchedPattern
|
|
163
|
+
? `approved: destructive tool matched ${matchedPattern}`
|
|
164
|
+
: 'approved: tool call did not match destructive patterns'
|
|
165
|
+
: `blocked: destructive tool matched ${matchedPattern}`,
|
|
166
|
+
receipt,
|
|
167
|
+
});
|
|
168
|
+
return { approved, matchedPattern, mode, decision, editedArgs: approval.editedArgs };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function buildGovernanceDecision(args: {
|
|
172
|
+
policy: SpendPolicy;
|
|
173
|
+
call: CallContext;
|
|
174
|
+
action: 'allow' | 'block';
|
|
175
|
+
projectedCents: number;
|
|
176
|
+
reason: string;
|
|
177
|
+
receipt: Record<string, unknown>;
|
|
178
|
+
}): SpendDecision {
|
|
179
|
+
return {
|
|
180
|
+
decisionId: randomUUID(),
|
|
181
|
+
timestamp: new Date().toISOString(),
|
|
182
|
+
action: args.action,
|
|
183
|
+
triggeredCap: null,
|
|
184
|
+
triggeredScopeKey: null,
|
|
185
|
+
projectedCents: args.projectedCents,
|
|
186
|
+
windowSpendBefore: 0,
|
|
187
|
+
windowSpendAfter: args.action === 'allow' ? args.projectedCents : 0,
|
|
188
|
+
provider: args.call.provider,
|
|
189
|
+
modelRequested: args.call.model,
|
|
190
|
+
modelResolved: args.call.model,
|
|
191
|
+
policyId: args.policy.id,
|
|
192
|
+
policyVersion: args.policy.version,
|
|
193
|
+
enforcementMode: args.policy.mode,
|
|
194
|
+
reasons: [args.reason],
|
|
195
|
+
entryType: 'governance',
|
|
196
|
+
governanceReceipt: sanitizeMetadata(args.receipt),
|
|
197
|
+
};
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export function fingerprintCall(call: CallContext): string {
|
|
201
|
+
if (call.callFingerprint) return sha256(String(call.callFingerprint));
|
|
202
|
+
const payload = {
|
|
203
|
+
provider: call.provider,
|
|
204
|
+
model: call.model,
|
|
205
|
+
label: call.label ?? null,
|
|
206
|
+
capabilityClaim: call.capabilityClaim ?? null,
|
|
207
|
+
workflowId: call.workflowId ?? null,
|
|
208
|
+
toolName: call.toolName ?? null,
|
|
209
|
+
toolArgs: sanitizeMetadata(call.toolArgs ?? null),
|
|
210
|
+
requestShape: sanitizeMetadata(call.requestShape ?? null),
|
|
211
|
+
};
|
|
212
|
+
return sha256(canonicalJson(payload));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export function sanitizeMetadata<T>(value: T, path = 'metadata'): T {
|
|
216
|
+
if (value == null) return value;
|
|
217
|
+
if (Array.isArray(value)) return value.map((child, index) => sanitizeMetadata(child, `${path}[${index}]`)) as T;
|
|
218
|
+
if (typeof value !== 'object') return value;
|
|
219
|
+
const out: Record<string, unknown> = {};
|
|
220
|
+
for (const [key, child] of Object.entries(value as Record<string, unknown>)) {
|
|
221
|
+
if (/^(prompt|completion|content|messages|input|output|text|raw)$/i.test(key)) {
|
|
222
|
+
throw new Error('Governance metadata cannot include data-plane field: ' + path + '.' + key);
|
|
223
|
+
}
|
|
224
|
+
out[key] = sanitizeMetadata(child, path + '.' + key);
|
|
225
|
+
}
|
|
226
|
+
return out as T;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
function evaluateCircuitBreaker(call: CallContext, config: CircuitBreakerConfig | undefined, nowMs: number): GovernanceBlock | null {
|
|
230
|
+
if (config?.enabled !== true) return null;
|
|
231
|
+
const threshold = positiveInt(config.repeatThreshold ?? config.repeat_threshold, 3);
|
|
232
|
+
const windowSeconds = positiveInt(config.windowSeconds ?? config.window_seconds, 300);
|
|
233
|
+
const planChurnMax = positiveInt(config.planChurnMax ?? config.plan_churn_max, 2);
|
|
234
|
+
const stepCap = positiveInt(config.stepCap ?? config.step_cap, 50);
|
|
235
|
+
const projectedCents = computeCallCents(call.model, call.inputTokens, call.outputTokens) ?? 0;
|
|
236
|
+
|
|
237
|
+
if (typeof call.reasoningStep === 'number' && call.reasoningStep >= stepCap) {
|
|
238
|
+
return {
|
|
239
|
+
kind: 'circuit_breaker',
|
|
240
|
+
projectedCents,
|
|
241
|
+
reason: `blocked: reasoning step cap ${stepCap} reached`,
|
|
242
|
+
receipt: { type: 'circuit_breaker', reason: 'step_cap', stepCap, reasoningStep: call.reasoningStep },
|
|
243
|
+
};
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (typeof call.planRevision === 'number' && call.planRevision >= planChurnMax && call.stateProgress !== true) {
|
|
247
|
+
return {
|
|
248
|
+
kind: 'circuit_breaker',
|
|
249
|
+
projectedCents,
|
|
250
|
+
reason: `blocked: plan churn exceeded ${planChurnMax} revisions without state progress`,
|
|
251
|
+
receipt: { type: 'circuit_breaker', reason: 'plan_churn', planChurnMax, planRevision: call.planRevision },
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const fingerprint = fingerprintCall(call);
|
|
256
|
+
const key = `${call.scope.tenantId}:${call.workflowId ?? call.scope.taskId ?? 'global'}:${fingerprint}`;
|
|
257
|
+
const windowMs = windowSeconds * 1000;
|
|
258
|
+
const recent = (circuitWindows.get(key) ?? []).filter((at) => nowMs - at <= windowMs);
|
|
259
|
+
const nextCount = recent.length + 1;
|
|
260
|
+
if (nextCount >= threshold) {
|
|
261
|
+
circuitWindows.set(key, [...recent, nowMs]);
|
|
262
|
+
return {
|
|
263
|
+
kind: 'circuit_breaker',
|
|
264
|
+
projectedCents,
|
|
265
|
+
reason: `blocked: identical call repeated ${threshold}x in ${Math.round((recent.length ? nowMs - recent[0]! : 0) / 1000)}s (loop circuit breaker)`,
|
|
266
|
+
receipt: { type: 'circuit_breaker', fingerprint, count: nextCount, repeatThreshold: threshold, windowSeconds },
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
circuitWindows.set(key, [...recent, nowMs]);
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
function evaluateWorkflowEnvelope(policy: SpendPolicy, call: CallContext, config: WorkflowEnvelopeConfig | undefined, nowMs: number): GovernanceBlock | null {
|
|
274
|
+
if (!config) return null;
|
|
275
|
+
const workflowId = call.workflowId ?? call.scope.taskId;
|
|
276
|
+
if (!workflowId) return null;
|
|
277
|
+
const projectedCents = computeCallCents(call.model, call.inputTokens, call.outputTokens) ?? 0;
|
|
278
|
+
const state = workflowStates.get(workflowId) ?? {
|
|
279
|
+
workflowId,
|
|
280
|
+
startedAtMs: nowMs,
|
|
281
|
+
spendCents: 0,
|
|
282
|
+
iterations: 0,
|
|
283
|
+
tokens: 0,
|
|
284
|
+
};
|
|
285
|
+
if (state.haltedReason) {
|
|
286
|
+
return envelopeBlock(policy, call, projectedCents, workflowId, state, state.haltedReason);
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
const maxRunCents = usdToCents(config.maxUsdPerRun ?? config.max_usd_per_run);
|
|
290
|
+
const maxIterations = positiveOptional(config.maxIterations ?? config.max_iterations);
|
|
291
|
+
const maxTokens = positiveOptional(config.maxTokens ?? config.max_tokens);
|
|
292
|
+
const maxWallClockSeconds = positiveOptional(config.maxWallClockSeconds ?? config.max_wall_clock_seconds);
|
|
293
|
+
const dailyCents = usdToCents(config.dailyUsdCap ?? config.daily_usd_cap);
|
|
294
|
+
const tokensAfter = state.tokens + call.inputTokens + call.outputTokens;
|
|
295
|
+
const spendAfter = state.spendCents + projectedCents;
|
|
296
|
+
const iterationAfter = state.iterations + 1;
|
|
297
|
+
const elapsedSeconds = Math.floor((nowMs - state.startedAtMs) / 1000);
|
|
298
|
+
const dailyKey = `${workflowId}:${new Date(nowMs).toISOString().slice(0, 10)}`;
|
|
299
|
+
const dailyAfter = (dailyWorkflowSpend.get(dailyKey) ?? 0) + projectedCents;
|
|
300
|
+
let reason: string | null = null;
|
|
301
|
+
if (maxRunCents !== null && spendAfter > maxRunCents) {
|
|
302
|
+
reason = `blocked: workflow envelope exceeded at ${formatUsd(state.spendCents)}/${formatUsd(maxRunCents)} (run ${workflowId})`;
|
|
303
|
+
} else if (dailyCents !== null && dailyAfter > dailyCents) {
|
|
304
|
+
reason = `blocked: workflow daily envelope exceeded at ${formatUsd(dailyAfter)}/${formatUsd(dailyCents)} (run ${workflowId})`;
|
|
305
|
+
} else if (maxIterations !== null && iterationAfter > maxIterations) {
|
|
306
|
+
reason = `blocked: workflow iteration cap exceeded at ${state.iterations}/${maxIterations} (run ${workflowId})`;
|
|
307
|
+
} else if (maxTokens !== null && tokensAfter > maxTokens) {
|
|
308
|
+
reason = `blocked: workflow token envelope exceeded at ${state.tokens}/${maxTokens} (run ${workflowId})`;
|
|
309
|
+
} else if (maxWallClockSeconds !== null && elapsedSeconds > maxWallClockSeconds) {
|
|
310
|
+
reason = `blocked: workflow wall-clock envelope exceeded at ${elapsedSeconds}s/${maxWallClockSeconds}s (run ${workflowId})`;
|
|
311
|
+
}
|
|
312
|
+
if (reason) {
|
|
313
|
+
state.haltedReason = reason;
|
|
314
|
+
workflowStates.set(workflowId, state);
|
|
315
|
+
return envelopeBlock(policy, call, projectedCents, workflowId, state, reason);
|
|
316
|
+
}
|
|
317
|
+
state.spendCents = spendAfter;
|
|
318
|
+
state.iterations = iterationAfter;
|
|
319
|
+
state.tokens = tokensAfter;
|
|
320
|
+
workflowStates.set(workflowId, state);
|
|
321
|
+
dailyWorkflowSpend.set(dailyKey, dailyAfter);
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function envelopeBlock(policy: SpendPolicy, _call: CallContext, projectedCents: number, workflowId: string, state: WorkflowState, reason: string): GovernanceBlock {
|
|
326
|
+
return {
|
|
327
|
+
kind: 'workflow_envelope',
|
|
328
|
+
projectedCents,
|
|
329
|
+
reason,
|
|
330
|
+
receipt: {
|
|
331
|
+
type: 'workflow_envelope_exceeded',
|
|
332
|
+
workflowId,
|
|
333
|
+
spendCents: state.spendCents,
|
|
334
|
+
iterations: state.iterations,
|
|
335
|
+
tokens: state.tokens,
|
|
336
|
+
policyId: policy.id,
|
|
337
|
+
reason,
|
|
338
|
+
},
|
|
339
|
+
};
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function matchDestructivePattern(toolName: string, toolArgs: Record<string, unknown>, patterns: string[]): string | null {
|
|
343
|
+
const haystack = `${toolName} ${canonicalJson(toolArgs)}`.toLowerCase();
|
|
344
|
+
for (const pattern of patterns) {
|
|
345
|
+
const clean = String(pattern).trim().toLowerCase();
|
|
346
|
+
if (!clean) continue;
|
|
347
|
+
const regex = new RegExp('^' + escapeRegex(clean).replace(/\\\*/g, '.*') + '$');
|
|
348
|
+
if (regex.test(toolName.toLowerCase()) || haystack.includes(clean.replace(/\*/g, ''))) return pattern;
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
function sha256(value: string): string {
|
|
354
|
+
return createHash('sha256').update(value, 'utf8').digest('hex');
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function escapeRegex(value: string): string {
|
|
358
|
+
return value.replace(/[.+?^${}()|[\]\\]/g, '\\$&');
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
function positiveInt(value: unknown, fallback: number): number {
|
|
362
|
+
return Number.isSafeInteger(value) && (value as number) > 0 ? value as number : fallback;
|
|
363
|
+
}
|
|
364
|
+
|
|
365
|
+
function positiveOptional(value: unknown): number | null {
|
|
366
|
+
return Number.isSafeInteger(value) && (value as number) > 0 ? value as number : null;
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
function usdToCents(value: unknown): number | null {
|
|
370
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.round(value * 100) : null;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
function formatUsd(cents: number): string {
|
|
374
|
+
return '$' + (cents / 100).toFixed(2);
|
|
375
|
+
}
|
|
376
|
+
|