Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions apps/api/src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -631,18 +631,24 @@ function standardizeSessionMetadata(
}

if (sessionEventType === SESSION_EVENTS.SESSION_BUDGET_AUTHORIZATION_ISSUED) {
const signedSba = asRecord(payloadRecord.sessionBudgetAuthorization)
const signedSbaAuthorization = asRecord(signedSba?.authorization)
const budgetId = asString(payloadRecord.budgetId)
const maxAmountMinor = asString(payloadRecord.maxAmountMinor)
const currency = asString(payloadRecord.currency)
const minorUnit =
typeof payloadRecord.minorUnit === 'number' ? payloadRecord.minorUnit : undefined
const budgetScope = asString(payloadRecord.budgetScope) ?? asString(signedSbaAuthorization?.budgetScope)
const scopeId = asString(payloadRecord.scopeId) ?? asString(signedSbaAuthorization?.scopeId)
const allowedRails = Array.isArray(payloadRecord.allowedRails) ? payloadRecord.allowedRails : []
const expiresAt = asString(payloadRecord.expiresAt)
return {
...(budgetId ? { budgetId } : {}),
...(maxAmountMinor ? { maxAmountMinor } : {}),
...(currency ? { currency } : {}),
...(minorUnit !== undefined ? { minorUnit } : {}),
...(budgetScope ? { budgetScope } : {}),
...(scopeId ? { scopeId } : {}),
...(allowedRails.length > 0 ? { allowedRails } : {}),
...(expiresAt ? { expiresAt } : {}),
}
Expand Down
2 changes: 2 additions & 0 deletions apps/api/src/routes/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -559,6 +559,8 @@ gateRouter.post('/entry', async (req, res) => {
maxAmountMinor: signedSba.authorization.maxAmountMinor,
currency: signedSba.authorization.currency,
minorUnit: signedSba.authorization.minorUnit,
budgetScope: signedSba.authorization.budgetScope,
scopeId: signedSba.authorization.scopeId,
allowedRails: signedSba.authorization.allowedRails,
expiresAt: signedSba.authorization.expiresAt,
sessionBudgetAuthorization: signedSba,
Expand Down
11 changes: 11 additions & 0 deletions apps/api/src/services/sessionBudgetAuthorization.ts
Original file line number Diff line number Diff line change
@@ -1,14 +1,18 @@
import crypto, { createHash, randomUUID } from 'node:crypto'
import type { Asset, PaymentPolicyDecision, Rail } from '@parker/policy-core'

export type BudgetScope = 'SESSION' | 'DAY' | 'VEHICLE' | 'FLEET'

export interface SessionBudgetAuthorization {
version: 1
budgetId: string
sessionId: string
vehicleId: string
scopeId?: string
policyHash: string
currency: string
minorUnit: number
budgetScope: BudgetScope
maxAmountMinor: string
allowedRails: Rail[]
allowedAssets: Asset[]
Expand Down Expand Up @@ -61,9 +65,11 @@ function parseVerificationPublicKey(): crypto.KeyObject | null {
export function createSignedSessionBudgetAuthorization(input: {
sessionId: string
vehicleId: string
scopeId?: string
policyHash: string
currency: string
minorUnit?: number
budgetScope?: BudgetScope
maxAmountMinor: string
allowedRails: Rail[]
allowedAssets: Asset[]
Expand All @@ -78,9 +84,11 @@ export function createSignedSessionBudgetAuthorization(input: {
budgetId: randomUUID(),
sessionId: input.sessionId,
vehicleId: input.vehicleId,
...(input.scopeId ? { scopeId: input.scopeId } : {}),
policyHash: input.policyHash,
currency: input.currency,
minorUnit: input.minorUnit ?? 2,
budgetScope: input.budgetScope ?? 'SESSION',
maxAmountMinor: input.maxAmountMinor,
allowedRails: input.allowedRails,
allowedAssets: input.allowedAssets,
Expand Down Expand Up @@ -128,6 +136,9 @@ export function verifySignedSessionBudgetAuthorizationForDecision(
if (authorization.sessionId !== input.sessionId || authorization.policyHash !== decision.policyHash) {
return { ok: false, reason: 'mismatch' }
}
if (authorization.budgetScope !== 'SESSION') {
return { ok: false, reason: 'mismatch' }
}
if (decision.rail && !authorization.allowedRails.includes(decision.rail)) {
return { ok: false, reason: 'mismatch' }
}
Expand Down
34 changes: 34 additions & 0 deletions apps/api/test/db/queries.session-events.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -163,6 +163,8 @@ describe('db.insertPolicyEvent mirrored session timeline metadata standardizatio
maxAmountMinor: '3000',
currency: 'USD',
minorUnit: 2,
budgetScope: 'SESSION',
scopeId: 'veh_123',
allowedRails: ['xrpl', 'stripe'],
expiresAt: '2026-03-08T18:00:00Z',
},
Expand All @@ -178,13 +180,45 @@ describe('db.insertPolicyEvent mirrored session timeline metadata standardizatio
maxAmountMinor: '3000',
currency: 'USD',
minorUnit: 2,
budgetScope: 'SESSION',
scopeId: 'veh_123',
allowedRails: ['xrpl', 'stripe'],
expiresAt: '2026-03-08T18:00:00Z',
},
}),
)
})

it('includes budgetScope in timeline metadata when only nested SBA envelope is present', async () => {
const sessionId = '77777777-7777-4777-8777-777777777777'
await db.insertPolicyEvent({
eventType: LIFECYCLE_EVENT.SESSION_BUDGET_AUTHORIZATION_ISSUED,
sessionId,
payload: {
sessionBudgetAuthorization: {
authorization: {
budgetScope: 'SESSION',
scopeId: 'veh_123',
},
signature: 'sig',
keyId: 'parker-budget-signing-key-1',
},
},
})

expect(emitSessionEvent).toHaveBeenCalledWith(
expect.objectContaining({ query: expect.any(Function) }),
expect.objectContaining({
sessionId,
eventType: SESSION_EVENTS.SESSION_BUDGET_AUTHORIZATION_ISSUED,
metadata: expect.objectContaining({
budgetScope: 'SESSION',
scopeId: 'veh_123',
}),
}),
)
})

it('emits settlement verified before session closed in timeline order', async () => {
const sessionId = '55555555-5555-4555-8555-555555555555'
await db.insertPolicyEvent({
Expand Down
36 changes: 36 additions & 0 deletions apps/api/test/routes/sessions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -247,5 +247,41 @@ describe('sessions routes', () => {
},
])
})

it('returns SBA timeline metadata including budgetScope', async () => {
vi.mocked(db.getSessionState).mockResolvedValue('active')
vi.mocked(db.getSessionTimeline).mockResolvedValue([
{
id: 'evt-sba',
sessionId,
eventType: 'SESSION_BUDGET_AUTHORIZATION.ISSUED',
timestamp: new Date('2026-03-07T09:11:03.000Z'),
metadata: {
budgetId: 'bud-1',
maxAmountMinor: '3000',
minorUnit: 2,
currency: 'USD',
budgetScope: 'SESSION',
scopeId: 'veh_123',
},
} as any,
])
const app = createApp()
const res = await request(app).get(`/api/sessions/${sessionId}/timeline`)

expect(res.status).toBe(200)
expect(res.body.events[0]).toEqual({
eventType: 'SESSION_BUDGET_AUTHORIZATION.ISSUED',
createdAt: '2026-03-07T09:11:03.000Z',
metadata: {
budgetId: 'bud-1',
maxAmountMinor: '3000',
minorUnit: 2,
currency: 'USD',
budgetScope: 'SESSION',
scopeId: 'veh_123',
},
})
})
})
})
41 changes: 41 additions & 0 deletions apps/api/test/services/sessionBudgetAuthorization.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ describe('sessionBudgetAuthorization service', () => {
const envelope = createSignedSessionBudgetAuthorization({
sessionId: '11111111-1111-4111-8111-111111111111',
vehicleId: '1234567',
scopeId: 'veh_123',
policyHash: 'ph-1',
currency: 'USD',
minorUnit: 2,
Expand All @@ -38,6 +39,8 @@ describe('sessionBudgetAuthorization service', () => {
expiresAt: new Date(Date.now() + 60_000).toISOString(),
})
expect(envelope).not.toBeNull()
expect(envelope!.authorization.budgetScope).toBe('SESSION')
expect(envelope!.authorization.scopeId).toBe('veh_123')

const decision = {
decisionId: 'dec-1',
Expand Down Expand Up @@ -76,6 +79,7 @@ describe('sessionBudgetAuthorization service', () => {
const envelope = createSignedSessionBudgetAuthorization({
sessionId: '11111111-1111-4111-8111-111111111111',
vehicleId: '1234567',
scopeId: 'veh_123',
policyHash: 'ph-1',
currency: 'USD',
maxAmountMinor: '1000',
Expand Down Expand Up @@ -112,4 +116,41 @@ describe('sessionBudgetAuthorization service', () => {
})
expect(verification).toEqual({ ok: false, reason: 'mismatch' })
})

it('rejects unsupported non-session budget scopes in current implementation', () => {
const { privateKey, publicKey } = crypto.generateKeyPairSync('ed25519')
process.env.PARKER_SBA_SIGNING_PRIVATE_KEY_PEM = privateKey.export({ type: 'pkcs8', format: 'pem' }).toString()
process.env.PARKER_SBA_SIGNING_PUBLIC_KEY_PEM = publicKey.export({ type: 'spki', format: 'pem' }).toString()

const envelope = createSignedSessionBudgetAuthorization({
sessionId: '11111111-1111-4111-8111-111111111111',
vehicleId: '1234567',
policyHash: 'ph-1',
currency: 'USD',
budgetScope: 'DAY',
scopeId: 'veh_123',
maxAmountMinor: '5000',
allowedRails: ['stripe'],
allowedAssets: [],
destinationAllowlist: [],
expiresAt: new Date(Date.now() + 60_000).toISOString(),
})
expect(envelope).not.toBeNull()

const decision = {
decisionId: 'dec-1',
policyHash: 'ph-1',
action: 'ALLOW',
reasons: ['OK'],
expiresAtISO: new Date(Date.now() + 60_000).toISOString(),
rail: 'stripe',
priceFiat: { amountMinor: '1000', currency: 'USD' },
} as unknown as PaymentPolicyDecision

const verification = verifySignedSessionBudgetAuthorizationForDecision(envelope!, {
sessionId: '11111111-1111-4111-8111-111111111111',
decision,
})
expect(verification).toEqual({ ok: false, reason: 'mismatch' })
})
})
25 changes: 25 additions & 0 deletions docs/SESSION_BUDGET_AUTHORIZATION.md
Original file line number Diff line number Diff line change
Expand Up @@ -100,9 +100,11 @@ Example:
"budgetId": "bud_6f92c",
"sessionId": "sess_8ac21",
"vehicleId": "veh_92c1",
"scopeId": "sess_8ac21",
"policyHash": "a93fd2...",
"currency": "USD",
"minorUnit": 2,
"budgetScope": "SESSION",
"maxAmountMinor": "3000",
"allowedRails": ["xrpl", "stripe"],
"allowedAssets": [
Expand All @@ -121,6 +123,23 @@ Example:

`allowedAssets` is only enforced for rails that require an on-chain asset (`xrpl`, `evm`). It is ignored for hosted payment rails such as `stripe`.

`budgetScope` defines the logical scope of the spending envelope.
In the initial Parker implementation the value is expected to be `SESSION`, meaning the budget applies only to the current parking session.
Future implementations may support broader scopes such as `DAY`, `VEHICLE`, or `FLEET`.

## Budget Scope Values

| Value | Description |
|------|-------------|
| SESSION | Budget applies only to the current parking session |
| DAY | Budget applies across all sessions for the same vehicle during a day |
| VEHICLE | Budget applies across sessions for a specific vehicle |
| FLEET | Budget applies across multiple vehicles under a fleet policy |

`scopeId` identifies the entity that the budget scope applies to.
For `SESSION` scope, `scopeId` should equal `sessionId`.
Examples: `budgetScope: "VEHICLE"` with `scopeId: "veh_123"`, or `budgetScope: "FLEET"` with `scopeId: "fleet_abc"`.

---

# Field Definitions
Expand All @@ -131,9 +150,11 @@ Example:
| budgetId | unique identifier |
| sessionId | Parker session |
| vehicleId | vehicle identifier |
| scopeId | identifier of the entity represented by `budgetScope` (e.g., session, vehicle, fleet) |
| policyHash | hash of policy snapshot |
| currency | fiat currency reference |
| minorUnit | decimal precision of the currency (e.g., USD = 2) |
| budgetScope | scope of the authorized budget (e.g., SESSION, DAY, VEHICLE, FLEET) |
| maxAmountMinor | max spend allowed |
| allowedRails | permitted payment rails |
| allowedAssets | permitted crypto assets |
Expand Down Expand Up @@ -212,6 +233,8 @@ decision.asset ∈ budget.allowedAssets
decision.destination ∈ destinationAllowlist
```

For scopes broader than `SESSION` (`DAY`, `VEHICLE`, `FLEET`), enforcement must apply this as a cumulative spend limit across the relevant scope window keyed by `scopeId`.

If any rule fails:

```
Expand Down Expand Up @@ -308,6 +331,8 @@ budgetId
maxAmountMinor
currency
minorUnit
budgetScope
scopeId
allowedRails
expiresAt
```
Expand Down
Loading