@brninpay/core 0.1.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.
Files changed (98) hide show
  1. package/.turbo/turbo-build.log +4 -0
  2. package/LICENSE +203 -0
  3. package/README.md +7 -0
  4. package/dist/decision-resolver.d.ts +5 -0
  5. package/dist/decision-resolver.d.ts.map +1 -0
  6. package/dist/decision-resolver.js +20 -0
  7. package/dist/decision-resolver.js.map +1 -0
  8. package/dist/errors.d.ts +3 -0
  9. package/dist/errors.d.ts.map +1 -0
  10. package/dist/errors.js +2 -0
  11. package/dist/errors.js.map +1 -0
  12. package/dist/index.d.ts +10 -0
  13. package/dist/index.d.ts.map +1 -0
  14. package/dist/index.js +8 -0
  15. package/dist/index.js.map +1 -0
  16. package/dist/policies/allowlist.d.ts +3 -0
  17. package/dist/policies/allowlist.d.ts.map +1 -0
  18. package/dist/policies/allowlist.js +21 -0
  19. package/dist/policies/allowlist.js.map +1 -0
  20. package/dist/policies/budget-check.d.ts +3 -0
  21. package/dist/policies/budget-check.d.ts.map +1 -0
  22. package/dist/policies/budget-check.js +22 -0
  23. package/dist/policies/budget-check.js.map +1 -0
  24. package/dist/policies/index.d.ts +5 -0
  25. package/dist/policies/index.d.ts.map +1 -0
  26. package/dist/policies/index.js +5 -0
  27. package/dist/policies/index.js.map +1 -0
  28. package/dist/policies/max-per-call.d.ts +3 -0
  29. package/dist/policies/max-per-call.d.ts.map +1 -0
  30. package/dist/policies/max-per-call.js +20 -0
  31. package/dist/policies/max-per-call.js.map +1 -0
  32. package/dist/policies/rate-limit.d.ts +3 -0
  33. package/dist/policies/rate-limit.d.ts.map +1 -0
  34. package/dist/policies/rate-limit.js +33 -0
  35. package/dist/policies/rate-limit.js.map +1 -0
  36. package/dist/policy-engine.d.ts +8 -0
  37. package/dist/policy-engine.d.ts.map +1 -0
  38. package/dist/policy-engine.js +39 -0
  39. package/dist/policy-engine.js.map +1 -0
  40. package/dist/types/errors.d.ts +80 -0
  41. package/dist/types/errors.d.ts.map +1 -0
  42. package/dist/types/errors.js +150 -0
  43. package/dist/types/errors.js.map +1 -0
  44. package/dist/types/payment.d.ts +55 -0
  45. package/dist/types/payment.d.ts.map +1 -0
  46. package/dist/types/payment.js +188 -0
  47. package/dist/types/payment.js.map +1 -0
  48. package/dist/types/policy.d.ts +58 -0
  49. package/dist/types/policy.d.ts.map +1 -0
  50. package/dist/types/policy.js +194 -0
  51. package/dist/types/policy.js.map +1 -0
  52. package/dist/types/wallet.d.ts +66 -0
  53. package/dist/types/wallet.d.ts.map +1 -0
  54. package/dist/types/wallet.js +13 -0
  55. package/dist/types/wallet.js.map +1 -0
  56. package/dist/types.d.ts +117 -0
  57. package/dist/types.d.ts.map +1 -0
  58. package/dist/types.js +4 -0
  59. package/dist/types.js.map +1 -0
  60. package/dist/utils/crypto.d.ts +25 -0
  61. package/dist/utils/crypto.d.ts.map +1 -0
  62. package/dist/utils/crypto.js +119 -0
  63. package/dist/utils/crypto.js.map +1 -0
  64. package/dist/validation/schemas.d.ts +598 -0
  65. package/dist/validation/schemas.d.ts.map +1 -0
  66. package/dist/validation/schemas.js +178 -0
  67. package/dist/validation/schemas.js.map +1 -0
  68. package/package.json +30 -0
  69. package/src/decision-resolver.ts +16 -0
  70. package/src/errors.ts +20 -0
  71. package/src/index.ts +139 -0
  72. package/src/policies/allowlist.ts +26 -0
  73. package/src/policies/budget-check.ts +26 -0
  74. package/src/policies/index.ts +4 -0
  75. package/src/policies/max-per-call.ts +25 -0
  76. package/src/policies/rate-limit.ts +40 -0
  77. package/src/policy-engine.ts +65 -0
  78. package/src/types/errors.ts +215 -0
  79. package/src/types/payment.ts +297 -0
  80. package/src/types/policy.ts +289 -0
  81. package/src/types/wallet.ts +84 -0
  82. package/src/types.ts +193 -0
  83. package/src/utils/crypto.ts +182 -0
  84. package/src/validation/schemas.ts +208 -0
  85. package/test/architecture/architecture-invariants.test.ts +95 -0
  86. package/test/authorization-pipeline.test.ts +117 -0
  87. package/test/crypto.test.ts +56 -0
  88. package/test/decision-resolver.test.ts +52 -0
  89. package/test/errors.test.ts +97 -0
  90. package/test/payment-types.test.ts +49 -0
  91. package/test/policies.test.ts +162 -0
  92. package/test/policy-engine.test.ts +96 -0
  93. package/test/policy-types.test.ts +93 -0
  94. package/test/property/auth-invariants.test.ts +110 -0
  95. package/test/property/policy-invariants.test.ts +241 -0
  96. package/test/schemas.test.ts +58 -0
  97. package/tsconfig.json +9 -0
  98. package/vitest.config.ts +8 -0
