@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,182 @@
1
+ import { HmacVerificationError, ValidationError } from '../types/errors.js'
2
+
3
+ export interface HmacSignatureOptions {
4
+ secret: string | Uint8Array
5
+ message: string | Uint8Array
6
+ encoding?: 'hex' | 'base64'
7
+ }
8
+
9
+ export interface TimestampValidationOptions {
10
+ nowMs?: number
11
+ maxSkewMs?: number
12
+ }
13
+
14
+ export interface TimestampValidationResult {
15
+ valid: boolean
16
+ ageMs: number
17
+ }
18
+
19
+ const textEncoder = new TextEncoder()
20
+
21
+ function getCrypto(): Crypto {
22
+ if (!globalThis.crypto?.subtle) {
23
+ throw new Error('Web Crypto API is not available in the current runtime')
24
+ }
25
+
26
+ return globalThis.crypto
27
+ }
28
+
29
+ function toUint8Array(value: string | Uint8Array): Uint8Array {
30
+ return typeof value === 'string' ? textEncoder.encode(value) : value
31
+ }
32
+
33
+ function toBufferSource(value: string | Uint8Array): Uint8Array<ArrayBuffer> {
34
+ return Uint8Array.from(toUint8Array(value))
35
+ }
36
+
37
+ function bytesToHex(bytes: Uint8Array): string {
38
+ return Array.from(bytes, (byte) => byte.toString(16).padStart(2, '0')).join('')
39
+ }
40
+
41
+ function hexToBytes(value: string): Uint8Array {
42
+ if (!/^[0-9a-f]+$/i.test(value) || value.length % 2 !== 0) {
43
+ throw new ValidationError('Hex signatures must use an even-length hexadecimal string', {
44
+ value,
45
+ })
46
+ }
47
+
48
+ const bytes = new Uint8Array(value.length / 2)
49
+ for (let index = 0; index < value.length; index += 2) {
50
+ bytes[index / 2] = Number.parseInt(value.slice(index, index + 2), 16)
51
+ }
52
+
53
+ return bytes
54
+ }
55
+
56
+ function bytesToBase64(bytes: Uint8Array): string {
57
+ const binary = Array.from(bytes, (byte) => String.fromCharCode(byte)).join('')
58
+ return btoa(binary)
59
+ }
60
+
61
+ function base64ToBytes(value: string): Uint8Array {
62
+ const binary = atob(value)
63
+ return Uint8Array.from(binary, (char) => char.charCodeAt(0))
64
+ }
65
+
66
+ function decodeSignature(value: string, encoding: 'hex' | 'base64'): Uint8Array {
67
+ return encoding === 'hex' ? hexToBytes(value) : base64ToBytes(value)
68
+ }
69
+
70
+ function encodeBytes(value: Uint8Array, encoding: 'hex' | 'base64'): string {
71
+ return encoding === 'hex' ? bytesToHex(value) : bytesToBase64(value)
72
+ }
73
+
74
+ function timingSafeEqual(left: Uint8Array, right: Uint8Array): boolean {
75
+ if (left.length !== right.length) {
76
+ return false
77
+ }
78
+
79
+ let mismatch = 0
80
+ for (let index = 0; index < left.length; index += 1) {
81
+ mismatch |= left[index] ^ right[index]
82
+ }
83
+
84
+ return mismatch === 0
85
+ }
86
+
87
+ async function importHmacKey(secret: string | Uint8Array): Promise<CryptoKey> {
88
+ return getCrypto().subtle.importKey(
89
+ 'raw',
90
+ toBufferSource(secret),
91
+ { name: 'HMAC', hash: 'SHA-256' },
92
+ false,
93
+ ['sign']
94
+ )
95
+ }
96
+
97
+ export async function sha256Hex(value: string | Uint8Array): Promise<`0x${string}`> {
98
+ const digest = await getCrypto().subtle.digest('SHA-256', toBufferSource(value))
99
+ return `0x${bytesToHex(new Uint8Array(digest))}`
100
+ }
101
+
102
+ export async function signHmacSha256({
103
+ secret,
104
+ message,
105
+ encoding = 'hex',
106
+ }: HmacSignatureOptions): Promise<string> {
107
+ const key = await importHmacKey(secret)
108
+ const signature = await getCrypto().subtle.sign('HMAC', key, toBufferSource(message))
109
+ return encodeBytes(new Uint8Array(signature), encoding)
110
+ }
111
+
112
+ export async function verifyHmacSha256(
113
+ options: HmacSignatureOptions & { signature: string }
114
+ ): Promise<boolean> {
115
+ const expected = await signHmacSha256(options)
116
+
117
+ try {
118
+ const receivedBuffer = decodeSignature(options.signature, options.encoding ?? 'hex')
119
+ const expectedBuffer = decodeSignature(expected, options.encoding ?? 'hex')
120
+ return timingSafeEqual(receivedBuffer, expectedBuffer)
121
+ } catch {
122
+ return false
123
+ }
124
+ }
125
+
126
+ export async function assertValidHmac(
127
+ options: HmacSignatureOptions & { signature: string }
128
+ ): Promise<void> {
129
+ if (!(await verifyHmacSha256(options))) {
130
+ throw new HmacVerificationError('HMAC signature verification failed')
131
+ }
132
+ }
133
+
134
+ export function generateNonce(byteLength = 16): string {
135
+ if (!Number.isInteger(byteLength) || byteLength < 16) {
136
+ throw new ValidationError('Nonce byte length must be an integer greater than or equal to 16', {
137
+ byteLength,
138
+ })
139
+ }
140
+
141
+ const bytes = new Uint8Array(byteLength)
142
+ getCrypto().getRandomValues(bytes)
143
+ return bytesToHex(bytes)
144
+ }
145
+
146
+ export function validateTimestamp(
147
+ value: string | number | Date,
148
+ options: TimestampValidationOptions = {}
149
+ ): TimestampValidationResult {
150
+ const nowMs = options.nowMs ?? Date.now()
151
+ const maxSkewMs = options.maxSkewMs ?? 5 * 60 * 1000
152
+ const timestampMs = value instanceof Date ? value.getTime() : new Date(value).getTime()
153
+
154
+ if (Number.isNaN(timestampMs)) {
155
+ throw new ValidationError('Timestamp must be a valid date value', { value })
156
+ }
157
+
158
+ if (!Number.isInteger(maxSkewMs) || maxSkewMs < 0) {
159
+ throw new ValidationError('maxSkewMs must be a non-negative integer', {
160
+ maxSkewMs,
161
+ })
162
+ }
163
+
164
+ const ageMs = nowMs - timestampMs
165
+ return {
166
+ valid: Math.abs(ageMs) <= maxSkewMs,
167
+ ageMs,
168
+ }
169
+ }
170
+
171
+ export function assertValidTimestamp(
172
+ value: string | number | Date,
173
+ options: TimestampValidationOptions = {}
174
+ ): void {
175
+ const result = validateTimestamp(value, options)
176
+ if (!result.valid) {
177
+ throw new ValidationError('Timestamp is outside the allowed clock skew window', {
178
+ ageMs: result.ageMs,
179
+ maxSkewMs: options.maxSkewMs ?? 5 * 60 * 1000,
180
+ })
181
+ }
182
+ }
@@ -0,0 +1,208 @@
1
+ import { z } from 'zod'
2
+ import { PAYMENT_INTENT_STATUSES } from '../types/payment.js'
3
+
4
+ export const AddressSchema = z
5
+ .string({ required_error: 'Address is required' })
6
+ .regex(/^0x[a-fA-F0-9]{40}$/, 'Address must be a valid 20-byte hex string')
7
+
8
+ export const HexSchema = z
9
+ .string({ required_error: 'Hex value is required' })
10
+ .regex(/^0x(?:[a-fA-F0-9]{2})*$/, 'Value must be a valid even-length hex string')
11
+
12
+ export const TimestampSchema = z
13
+ .string({ required_error: 'Timestamp is required' })
14
+ .datetime({ offset: true, message: 'Timestamp must be a valid ISO-8601 string' })
15
+
16
+ export const NonNegativeBigIntSchema = z
17
+ .union([
18
+ z.bigint(),
19
+ z.number().int().nonnegative(),
20
+ z
21
+ .string()
22
+ .regex(/^\d+$/, 'Amount must contain only digits'),
23
+ ])
24
+ .transform((value) => BigInt(value))
25
+
26
+ export const PositiveBigIntSchema = NonNegativeBigIntSchema.refine(
27
+ (value) => value > 0n,
28
+ 'Amount must be greater than zero'
29
+ )
30
+
31
+ export const PaymentAmountSchema = z.object({
32
+ currency: z.literal('USDC'),
33
+ value: PositiveBigIntSchema,
34
+ decimals: z
35
+ .number({ required_error: 'Amount decimals are required' })
36
+ .int('Amount decimals must be an integer')
37
+ .min(0, 'Amount decimals cannot be negative')
38
+ .max(18, 'Amount decimals cannot exceed 18'),
39
+ })
40
+
41
+ export const PaymentRecipientSchema = z.object({
42
+ address: AddressSchema,
43
+ chainId: z
44
+ .number({ required_error: 'Recipient chainId is required' })
45
+ .int('Recipient chainId must be an integer')
46
+ .positive('Recipient chainId must be positive'),
47
+ ensName: z.string().trim().min(1).nullable().optional(),
48
+ })
49
+
50
+ export const PaymentIntentFailureSchema = z.object({
51
+ code: z.string().trim().min(1, 'Failure code is required'),
52
+ message: z.string().trim().min(1, 'Failure message is required'),
53
+ retryable: z.boolean(),
54
+ detail: z.record(z.unknown()).optional(),
55
+ })
56
+
57
+ export const PaymentIntentSchema = z.object({
58
+ id: z.string().trim().min(1, 'Payment intent id is required'),
59
+ workspaceId: z.string().trim().min(1, 'workspaceId is required'),
60
+ actorId: z.string().trim().min(1, 'actorId is required'),
61
+ status: z.enum(PAYMENT_INTENT_STATUSES),
62
+ amount: PaymentAmountSchema,
63
+ recipient: PaymentRecipientSchema,
64
+ createdAt: TimestampSchema,
65
+ updatedAt: TimestampSchema,
66
+ expiresAt: TimestampSchema.nullable().optional(),
67
+ idempotencyKey: z.string().trim().min(1).nullable().optional(),
68
+ description: z.string().trim().min(1).nullable().optional(),
69
+ reference: z.string().trim().min(1).nullable().optional(),
70
+ transactionHash: HexSchema.nullable().optional(),
71
+ failure: PaymentIntentFailureSchema.nullable().optional(),
72
+ metadata: z.record(z.unknown()).optional(),
73
+ })
74
+
75
+ export const BudgetPolicySchema = z.object({
76
+ id: z.string().trim().min(1, 'Policy id is required'),
77
+ name: z.string().trim().min(1, 'Policy name is required'),
78
+ enabled: z.boolean(),
79
+ kind: z.literal('budget'),
80
+ currency: z.literal('USDC'),
81
+ limit: PositiveBigIntSchema,
82
+ softLimit: PositiveBigIntSchema.optional(),
83
+ })
84
+
85
+ export const AllowlistPolicySchema = z.object({
86
+ id: z.string().trim().min(1, 'Policy id is required'),
87
+ name: z.string().trim().min(1, 'Policy name is required'),
88
+ enabled: z.boolean(),
89
+ kind: z.literal('allowlist'),
90
+ addresses: z.array(AddressSchema).min(1, 'Allowlist must contain at least one address'),
91
+ chains: z.array(z.number().int().positive()).optional(),
92
+ })
93
+
94
+ export const RateLimitPolicySchema = z.object({
95
+ id: z.string().trim().min(1, 'Policy id is required'),
96
+ name: z.string().trim().min(1, 'Policy name is required'),
97
+ enabled: z.boolean(),
98
+ kind: z.literal('rate_limit'),
99
+ maxRequests: z.number().int().positive('maxRequests must be positive'),
100
+ intervalMs: z.number().int().positive('intervalMs must be positive'),
101
+ burst: z.number().int().positive('burst must be positive').optional(),
102
+ scope: z.enum(['actor', 'workspace', 'ip']),
103
+ })
104
+
105
+ export const PolicyDefinitionSchema = z.discriminatedUnion('kind', [
106
+ BudgetPolicySchema,
107
+ AllowlistPolicySchema,
108
+ RateLimitPolicySchema,
109
+ ])
110
+
111
+ export const PolicyEvaluationContextSchema = z.object({
112
+ actorId: z.string().trim().min(1, 'actorId is required'),
113
+ workspaceId: z.string().trim().min(1, 'workspaceId is required'),
114
+ recipient: AddressSchema,
115
+ chainId: z.number().int().positive('chainId must be positive'),
116
+ amount: PositiveBigIntSchema,
117
+ currency: z.literal('USDC'),
118
+ timestampMs: z.number().int().nonnegative('timestampMs must be non-negative'),
119
+ spentAmount: NonNegativeBigIntSchema,
120
+ requestCount: z.number().int().nonnegative('requestCount must be non-negative'),
121
+ sourceIp: z.string().trim().min(1).optional(),
122
+ })
123
+
124
+ export const SmartWalletSchema = z.object({
125
+ address: AddressSchema,
126
+ ownerAddress: AddressSchema,
127
+ chainId: z.number().int().positive('chainId must be positive'),
128
+ provider: z.enum(['safe', 'kernel', 'circle']),
129
+ entryPointAddress: AddressSchema,
130
+ entryPointVersion: z.enum(['v0.6', 'v0.7']),
131
+ factoryAddress: AddressSchema.optional(),
132
+ deployed: z.boolean(),
133
+ })
134
+
135
+ export const UserOperationCallSchema = z.object({
136
+ to: AddressSchema,
137
+ value: NonNegativeBigIntSchema,
138
+ data: HexSchema,
139
+ })
140
+
141
+ export const GasEstimateSchema = z.object({
142
+ callGasLimit: NonNegativeBigIntSchema,
143
+ verificationGasLimit: NonNegativeBigIntSchema,
144
+ preVerificationGas: NonNegativeBigIntSchema,
145
+ maxFeePerGas: NonNegativeBigIntSchema,
146
+ maxPriorityFeePerGas: NonNegativeBigIntSchema,
147
+ paymasterVerificationGasLimit: NonNegativeBigIntSchema.optional(),
148
+ paymasterPostOpGasLimit: NonNegativeBigIntSchema.optional(),
149
+ })
150
+
151
+ export const UserOperationV06Schema = z.object({
152
+ version: z.literal('v0.6'),
153
+ sender: AddressSchema,
154
+ nonce: NonNegativeBigIntSchema,
155
+ initCode: HexSchema,
156
+ callData: HexSchema,
157
+ callGasLimit: NonNegativeBigIntSchema,
158
+ verificationGasLimit: NonNegativeBigIntSchema,
159
+ preVerificationGas: NonNegativeBigIntSchema,
160
+ maxFeePerGas: NonNegativeBigIntSchema,
161
+ maxPriorityFeePerGas: NonNegativeBigIntSchema,
162
+ paymasterAndData: HexSchema,
163
+ signature: HexSchema,
164
+ })
165
+
166
+ export const UserOperationV07Schema = z.object({
167
+ version: z.literal('v0.7'),
168
+ sender: AddressSchema,
169
+ nonce: NonNegativeBigIntSchema,
170
+ factory: z.union([AddressSchema, HexSchema]),
171
+ factoryData: HexSchema,
172
+ callData: HexSchema,
173
+ callGasLimit: NonNegativeBigIntSchema,
174
+ verificationGasLimit: NonNegativeBigIntSchema,
175
+ preVerificationGas: NonNegativeBigIntSchema,
176
+ maxFeePerGas: NonNegativeBigIntSchema,
177
+ maxPriorityFeePerGas: NonNegativeBigIntSchema,
178
+ paymaster: z.union([AddressSchema, HexSchema]),
179
+ paymasterVerificationGasLimit: NonNegativeBigIntSchema,
180
+ paymasterPostOpGasLimit: NonNegativeBigIntSchema,
181
+ paymasterData: HexSchema,
182
+ signature: HexSchema,
183
+ })
184
+
185
+ export const UserOperationSchema = z.discriminatedUnion('version', [
186
+ UserOperationV06Schema,
187
+ UserOperationV07Schema,
188
+ ])
189
+
190
+ export function parseAddress(value: unknown): z.infer<typeof AddressSchema> {
191
+ return AddressSchema.parse(value)
192
+ }
193
+
194
+ export function parsePaymentIntent(value: unknown): z.infer<typeof PaymentIntentSchema> {
195
+ return PaymentIntentSchema.parse(value)
196
+ }
197
+
198
+ export function parsePolicyDefinition(
199
+ value: unknown
200
+ ): z.infer<typeof PolicyDefinitionSchema> {
201
+ return PolicyDefinitionSchema.parse(value)
202
+ }
203
+
204
+ export function parseUserOperation(
205
+ value: unknown
206
+ ): z.infer<typeof UserOperationSchema> {
207
+ return UserOperationSchema.parse(value)
208
+ }
@@ -0,0 +1,95 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { readFileSync, readdirSync, statSync } from 'fs'
3
+ import { join, relative } from 'path'
4
+
5
+ const SRC = join(import.meta.dirname, '../../src')
6
+
7
+ const blockchains = [
8
+ 'viem', '@viem', 'ethers', '@ethersproject', 'web3', 'wagmi',
9
+ '@wagmi', '@solana', '@cosmjs', '@polkadot', 'near-api-js',
10
+ '@near-js', '@injectivelabs', 'alchemy-sdk', 'moralis', 'thirdweb',
11
+ ]
12
+
13
+ const nodeBuiltins = [
14
+ 'fs', 'path', 'crypto', 'http', 'https', 'os', 'child_process',
15
+ 'stream', 'net', 'dns', 'buffer', 'events', 'assert', 'tls',
16
+ 'cluster', 'dgram', 'readline', 'repl', 'vm', 'worker_threads',
17
+ 'perf_hooks', 'async_hooks', 'v8', 'inspector', 'wasi',
18
+ 'diagnostics_channel',
19
+ ]
20
+
21
+ function walk(dir: string): string[] {
22
+ const files: string[] = []
23
+ try {
24
+ for (const entry of readdirSync(dir)) {
25
+ const full = join(dir, entry)
26
+ if (statSync(full).isDirectory()) {
27
+ files.push(...walk(full))
28
+ } else if (entry.endsWith('.ts') && !entry.endsWith('.d.ts')) {
29
+ files.push(full)
30
+ }
31
+ }
32
+ } catch { /* skip */ }
33
+ return files
34
+ }
35
+
36
+ function parseImports(source: string): string[] {
37
+ const imports: string[] = []
38
+ const re = /import(?: type)?\s+(?:\{[^}]*\}|[^'"]+)\s+from\s+['"]([^'"]+)['"]/g
39
+ let m
40
+ while ((m = re.exec(source)) !== null) imports.push(m[1])
41
+ return imports
42
+ }
43
+
44
+ function getModuleName(p: string): string {
45
+ if (p.startsWith('@')) {
46
+ return p.split('/').slice(0, 2).join('/')
47
+ }
48
+ if (p.startsWith('.') || p.startsWith('/')) return ''
49
+ return p.split('/')[0]
50
+ }
51
+
52
+ describe('@brninpay/core — architecture invariants', () => {
53
+ const files = walk(SRC)
54
+ const blockViolations: { file: string; imp: string }[] = []
55
+ const nodeViolations: { file: string; imp: string }[] = []
56
+ const wsViolations: { file: string; imp: string }[] = []
57
+
58
+ for (const file of files) {
59
+ const source = readFileSync(file, 'utf8')
60
+ const imports = parseImports(source)
61
+ const rel = relative(SRC, file)
62
+
63
+ for (const imp of imports) {
64
+ const mod = getModuleName(imp)
65
+
66
+ if (blockchains.some((b) => mod.startsWith(b))) {
67
+ blockViolations.push({ file: rel, imp })
68
+ }
69
+
70
+ if (nodeBuiltins.includes(mod)) {
71
+ nodeViolations.push({ file: rel, imp })
72
+ }
73
+
74
+ if (imp.startsWith('@brninpay/')) {
75
+ wsViolations.push({ file: rel, imp })
76
+ }
77
+ }
78
+ }
79
+
80
+ it('must not import blockchain libraries (viem, ethers, web3, etc.)', () => {
81
+ expect(blockViolations, fmt('blockchain imports', blockViolations)).toHaveLength(0)
82
+ })
83
+
84
+ it('must not import Node.js builtins (fs, crypto, http, path, etc.)', () => {
85
+ expect(nodeViolations, fmt('Node builtins', nodeViolations)).toHaveLength(0)
86
+ })
87
+
88
+ it('must not import from other @brninpay packages', () => {
89
+ expect(wsViolations, fmt('forbidden workspace imports', wsViolations)).toHaveLength(0)
90
+ })
91
+ })
92
+
93
+ function fmt(label: string, v: { file: string; imp: string }[]): string {
94
+ return v.length ? `\n\n${label}:\n${v.map((x) => ` ${x.file} → ${x.imp}`).join('\n')}` : ''
95
+ }
@@ -0,0 +1,117 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { PolicyEngine } from '../src/policy-engine.js'
3
+ import { createBudgetCheckPolicy } from '../src/policies/budget-check.js'
4
+ import { createAllowlistPolicy } from '../src/policies/allowlist.js'
5
+ import { createMaxPerCallPolicy } from '../src/policies/max-per-call.js'
6
+ import type { Actor, Intent, BudgetState } from '../src/types.js'
7
+
8
+ const actor: Actor = {
9
+ id: 'actor_test_1',
10
+ name: 'test-agent',
11
+ status: 'active',
12
+ createdAt: new Date().toISOString(),
13
+ }
14
+
15
+ const budget: BudgetState = {
16
+ total: 100_000_000n,
17
+ used: 10_000_000n,
18
+ remaining: 90_000_000n,
19
+ period: 'monthly',
20
+ currency: 'USDC',
21
+ }
22
+
23
+ describe('Authorization Pipeline', () => {
24
+ it('approves payment within all limits', async () => {
25
+ const engine = new PolicyEngine([
26
+ createBudgetCheckPolicy('pol_budget', '1'),
27
+ createAllowlistPolicy('pol_allow', '1', ['openai:gpt-4']),
28
+ createMaxPerCallPolicy('pol_max', '1', 50_000_000n),
29
+ ])
30
+
31
+ const intent: Intent = {
32
+ target: 'openai:gpt-4',
33
+ amount: 5_000_000n,
34
+ purpose: { type: 'llm-inference', id: 'test-1' },
35
+ }
36
+
37
+ const auth = await engine.evaluate(actor, intent, budget)
38
+ expect(auth.decision).toBe('approved')
39
+ expect(auth.policyResults).toHaveLength(3)
40
+ })
41
+
42
+ it('denies payment exceeding budget', async () => {
43
+ const engine = new PolicyEngine([
44
+ createBudgetCheckPolicy('pol_budget', '1'),
45
+ createAllowlistPolicy('pol_allow', '1', ['openai:gpt-4']),
46
+ ])
47
+
48
+ const intent: Intent = {
49
+ target: 'openai:gpt-4',
50
+ amount: 95_000_000n,
51
+ purpose: { type: 'llm-inference', id: 'test-2' },
52
+ }
53
+
54
+ const auth = await engine.evaluate(actor, intent, budget)
55
+ expect(auth.decision).toBe('denied')
56
+ const budgetResult = auth.policyResults.find((r) => r.policyType === 'budget-check')
57
+ expect(budgetResult?.decision).toBe('deny')
58
+ })
59
+
60
+ it('denies payment to unauthorized target', async () => {
61
+ const engine = new PolicyEngine([
62
+ createBudgetCheckPolicy('pol_budget', '1'),
63
+ createAllowlistPolicy('pol_allow', '1', ['openai:gpt-4']),
64
+ ])
65
+
66
+ const intent: Intent = {
67
+ target: 'evil:service',
68
+ amount: 5_000_000n,
69
+ purpose: { type: 'test', id: 'test-3' },
70
+ }
71
+
72
+ const auth = await engine.evaluate(actor, intent, budget)
73
+ expect(auth.decision).toBe('denied')
74
+ const allowResult = auth.policyResults.find((r) => r.policyType === 'allowlist')
75
+ expect(allowResult?.decision).toBe('deny')
76
+ })
77
+
78
+ it('deny overrides pass across all policy types', async () => {
79
+ const engine = new PolicyEngine([
80
+ createBudgetCheckPolicy('pol_budget', '1'),
81
+ createMaxPerCallPolicy('pol_max', '1', 1_000_000n),
82
+ createAllowlistPolicy('pol_allow', '1', ['openai:gpt-4']),
83
+ ])
84
+
85
+ const intent: Intent = {
86
+ target: 'openai:gpt-4',
87
+ amount: 10_000_000n,
88
+ purpose: { type: 'test', id: 'test-4' },
89
+ }
90
+
91
+ const auth = await engine.evaluate(actor, intent, budget)
92
+ expect(auth.decision).toBe('denied')
93
+ expect(auth.policyResults.find((r) => r.policyType === 'budget-check')?.decision).toBe('pass')
94
+ expect(auth.policyResults.find((r) => r.policyType === 'allowlist')?.decision).toBe('pass')
95
+ expect(auth.policyResults.find((r) => r.policyType === 'max-per-call')?.decision).toBe('deny')
96
+ })
97
+
98
+ it('previewPay returns same result as pay without execution', async () => {
99
+ const engine = new PolicyEngine([
100
+ createBudgetCheckPolicy('pol_budget', '1'),
101
+ createAllowlistPolicy('pol_allow', '1', ['openai:gpt-4']),
102
+ ])
103
+
104
+ const intent: Intent = {
105
+ target: 'openai:gpt-4',
106
+ amount: 5_000_000n,
107
+ purpose: { type: 'test', id: 'preview-test' },
108
+ }
109
+
110
+ const auth1 = await engine.evaluate(actor, intent, budget)
111
+ const auth2 = await engine.evaluate(actor, intent, budget)
112
+
113
+ expect(auth1.decision).toBe(auth2.decision)
114
+ expect(auth1.policyResults.map((r) => r.decision))
115
+ .toEqual(auth2.policyResults.map((r) => r.decision))
116
+ })
117
+ })
@@ -0,0 +1,56 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ assertValidHmac,
4
+ generateNonce,
5
+ sha256Hex,
6
+ signHmacSha256,
7
+ validateTimestamp,
8
+ verifyHmacSha256,
9
+ } from '../src/utils/crypto.js'
10
+
11
+ describe('crypto utils', () => {
12
+ it('hashes payloads with sha256', async () => {
13
+ await expect(sha256Hex('hello')).resolves.toBe(
14
+ '0x2cf24dba5fb0a30e26e83b2ac5b9e29e1b161e5c1fa7425e73043362938b9824'
15
+ )
16
+ })
17
+
18
+ it('signs and verifies HMAC payloads', async () => {
19
+ const signature = await signHmacSha256({
20
+ secret: 'top-secret',
21
+ message: 'payload',
22
+ })
23
+
24
+ await expect(
25
+ verifyHmacSha256({
26
+ secret: 'top-secret',
27
+ message: 'payload',
28
+ signature,
29
+ })
30
+ ).resolves.toBe(true)
31
+
32
+ await expect(
33
+ assertValidHmac({
34
+ secret: 'top-secret',
35
+ message: 'payload',
36
+ signature,
37
+ })
38
+ ).resolves.toBeUndefined()
39
+ })
40
+
41
+ it('creates nonces with enough entropy', () => {
42
+ const nonce = generateNonce()
43
+ expect(nonce).toMatch(/^[0-9a-f]{32}$/)
44
+ })
45
+
46
+ it('validates timestamp skew windows', () => {
47
+ const now = Date.now()
48
+
49
+ expect(validateTimestamp(new Date(now - 1000), { nowMs: now, maxSkewMs: 5000 }).valid).toBe(
50
+ true
51
+ )
52
+ expect(validateTimestamp(new Date(now - 10000), { nowMs: now, maxSkewMs: 5000 }).valid).toBe(
53
+ false
54
+ )
55
+ })
56
+ })