@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,52 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { DecisionResolver } from '../src/decision-resolver.js'
3
+ import type { PolicyResult } from '../src/types.js'
4
+
5
+ function makeResult(decision: PolicyResult['decision']): PolicyResult {
6
+ return {
7
+ policyId: 'pol_1',
8
+ policyType: 'test',
9
+ version: '1',
10
+ decision,
11
+ reason: { code: 'test', message: 'test' },
12
+ evaluatedAt: new Date().toISOString(),
13
+ }
14
+ }
15
+
16
+ describe('DecisionResolver', () => {
17
+ it('returns denied for empty results', () => {
18
+ expect(DecisionResolver.resolve([])).toBe('denied')
19
+ })
20
+
21
+ it('returns approved for single pass', () => {
22
+ expect(DecisionResolver.resolve([makeResult('pass')])).toBe('approved')
23
+ })
24
+
25
+ it('returns denied for single deny', () => {
26
+ expect(DecisionResolver.resolve([makeResult('deny')])).toBe('denied')
27
+ })
28
+
29
+ it('returns pending-review for single review', () => {
30
+ expect(DecisionResolver.resolve([makeResult('review')])).toBe('pending-review')
31
+ })
32
+
33
+ it('deny overrides pass', () => {
34
+ const results = [makeResult('pass'), makeResult('deny'), makeResult('pass')]
35
+ expect(DecisionResolver.resolve(results)).toBe('denied')
36
+ })
37
+
38
+ it('deny overrides review', () => {
39
+ const results = [makeResult('review'), makeResult('deny')]
40
+ expect(DecisionResolver.resolve(results)).toBe('denied')
41
+ })
42
+
43
+ it('review overrides pass', () => {
44
+ const results = [makeResult('pass'), makeResult('review')]
45
+ expect(DecisionResolver.resolve(results)).toBe('pending-review')
46
+ })
47
+
48
+ it('returns approved for all passes', () => {
49
+ const results = [makeResult('pass'), makeResult('pass'), makeResult('pass')]
50
+ expect(DecisionResolver.resolve(results)).toBe('approved')
51
+ })
52
+ })
@@ -0,0 +1,97 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ AuthorizationError,
4
+ BudgetExhaustedError,
5
+ AllowlistDeniedError,
6
+ RateLimitExceededError,
7
+ AuthorizationExpiredError,
8
+ ExecutionError,
9
+ NetworkError,
10
+ GasEstimationError,
11
+ BroadcastError,
12
+ ReorgDetectedError,
13
+ } from '../src/errors.js'
14
+
15
+ describe('AuthorizationError', () => {
16
+ it('creates with code and message', () => {
17
+ const err = new AuthorizationError('test_code', 'test message')
18
+ expect(err.code).toBe('test_code')
19
+ expect(err.message).toBe('test message')
20
+ expect(err.name).toBe('AuthorizationError')
21
+ })
22
+ })
23
+
24
+ describe('BudgetExhaustedError', () => {
25
+ it('sets correct code and properties', () => {
26
+ const err = new BudgetExhaustedError('pol_1', 100n, 500n)
27
+ expect(err.code).toBe('budget_exhausted')
28
+ expect(err.budgetUsed).toBe(100n)
29
+ expect(err.budgetLimit).toBe(500n)
30
+ expect(err.policyId).toBe('pol_1')
31
+ expect(err.message).toContain('100')
32
+ expect(err.message).toContain('500')
33
+ })
34
+ })
35
+
36
+ describe('AllowlistDeniedError', () => {
37
+ it('sets correct code and target', () => {
38
+ const err = new AllowlistDeniedError('pol_1', 'openai:gpt-4')
39
+ expect(err.code).toBe('allowlist_denied')
40
+ expect(err.target).toBe('openai:gpt-4')
41
+ expect(err.message).toContain('openai:gpt-4')
42
+ })
43
+ })
44
+
45
+ describe('RateLimitExceededError', () => {
46
+ it('sets correct code and properties', () => {
47
+ const err = new RateLimitExceededError('pol_1', 60_000, 10)
48
+ expect(err.code).toBe('rate_limit_exceeded')
49
+ expect(err.windowMs).toBe(60_000)
50
+ expect(err.maxCalls).toBe(10)
51
+ })
52
+ })
53
+
54
+ describe('AuthorizationExpiredError', () => {
55
+ it('sets correct code', () => {
56
+ const err = new AuthorizationExpiredError()
57
+ expect(err.code).toBe('authorization_expired')
58
+ })
59
+ })
60
+
61
+ describe('ExecutionError', () => {
62
+ it('creates with code and message', () => {
63
+ const err = new ExecutionError('exec_code', 'exec message')
64
+ expect(err.code).toBe('exec_code')
65
+ expect(err.name).toBe('ExecutionError')
66
+ })
67
+ })
68
+
69
+ describe('NetworkError', () => {
70
+ it('sets correct code', () => {
71
+ const err = new NetworkError('connection refused')
72
+ expect(err.code).toBe('network_error')
73
+ expect(err.message).toBe('connection refused')
74
+ })
75
+ })
76
+
77
+ describe('GasEstimationError', () => {
78
+ it('sets correct code', () => {
79
+ const err = new GasEstimationError('gas too high')
80
+ expect(err.code).toBe('gas_estimation_error')
81
+ })
82
+ })
83
+
84
+ describe('BroadcastError', () => {
85
+ it('sets correct code', () => {
86
+ const err = new BroadcastError('tx failed')
87
+ expect(err.code).toBe('broadcast_error')
88
+ })
89
+ })
90
+
91
+ describe('ReorgDetectedError', () => {
92
+ it('sets correct code and depth', () => {
93
+ const err = new ReorgDetectedError(3)
94
+ expect(err.code).toBe('reorg_detected')
95
+ expect(err.depth).toBe(3)
96
+ })
97
+ })
@@ -0,0 +1,49 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ assertValidPaymentIntentTransition,
4
+ canTransitionPaymentIntentStatus,
5
+ deserializePaymentIntent,
6
+ serializePaymentIntent,
7
+ } from '../src/types/payment.js'
8
+ import { ValidationError } from '../src/errors.js'
9
+ import type { PaymentIntent } from '../src/types/payment.js'
10
+
11
+ const paymentIntent: PaymentIntent = {
12
+ id: 'pi_123',
13
+ workspaceId: 'ws_123',
14
+ actorId: 'actor_123',
15
+ status: 'approved',
16
+ amount: {
17
+ currency: 'USDC',
18
+ value: 1250000n,
19
+ decimals: 6,
20
+ },
21
+ recipient: {
22
+ address: '0x1111111111111111111111111111111111111111',
23
+ chainId: 8453,
24
+ },
25
+ createdAt: '2026-07-06T10:00:00.000Z',
26
+ updatedAt: '2026-07-06T10:00:00.000Z',
27
+ }
28
+
29
+ describe('payment types', () => {
30
+ it('serializes and deserializes bigint fields without losing precision', () => {
31
+ const serialized = serializePaymentIntent(paymentIntent)
32
+ const restored = deserializePaymentIntent(serialized)
33
+
34
+ expect(restored.amount.value).toBe(1250000n)
35
+ expect(restored.recipient.address).toBe(paymentIntent.recipient.address)
36
+ })
37
+
38
+ it('allows valid state transitions', () => {
39
+ expect(canTransitionPaymentIntentStatus('approved', 'submitted')).toBe(true)
40
+ expect(() => assertValidPaymentIntentTransition('approved', 'submitted')).not.toThrow()
41
+ })
42
+
43
+ it('rejects invalid state transitions', () => {
44
+ expect(canTransitionPaymentIntentStatus('succeeded', 'approved')).toBe(false)
45
+ expect(() => assertValidPaymentIntentTransition('succeeded', 'approved')).toThrow(
46
+ ValidationError
47
+ )
48
+ })
49
+ })
@@ -0,0 +1,162 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { createBudgetCheckPolicy } from '../src/policies/budget-check.js'
3
+ import { createAllowlistPolicy } from '../src/policies/allowlist.js'
4
+ import { createMaxPerCallPolicy } from '../src/policies/max-per-call.js'
5
+ import { createRateLimitPolicy } from '../src/policies/rate-limit.js'
6
+ import type { Actor, Intent, BudgetState, PolicyResult } from '../src/types.js'
7
+
8
+ const defaultActor: Actor = {
9
+ id: 'actor_1',
10
+ name: 'test-agent',
11
+ status: 'active',
12
+ createdAt: new Date().toISOString(),
13
+ }
14
+
15
+ const defaultBudget: BudgetState = {
16
+ total: 1000n,
17
+ used: 200n,
18
+ remaining: 800n,
19
+ period: 'monthly',
20
+ currency: 'USDC',
21
+ }
22
+
23
+ async function evaluatePolicy(
24
+ policy: { middleware: any },
25
+ intent: Intent,
26
+ budget: BudgetState = defaultBudget,
27
+ actor: Actor = defaultActor
28
+ ): Promise<PolicyResult> {
29
+ const next = async () => ({
30
+ policyId: 'test',
31
+ policyType: 'test',
32
+ version: '1',
33
+ decision: 'pass' as const,
34
+ reason: { code: 'noop', message: 'noop' },
35
+ evaluatedAt: new Date().toISOString(),
36
+ })
37
+ return policy.middleware(
38
+ { actor, intent, budget, evaluatedPolicies: [] },
39
+ next
40
+ )
41
+ }
42
+
43
+ describe('BudgetCheckPolicy', () => {
44
+ it('passes when used + amount <= limit', async () => {
45
+ const policy = createBudgetCheckPolicy('pol_budget', '1')
46
+ const intent: Intent = {
47
+ target: 'test:svc',
48
+ amount: 300n,
49
+ purpose: { type: 'test', id: '1' },
50
+ }
51
+ const result = await evaluatePolicy(policy, intent)
52
+ expect(result.decision).toBe('pass')
53
+ })
54
+
55
+ it('denies when used + amount > limit', async () => {
56
+ const policy = createBudgetCheckPolicy('pol_budget', '1')
57
+ const intent: Intent = {
58
+ target: 'test:svc',
59
+ amount: 900n,
60
+ purpose: { type: 'test', id: '1' },
61
+ }
62
+ const result = await evaluatePolicy(policy, intent)
63
+ expect(result.decision).toBe('deny')
64
+ expect(result.reason.code).toBe('budget_exhausted')
65
+ })
66
+
67
+ it('uses budget from context', async () => {
68
+ const policy = createBudgetCheckPolicy('pol_budget', '1')
69
+ const intent: Intent = {
70
+ target: 'test:svc',
71
+ amount: 50n,
72
+ purpose: { type: 'test', id: '1' },
73
+ }
74
+ const smallBudget: BudgetState = {
75
+ total: 100n,
76
+ used: 80n,
77
+ remaining: 20n,
78
+ period: 'total',
79
+ currency: 'USDC',
80
+ }
81
+ const result = await evaluatePolicy(policy, intent, smallBudget)
82
+ expect(result.decision).toBe('deny')
83
+ })
84
+ })
85
+
86
+ describe('AllowlistPolicy', () => {
87
+ it('passes when target is in allowlist', async () => {
88
+ const policy = createAllowlistPolicy('pol_allow', '1', ['good:svc', 'better:svc'])
89
+ const intent: Intent = {
90
+ target: 'good:svc',
91
+ amount: 100n,
92
+ purpose: { type: 'test', id: '1' },
93
+ }
94
+ const result = await evaluatePolicy(policy, intent)
95
+ expect(result.decision).toBe('pass')
96
+ })
97
+
98
+ it('denies when target is not in allowlist', async () => {
99
+ const policy = createAllowlistPolicy('pol_allow', '1', ['good:svc'])
100
+ const intent: Intent = {
101
+ target: 'evil:svc',
102
+ amount: 100n,
103
+ purpose: { type: 'test', id: '1' },
104
+ }
105
+ const result = await evaluatePolicy(policy, intent)
106
+ expect(result.decision).toBe('deny')
107
+ expect(result.reason.code).toBe('allowlist_denied')
108
+ })
109
+ })
110
+
111
+ describe('MaxPerCallPolicy', () => {
112
+ it('passes when amount <= max', async () => {
113
+ const policy = createMaxPerCallPolicy('pol_max', '1', 500n)
114
+ const intent: Intent = {
115
+ target: 'test:svc',
116
+ amount: 300n,
117
+ purpose: { type: 'test', id: '1' },
118
+ }
119
+ const result = await evaluatePolicy(policy, intent)
120
+ expect(result.decision).toBe('pass')
121
+ })
122
+
123
+ it('denies when amount > max', async () => {
124
+ const policy = createMaxPerCallPolicy('pol_max', '1', 500n)
125
+ const intent: Intent = {
126
+ target: 'test:svc',
127
+ amount: 600n,
128
+ purpose: { type: 'test', id: '1' },
129
+ }
130
+ const result = await evaluatePolicy(policy, intent)
131
+ expect(result.decision).toBe('deny')
132
+ })
133
+ })
134
+
135
+ describe('RateLimitPolicy', () => {
136
+ it('passes when call count is within limit', async () => {
137
+ const store = new Map<string, number[]>()
138
+ const policy = createRateLimitPolicy('pol_rate', '1', 10, 60_000, store)
139
+ const intent: Intent = {
140
+ target: 'test:svc',
141
+ amount: 100n,
142
+ purpose: { type: 'test', id: '1' },
143
+ }
144
+ const result = await evaluatePolicy(policy, intent)
145
+ expect(result.decision).toBe('pass')
146
+ })
147
+
148
+ it('denies when call count exceeds limit', async () => {
149
+ const now = Date.now()
150
+ const store = new Map<string, number[]>()
151
+ store.set('actor_1', Array(10).fill(now - 1000))
152
+ const policy = createRateLimitPolicy('pol_rate', '1', 10, 60_000, store)
153
+ const intent: Intent = {
154
+ target: 'test:svc',
155
+ amount: 100n,
156
+ purpose: { type: 'test', id: '1' },
157
+ }
158
+ const result = await evaluatePolicy(policy, intent)
159
+ expect(result.decision).toBe('deny')
160
+ expect(result.reason.code).toBe('rate_limit_exceeded')
161
+ })
162
+ })
@@ -0,0 +1,96 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import { PolicyEngine } from '../src/policy-engine.js'
3
+ import type {
4
+ Actor,
5
+ Intent,
6
+ PolicyContext,
7
+ PolicyMiddleware,
8
+ PolicyEvaluator,
9
+ BudgetState,
10
+ PolicyResult,
11
+ } from '../src/types.js'
12
+
13
+ const defaultActor: Actor = {
14
+ id: 'actor_1',
15
+ name: 'test-agent',
16
+ status: 'active',
17
+ createdAt: new Date().toISOString(),
18
+ }
19
+
20
+ const defaultIntent: Intent = {
21
+ target: 'test:service',
22
+ amount: 100n,
23
+ purpose: { type: 'test', id: '1' },
24
+ }
25
+
26
+ const defaultBudget: BudgetState = {
27
+ total: 1000n,
28
+ used: 200n,
29
+ remaining: 800n,
30
+ period: 'monthly',
31
+ currency: 'USDC',
32
+ }
33
+
34
+ function passMiddleware(id: string): PolicyEvaluator {
35
+ return {
36
+ id,
37
+ type: 'pass',
38
+ version: '1',
39
+ middleware: async (_ctx, next) => {
40
+ const result = await next()
41
+ return result
42
+ },
43
+ }
44
+ }
45
+
46
+ function denyMiddleware(id: string): PolicyEvaluator {
47
+ return {
48
+ id,
49
+ type: 'deny',
50
+ version: '1',
51
+ middleware: async (_ctx, _next) => ({
52
+ policyId: id,
53
+ policyType: 'deny',
54
+ version: '1',
55
+ decision: 'deny' as const,
56
+ reason: { code: 'test_deny', message: 'denied by test' },
57
+ evaluatedAt: new Date().toISOString(),
58
+ }),
59
+ }
60
+ }
61
+
62
+ describe('PolicyEngine', () => {
63
+ it('returns approved for empty middleware list', async () => {
64
+ const engine = new PolicyEngine([])
65
+ const result = await engine.evaluate(defaultActor, defaultIntent, defaultBudget)
66
+ expect(result.decision).toBe('approved')
67
+ expect(result.policyResults).toHaveLength(0)
68
+ })
69
+
70
+ it('evaluates all pass middlewares', async () => {
71
+ const engine = new PolicyEngine([
72
+ passMiddleware('pol_1'),
73
+ passMiddleware('pol_2'),
74
+ ])
75
+ const result = await engine.evaluate(defaultActor, defaultIntent, defaultBudget)
76
+ expect(result.decision).toBe('approved')
77
+ expect(result.policyResults).toHaveLength(2)
78
+ })
79
+
80
+ it('deny overrides pass results', async () => {
81
+ const engine = new PolicyEngine([
82
+ passMiddleware('pol_1'),
83
+ denyMiddleware('pol_2'),
84
+ ])
85
+ const result = await engine.evaluate(defaultActor, defaultIntent, defaultBudget)
86
+ expect(result.decision).toBe('denied')
87
+ expect(result.policyResults).toHaveLength(2)
88
+ })
89
+
90
+ it('includes actor version in authorization', async () => {
91
+ const engine = new PolicyEngine([])
92
+ const result = await engine.evaluate(defaultActor, defaultIntent, defaultBudget)
93
+ expect(result.actorVersion).toBe('1')
94
+ expect(result.intent).toEqual(defaultIntent)
95
+ })
96
+ })
@@ -0,0 +1,93 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import {
3
+ aggregatePolicyDecisions,
4
+ evaluatePolicies,
5
+ type AllowlistPolicy,
6
+ type BudgetPolicy,
7
+ type PolicyEvaluationContext,
8
+ type RateLimitPolicy,
9
+ } from '../src/types/policy.js'
10
+
11
+ const context: PolicyEvaluationContext = {
12
+ actorId: 'actor_123',
13
+ workspaceId: 'ws_123',
14
+ recipient: '0x1111111111111111111111111111111111111111',
15
+ chainId: 8453,
16
+ amount: 2500000n,
17
+ currency: 'USDC',
18
+ timestampMs: Date.now(),
19
+ spentAmount: 1000000n,
20
+ requestCount: 2,
21
+ }
22
+
23
+ describe('policy types', () => {
24
+ it('returns deny when any policy denies the request', () => {
25
+ const policies: [BudgetPolicy, AllowlistPolicy] = [
26
+ {
27
+ id: 'budget_1',
28
+ name: 'Budget Guard',
29
+ enabled: true,
30
+ kind: 'budget',
31
+ currency: 'USDC',
32
+ limit: 5000000n,
33
+ },
34
+ {
35
+ id: 'allowlist_1',
36
+ name: 'Allowlist',
37
+ enabled: true,
38
+ kind: 'allowlist',
39
+ addresses: ['0x2222222222222222222222222222222222222222'],
40
+ },
41
+ ]
42
+
43
+ const result = evaluatePolicies(policies, context)
44
+ expect(result.decision).toBe('deny')
45
+ expect(result.reasons[0]?.code).toBe('recipient_not_allowlisted')
46
+ })
47
+
48
+ it('returns review when no policy denies but one flags review', () => {
49
+ const policies: [BudgetPolicy, RateLimitPolicy] = [
50
+ {
51
+ id: 'budget_2',
52
+ name: 'Soft Budget',
53
+ enabled: true,
54
+ kind: 'budget',
55
+ currency: 'USDC',
56
+ limit: 5000000n,
57
+ softLimit: 3000000n,
58
+ },
59
+ {
60
+ id: 'rate_1',
61
+ name: 'Rate Limit',
62
+ enabled: true,
63
+ kind: 'rate_limit',
64
+ maxRequests: 10,
65
+ intervalMs: 60000,
66
+ burst: 5,
67
+ scope: 'actor',
68
+ },
69
+ ]
70
+
71
+ const result = evaluatePolicies(policies, context)
72
+ expect(result.decision).toBe('review')
73
+ expect(result.reasons.some((reason) => reason.code === 'budget_soft_limit_exceeded')).toBe(true)
74
+ })
75
+
76
+ it('aggregates allow results when all policies pass', () => {
77
+ const result = aggregatePolicyDecisions([
78
+ {
79
+ policyId: 'budget_3',
80
+ decision: 'allow',
81
+ reason: { code: 'budget_ok', message: 'Budget okay' },
82
+ },
83
+ {
84
+ policyId: 'allowlist_3',
85
+ decision: 'allow',
86
+ reason: { code: 'allowlist_ok', message: 'Allowlist okay' },
87
+ },
88
+ ])
89
+
90
+ expect(result.decision).toBe('allow')
91
+ expect(result.reasons).toHaveLength(2)
92
+ })
93
+ })
@@ -0,0 +1,110 @@
1
+ import { describe, expect, it } from 'vitest'
2
+ import fc from 'fast-check'
3
+ import { PolicyEngine } from '../../src/policy-engine.js'
4
+ import { createBudgetCheckPolicy } from '../../src/policies/budget-check.js'
5
+ import { DecisionResolver } from '../../src/decision-resolver.js'
6
+ import type { Actor, Intent, BudgetState, PolicyResult } from '../../src/types.js'
7
+
8
+ const actor: Actor = {
9
+ id: 'actor_prop',
10
+ name: 'prop-test',
11
+ status: 'active',
12
+ createdAt: new Date().toISOString(),
13
+ }
14
+
15
+ describe('Authorization invariants (property-based)', () => {
16
+ it('same inputs always produce same authorization result', async () => {
17
+ await fc.assert(
18
+ fc.asyncProperty(
19
+ fc.bigInt({ min: 0n, max: 1_000_000n }),
20
+ fc.bigInt({ min: 0n, max: 1_000_000n }),
21
+ async (budgetTotal, spendAmount) => {
22
+ const budget: BudgetState = {
23
+ total: budgetTotal,
24
+ used: 0n,
25
+ remaining: budgetTotal,
26
+ period: 'total',
27
+ currency: 'USDC',
28
+ }
29
+ const engine = new PolicyEngine([
30
+ createBudgetCheckPolicy('pol_budget', '1'),
31
+ ])
32
+ const intent: Intent = {
33
+ target: 'test:svc',
34
+ amount: spendAmount,
35
+ purpose: { type: 'test', id: 'prop' },
36
+ }
37
+ const a = await engine.evaluate(actor, intent, budget)
38
+ const b = await engine.evaluate(actor, intent, budget)
39
+ expect(a.decision).toBe(b.decision)
40
+ }
41
+ ),
42
+ { numRuns: 100 }
43
+ )
44
+ })
45
+
46
+ it('deny always dominates pass and review', () => {
47
+ fc.assert(
48
+ fc.property(
49
+ fc.array(
50
+ fc.constantFrom<'pass' | 'deny' | 'review'>('pass', 'deny', 'review'),
51
+ { minLength: 0, maxLength: 10 }
52
+ ),
53
+ (decisions) => {
54
+ const results: PolicyResult[] = decisions.map((d, i) => ({
55
+ policyId: `pol_${i}`,
56
+ policyType: 'prop-test',
57
+ version: '1',
58
+ decision: d,
59
+ reason: { code: 'prop', message: 'property test' },
60
+ evaluatedAt: new Date().toISOString(),
61
+ }))
62
+ const resolved = DecisionResolver.resolve(results)
63
+ if (decisions.length === 0) {
64
+ expect(resolved).toBe('denied')
65
+ } else if (decisions.some((d) => d === 'deny')) {
66
+ expect(resolved).toBe('denied')
67
+ } else if (decisions.some((d) => d === 'review')) {
68
+ expect(resolved).toBe('pending-review')
69
+ } else {
70
+ expect(resolved).toBe('approved')
71
+ }
72
+ }
73
+ ),
74
+ { numRuns: 200 }
75
+ )
76
+ })
77
+
78
+ it('budget enforcement never allows overspend', async () => {
79
+ await fc.assert(
80
+ fc.asyncProperty(
81
+ fc.bigInt({ min: 1n, max: 1_000_000n }),
82
+ fc.bigInt({ min: 1n, max: 1_000_000n }),
83
+ async (total, amount) => {
84
+ const budget: BudgetState = {
85
+ total,
86
+ used: 0n,
87
+ remaining: total,
88
+ period: 'total',
89
+ currency: 'USDC',
90
+ }
91
+ const engine = new PolicyEngine([
92
+ createBudgetCheckPolicy('pol_budget', '1'),
93
+ ])
94
+ const intent: Intent = {
95
+ target: 'test:svc',
96
+ amount,
97
+ purpose: { type: 'test', id: 'prop' },
98
+ }
99
+ const auth = await engine.evaluate(actor, intent, budget)
100
+ if (amount > total) {
101
+ expect(auth.decision).toBe('denied')
102
+ } else {
103
+ expect(auth.decision).toBe('approved')
104
+ }
105
+ }
106
+ ),
107
+ { numRuns: 100 }
108
+ )
109
+ })
110
+ })