@@ -0,0 +1,215 @@
1
+ export enum ErrorCode {
2
+ VALIDATION_ERROR = 'validation_error',
3
+ AUTHORIZATION_ERROR = 'authorization_error',
4
+ BUDGET_EXHAUSTED = 'budget_exhausted',
5
+ ALLOWLIST_DENIED = 'allowlist_denied',
6
+ RATE_LIMIT_EXCEEDED = 'rate_limit_exceeded',
7
+ AUTHORIZATION_EXPIRED = 'authorization_expired',
8
+ EXECUTION_ERROR = 'execution_error',
9
+ NETWORK_ERROR = 'network_error',
10
+ GAS_ESTIMATION_ERROR = 'gas_estimation_error',
11
+ BROADCAST_ERROR = 'broadcast_error',
12
+ REORG_DETECTED = 'reorg_detected',
13
+ HMAC_VERIFICATION_FAILED = 'hmac_verification_failed',
14
+ }
15
+
16
+ export interface ErrorCodeMessage {
17
+ code: string
18
+ message: string
19
+ }
20
+
21
+ export interface SerializedError {
22
+ name: string
23
+ code: string
24
+ message: string
25
+ statusCode: number
26
+ details?: unknown
27
+ }
28
+
29
+ export class RuntimeeError extends Error {
30
+ public readonly code: string
31
+ public readonly statusCode: number
32
+ public readonly details?: unknown
33
+
34
+ constructor(
35
+ code: string,
36
+ message: string,
37
+ statusCode = 500,
38
+ details?: unknown,
39
+ options?: ErrorOptions
40
+ ) {
41
+ super(message, options)
42
+ this.name = new.target.name
43
+ this.code = code
44
+ this.statusCode = statusCode
45
+ this.details = details
46
+ Object.setPrototypeOf(this, new.target.prototype)
47
+ }
48
+
49
+ toJSON(): SerializedError {
50
+ return serializeError(this)
51
+ }
52
+ }
53
+
54
+ export class ValidationError extends RuntimeeError {
55
+ constructor(message: string, details?: unknown, options?: ErrorOptions) {
56
+ super(ErrorCode.VALIDATION_ERROR, message, 400, details, options)
57
+ }
58
+ }
59
+
60
+ export class AuthorizationError extends RuntimeeError {
61
+ constructor(code: string, message: string, details?: unknown, options?: ErrorOptions) {
62
+ super(code, message, 403, details, options)
63
+ }
64
+ }
65
+
66
+ export class BudgetExhaustedError extends AuthorizationError {
67
+ public readonly policyId: string
68
+ public readonly budgetUsed: bigint
69
+ public readonly budgetLimit: bigint
70
+
71
+ constructor(policyId: string, used: bigint, limit: bigint, options?: ErrorOptions) {
72
+ super(
73
+ ErrorCode.BUDGET_EXHAUSTED,
74
+ `Budget exhausted: ${used} used of ${limit} limit`,
75
+ {
76
+ policyId,
77
+ budgetUsed: used.toString(),
78
+ budgetLimit: limit.toString(),
79
+ },
80
+ options
81
+ )
82
+ this.policyId = policyId
83
+ this.budgetUsed = used
84
+ this.budgetLimit = limit
85
+ }
86
+ }
87
+
88
+ export class AllowlistDeniedError extends AuthorizationError {
89
+ public readonly policyId: string
90
+ public readonly target: string
91
+
92
+ constructor(policyId: string, target: string, options?: ErrorOptions) {
93
+ super(
94
+ ErrorCode.ALLOWLIST_DENIED,
95
+ `Target "${target}" not in allowlist`,
96
+ {
97
+ policyId,
98
+ target,
99
+ },
100
+ options
101
+ )
102
+ this.policyId = policyId
103
+ this.target = target
104
+ }
105
+ }
106
+
107
+ export class RateLimitExceededError extends AuthorizationError {
108
+ public readonly policyId: string
109
+ public readonly windowMs: number
110
+ public readonly maxCalls: number
111
+
112
+ constructor(policyId: string, windowMs: number, maxCalls: number, options?: ErrorOptions) {
113
+ super(
114
+ ErrorCode.RATE_LIMIT_EXCEEDED,
115
+ `Rate limit exceeded: max ${maxCalls} calls per ${windowMs}ms window`,
116
+ {
117
+ policyId,
118
+ windowMs,
119
+ maxCalls,
120
+ },
121
+ options
122
+ )
123
+ this.policyId = policyId
124
+ this.windowMs = windowMs
125
+ this.maxCalls = maxCalls
126
+ }
127
+ }
128
+
129
+ export class AuthorizationExpiredError extends AuthorizationError {
130
+ constructor(options?: ErrorOptions) {
131
+ super(
132
+ ErrorCode.AUTHORIZATION_EXPIRED,
133
+ 'Authorization preview has expired. Re-simulate.',
134
+ undefined,
135
+ options
136
+ )
137
+ }
138
+ }
139
+
140
+ export class ExecutionError extends RuntimeeError {
141
+ constructor(code: string, message: string, details?: unknown, options?: ErrorOptions) {
142
+ super(code, message, 502, details, options)
143
+ }
144
+ }
145
+
146
+ export class NetworkError extends ExecutionError {
147
+ constructor(message: string, details?: unknown, options?: ErrorOptions) {
148
+ super(ErrorCode.NETWORK_ERROR, message, details, options)
149
+ }
150
+ }
151
+
152
+ export class GasEstimationError extends ExecutionError {
153
+ constructor(message: string, details?: unknown, options?: ErrorOptions) {
154
+ super(ErrorCode.GAS_ESTIMATION_ERROR, message, details, options)
155
+ }
156
+ }
157
+
158
+ export class BroadcastError extends ExecutionError {
159
+ constructor(message: string, details?: unknown, options?: ErrorOptions) {
160
+ super(ErrorCode.BROADCAST_ERROR, message, details, options)
161
+ }
162
+ }
163
+
164
+ export class ReorgDetectedError extends ExecutionError {
165
+ public readonly depth: number
166
+
167
+ constructor(depth: number, options?: ErrorOptions) {
168
+ super(
169
+ ErrorCode.REORG_DETECTED,
170
+ `Chain reorg detected at depth ${depth}`,
171
+ { depth },
172
+ options
173
+ )
174
+ this.depth = depth
175
+ }
176
+ }
177
+
178
+ export class HmacVerificationError extends RuntimeeError {
179
+ constructor(message: string, details?: unknown, options?: ErrorOptions) {
180
+ super(ErrorCode.HMAC_VERIFICATION_FAILED, message, 401, details, options)
181
+ }
182
+ }
183
+
184
+ export function isRuntimeeError(error: unknown): error is RuntimeeError {
185
+ return error instanceof RuntimeeError
186
+ }
187
+
188
+ export function serializeError(error: unknown): SerializedError {
189
+ if (error instanceof RuntimeeError) {
190
+ return {
191
+ name: error.name,
192
+ code: error.code,
193
+ message: error.message,
194
+ statusCode: error.statusCode,
195
+ details: error.details,
196
+ }
197
+ }
198
+
199
+ if (error instanceof Error) {
200
+ return {
201
+ name: error.name,
202
+ code: ErrorCode.EXECUTION_ERROR,
203
+ message: error.message,
204
+ statusCode: 500,
205
+ }
206
+ }
207
+
208
+ return {
209
+ name: 'UnknownError',
210
+ code: ErrorCode.EXECUTION_ERROR,
211
+ message: 'Unknown error',
212
+ statusCode: 500,
213
+ details: error,
214
+ }
215
+ }
@@ -0,0 +1,297 @@
1
+ import { ValidationError } from './errors.js'
2
+ import type { Address } from './wallet.js'
3
+
4
+ export const PAYMENT_INTENT_STATUSES = [
5
+ 'draft',
6
+ 'pending_policy',
7
+ 'pending_approval',
8
+ 'approved',
9
+ 'rejected',
10
+ 'pending_submission',
11
+ 'submitted',
12
+ 'confirming',
13
+ 'succeeded',
14
+ 'failed',
15
+ 'cancelled',
16
+ 'expired',
17
+ ] as const
18
+
19
+ export type PaymentIntentStatus = (typeof PAYMENT_INTENT_STATUSES)[number]
20
+
21
+ export interface PaymentAmount {
22
+ currency: 'USDC'
23
+ value: bigint
24
+ decimals: number
25
+ }
26
+
27
+ export interface PaymentRecipient {
28
+ address: Address
29
+ chainId: number
30
+ ensName?: string | null
31
+ }
32
+
33
+ export interface PaymentIntentFailure {
34
+ code: string
35
+ message: string
36
+ retryable: boolean
37
+ detail?: Record<string, unknown>
38
+ }
39
+
40
+ export type PaymentMetadataValue =
41
+ | string
42
+ | number
43
+ | boolean
44
+ | null
45
+ | PaymentMetadataValue[]
46
+ | { [key: string]: PaymentMetadataValue }
47
+
48
+ export interface PaymentIntent {
49
+ id: string
50
+ workspaceId: string
51
+ actorId: string
52
+ status: PaymentIntentStatus
53
+ amount: PaymentAmount
54
+ recipient: PaymentRecipient
55
+ createdAt: string
56
+ updatedAt: string
57
+ expiresAt?: string | null
58
+ idempotencyKey?: string | null
59
+ description?: string | null
60
+ reference?: string | null
61
+ transactionHash?: `0x${string}` | null
62
+ failure?: PaymentIntentFailure | null
63
+ metadata?: Record<string, PaymentMetadataValue>
64
+ }
65
+
66
+ export interface SerializedBigInt {
67
+ __type: 'bigint'
68
+ value: string
69
+ }
70
+
71
+ export const PAYMENT_INTENT_TERMINAL_STATUSES = new Set<PaymentIntentStatus>([
72
+ 'rejected',
73
+ 'succeeded',
74
+ 'failed',
75
+ 'cancelled',
76
+ 'expired',
77
+ ])
78
+
79
+ export const PAYMENT_INTENT_TRANSITIONS: Record<
80
+ PaymentIntentStatus,
81
+ readonly PaymentIntentStatus[]
82
+ > = {
83
+ draft: ['pending_policy', 'cancelled', 'expired'],
84
+ pending_policy: ['pending_approval', 'approved', 'rejected', 'cancelled', 'expired'],
85
+ pending_approval: ['approved', 'rejected', 'cancelled', 'expired'],
86
+ approved: ['pending_submission', 'submitted', 'cancelled', 'expired'],
87
+ rejected: [],
88
+ pending_submission: ['submitted', 'failed', 'cancelled', 'expired'],
89
+ submitted: ['confirming', 'succeeded', 'failed'],
90
+ confirming: ['succeeded', 'failed'],
91
+ succeeded: [],
92
+ failed: [],
93
+ cancelled: [],
94
+ expired: [],
95
+ }
96
+
97
+ function isObject(value: unknown): value is Record<string, unknown> {
98
+ return typeof value === 'object' && value !== null
99
+ }
100
+
101
+ function assertIsoTimestamp(value: string, fieldName: string): void {
102
+ if (Number.isNaN(Date.parse(value))) {
103
+ throw new ValidationError(`${fieldName} must be a valid ISO-8601 timestamp`, {
104
+ field: fieldName,
105
+ value,
106
+ })
107
+ }
108
+ }
109
+
110
+ export function validatePaymentAmount(amount: PaymentAmount): void {
111
+ if (amount.currency !== 'USDC') {
112
+ throw new ValidationError('Payment amount currency must be USDC', {
113
+ field: 'amount.currency',
114
+ value: amount.currency,
115
+ })
116
+ }
117
+
118
+ if (amount.value <= 0n) {
119
+ throw new ValidationError('Payment amount must be greater than zero', {
120
+ field: 'amount.value',
121
+ value: amount.value.toString(),
122
+ })
123
+ }
124
+
125
+ if (!Number.isInteger(amount.decimals) || amount.decimals < 0 || amount.decimals > 18) {
126
+ throw new ValidationError('Payment amount decimals must be an integer between 0 and 18', {
127
+ field: 'amount.decimals',
128
+ value: amount.decimals,
129
+ })
130
+ }
131
+ }
132
+
133
+ export function isPaymentIntentTerminalStatus(status: PaymentIntentStatus): boolean {
134
+ return PAYMENT_INTENT_TERMINAL_STATUSES.has(status)
135
+ }
136
+
137
+ export function canTransitionPaymentIntentStatus(
138
+ currentStatus: PaymentIntentStatus,
139
+ nextStatus: PaymentIntentStatus
140
+ ): boolean {
141
+ return PAYMENT_INTENT_TRANSITIONS[currentStatus].includes(nextStatus)
142
+ }
143
+
144
+ export function assertValidPaymentIntentTransition(
145
+ currentStatus: PaymentIntentStatus,
146
+ nextStatus: PaymentIntentStatus
147
+ ): void {
148
+ if (!canTransitionPaymentIntentStatus(currentStatus, nextStatus)) {
149
+ throw new ValidationError(
150
+ `Invalid payment intent transition from ${currentStatus} to ${nextStatus}`,
151
+ {
152
+ currentStatus,
153
+ nextStatus,
154
+ }
155
+ )
156
+ }
157
+ }
158
+
159
+ export function assertPaymentIntent(value: unknown): asserts value is PaymentIntent {
160
+ if (!isObject(value)) {
161
+ throw new ValidationError('Payment intent payload must be an object')
162
+ }
163
+
164
+ if (typeof value.id !== 'string' || !value.id.trim()) {
165
+ throw new ValidationError('Payment intent id is required', { field: 'id' })
166
+ }
167
+
168
+ if (typeof value.workspaceId !== 'string' || !value.workspaceId.trim()) {
169
+ throw new ValidationError('Payment intent workspaceId is required', {
170
+ field: 'workspaceId',
171
+ })
172
+ }
173
+
174
+ if (typeof value.actorId !== 'string' || !value.actorId.trim()) {
175
+ throw new ValidationError('Payment intent actorId is required', {
176
+ field: 'actorId',
177
+ })
178
+ }
179
+
180
+ if (
181
+ typeof value.status !== 'string' ||
182
+ !PAYMENT_INTENT_STATUSES.includes(value.status as PaymentIntentStatus)
183
+ ) {
184
+ throw new ValidationError('Payment intent status is invalid', {
185
+ field: 'status',
186
+ value: value.status,
187
+ })
188
+ }
189
+
190
+ if (!isObject(value.amount)) {
191
+ throw new ValidationError('Payment intent amount is required', { field: 'amount' })
192
+ }
193
+
194
+ if (!isObject(value.recipient)) {
195
+ throw new ValidationError('Payment intent recipient is required', {
196
+ field: 'recipient',
197
+ })
198
+ }
199
+
200
+ if (typeof value.recipient.address !== 'string' || !value.recipient.address.startsWith('0x')) {
201
+ throw new ValidationError('Payment intent recipient address is invalid', {
202
+ field: 'recipient.address',
203
+ value: value.recipient.address,
204
+ })
205
+ }
206
+
207
+ if (
208
+ typeof value.recipient.chainId !== 'number' ||
209
+ !Number.isInteger(value.recipient.chainId) ||
210
+ value.recipient.chainId <= 0
211
+ ) {
212
+ throw new ValidationError('Payment intent recipient chainId is invalid', {
213
+ field: 'recipient.chainId',
214
+ value: value.recipient.chainId,
215
+ })
216
+ }
217
+
218
+ validatePaymentAmount(value.amount as unknown as PaymentAmount)
219
+
220
+ if (typeof value.createdAt !== 'string') {
221
+ throw new ValidationError('Payment intent createdAt is required', {
222
+ field: 'createdAt',
223
+ })
224
+ }
225
+
226
+ if (typeof value.updatedAt !== 'string') {
227
+ throw new ValidationError('Payment intent updatedAt is required', {
228
+ field: 'updatedAt',
229
+ })
230
+ }
231
+
232
+ assertIsoTimestamp(value.createdAt, 'createdAt')
233
+ assertIsoTimestamp(value.updatedAt, 'updatedAt')
234
+
235
+ if (value.expiresAt !== undefined && value.expiresAt !== null && typeof value.expiresAt !== 'string') {
236
+ throw new ValidationError('Payment intent expiresAt must be a timestamp string', {
237
+ field: 'expiresAt',
238
+ })
239
+ }
240
+
241
+ if (value.expiresAt) {
242
+ assertIsoTimestamp(value.expiresAt, 'expiresAt')
243
+ }
244
+ }
245
+
246
+ export function serializeBigInts(value: unknown): unknown {
247
+ if (typeof value === 'bigint') {
248
+ return {
249
+ __type: 'bigint',
250
+ value: value.toString(),
251
+ } satisfies SerializedBigInt
252
+ }
253
+
254
+ if (Array.isArray(value)) {
255
+ return value.map((entry) => serializeBigInts(entry))
256
+ }
257
+
258
+ if (isObject(value)) {
259
+ return Object.fromEntries(
260
+ Object.entries(value).map(([key, entry]) => [key, serializeBigInts(entry)])
261
+ )
262
+ }
263
+
264
+ return value
265
+ }
266
+
267
+ export function deserializeBigInts<T>(value: unknown): T {
268
+ if (Array.isArray(value)) {
269
+ return value.map((entry) => deserializeBigInts(entry)) as T
270
+ }
271
+
272
+ if (isObject(value)) {
273
+ if (value.__type === 'bigint' && typeof value.value === 'string') {
274
+ return BigInt(value.value) as T
275
+ }
276
+
277
+ return Object.fromEntries(
278
+ Object.entries(value).map(([key, entry]) => [key, deserializeBigInts(entry)])
279
+ ) as T
280
+ }
281
+
282
+ return value as T
283
+ }
284
+
285
+ export function serializePaymentIntent(paymentIntent: PaymentIntent): string {
286
+ assertPaymentIntent(paymentIntent)
287
+
288
+ // Preserve bigint fidelity over JSON transport without leaking decimal precision.
289
+ return JSON.stringify(serializeBigInts(paymentIntent))
290
+ }
291
+
292
+ export function deserializePaymentIntent(payload: string | unknown): PaymentIntent {
293
+ const candidate = typeof payload === 'string' ? JSON.parse(payload) : payload
294
+ const paymentIntent = deserializeBigInts<unknown>(candidate)
295
+ assertPaymentIntent(paymentIntent)
296
+ return paymentIntent
297
+ }