From 53d2e408db966cd6fb456af982f66b08549dfc99 Mon Sep 17 00:00:00 2001 From: NAOR YUVAL Date: Sun, 8 Mar 2026 20:55:13 +0200 Subject: [PATCH 1/3] feat(api): add SBA budgetScope field and enforcement Include budgetScope in signed SBA envelopes and timeline metadata, default issuance to SESSION, and reject non-session scopes during current decision verification to align behavior with the updated SBA spec. Made-with: Cursor --- apps/api/src/db/queries.ts | 2 + apps/api/src/routes/gate.ts | 1 + .../services/sessionBudgetAuthorization.ts | 8 ++++ .../test/db/queries.session-events.test.ts | 2 + .../sessionBudgetAuthorization.test.ts | 37 +++++++++++++++++++ docs/SESSION_BUDGET_AUTHORIZATION.md | 7 ++++ 6 files changed, 57 insertions(+) diff --git a/apps/api/src/db/queries.ts b/apps/api/src/db/queries.ts index b0fe409..c9c3963 100644 --- a/apps/api/src/db/queries.ts +++ b/apps/api/src/db/queries.ts @@ -636,6 +636,7 @@ function standardizeSessionMetadata( const currency = asString(payloadRecord.currency) const minorUnit = typeof payloadRecord.minorUnit === 'number' ? payloadRecord.minorUnit : undefined + const budgetScope = asString(payloadRecord.budgetScope) const allowedRails = Array.isArray(payloadRecord.allowedRails) ? payloadRecord.allowedRails : [] const expiresAt = asString(payloadRecord.expiresAt) return { @@ -643,6 +644,7 @@ function standardizeSessionMetadata( ...(maxAmountMinor ? { maxAmountMinor } : {}), ...(currency ? { currency } : {}), ...(minorUnit !== undefined ? { minorUnit } : {}), + ...(budgetScope ? { budgetScope } : {}), ...(allowedRails.length > 0 ? { allowedRails } : {}), ...(expiresAt ? { expiresAt } : {}), } diff --git a/apps/api/src/routes/gate.ts b/apps/api/src/routes/gate.ts index b35d16f..2525d20 100644 --- a/apps/api/src/routes/gate.ts +++ b/apps/api/src/routes/gate.ts @@ -559,6 +559,7 @@ gateRouter.post('/entry', async (req, res) => { maxAmountMinor: signedSba.authorization.maxAmountMinor, currency: signedSba.authorization.currency, minorUnit: signedSba.authorization.minorUnit, + budgetScope: signedSba.authorization.budgetScope, allowedRails: signedSba.authorization.allowedRails, expiresAt: signedSba.authorization.expiresAt, sessionBudgetAuthorization: signedSba, diff --git a/apps/api/src/services/sessionBudgetAuthorization.ts b/apps/api/src/services/sessionBudgetAuthorization.ts index 1caa6ea..65d5e48 100644 --- a/apps/api/src/services/sessionBudgetAuthorization.ts +++ b/apps/api/src/services/sessionBudgetAuthorization.ts @@ -1,6 +1,8 @@ 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 @@ -9,6 +11,7 @@ export interface SessionBudgetAuthorization { policyHash: string currency: string minorUnit: number + budgetScope: BudgetScope maxAmountMinor: string allowedRails: Rail[] allowedAssets: Asset[] @@ -64,6 +67,7 @@ export function createSignedSessionBudgetAuthorization(input: { policyHash: string currency: string minorUnit?: number + budgetScope?: BudgetScope maxAmountMinor: string allowedRails: Rail[] allowedAssets: Asset[] @@ -81,6 +85,7 @@ export function createSignedSessionBudgetAuthorization(input: { policyHash: input.policyHash, currency: input.currency, minorUnit: input.minorUnit ?? 2, + budgetScope: input.budgetScope ?? 'SESSION', maxAmountMinor: input.maxAmountMinor, allowedRails: input.allowedRails, allowedAssets: input.allowedAssets, @@ -128,6 +133,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' } } diff --git a/apps/api/test/db/queries.session-events.test.ts b/apps/api/test/db/queries.session-events.test.ts index f908c6c..c67d9b6 100644 --- a/apps/api/test/db/queries.session-events.test.ts +++ b/apps/api/test/db/queries.session-events.test.ts @@ -163,6 +163,7 @@ describe('db.insertPolicyEvent mirrored session timeline metadata standardizatio maxAmountMinor: '3000', currency: 'USD', minorUnit: 2, + budgetScope: 'SESSION', allowedRails: ['xrpl', 'stripe'], expiresAt: '2026-03-08T18:00:00Z', }, @@ -178,6 +179,7 @@ describe('db.insertPolicyEvent mirrored session timeline metadata standardizatio maxAmountMinor: '3000', currency: 'USD', minorUnit: 2, + budgetScope: 'SESSION', allowedRails: ['xrpl', 'stripe'], expiresAt: '2026-03-08T18:00:00Z', }, diff --git a/apps/api/test/services/sessionBudgetAuthorization.test.ts b/apps/api/test/services/sessionBudgetAuthorization.test.ts index 59d9de4..21816df 100644 --- a/apps/api/test/services/sessionBudgetAuthorization.test.ts +++ b/apps/api/test/services/sessionBudgetAuthorization.test.ts @@ -38,6 +38,7 @@ describe('sessionBudgetAuthorization service', () => { expiresAt: new Date(Date.now() + 60_000).toISOString(), }) expect(envelope).not.toBeNull() + expect(envelope!.authorization.budgetScope).toBe('SESSION') const decision = { decisionId: 'dec-1', @@ -112,4 +113,40 @@ 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', + 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' }) + }) }) diff --git a/docs/SESSION_BUDGET_AUTHORIZATION.md b/docs/SESSION_BUDGET_AUTHORIZATION.md index e79a134..8c5c95c 100644 --- a/docs/SESSION_BUDGET_AUTHORIZATION.md +++ b/docs/SESSION_BUDGET_AUTHORIZATION.md @@ -103,6 +103,7 @@ Example: "policyHash": "a93fd2...", "currency": "USD", "minorUnit": 2, + "budgetScope": "SESSION", "maxAmountMinor": "3000", "allowedRails": ["xrpl", "stripe"], "allowedAssets": [ @@ -121,6 +122,10 @@ 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`. + --- # Field Definitions @@ -134,6 +139,7 @@ Example: | 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 | @@ -308,6 +314,7 @@ budgetId maxAmountMinor currency minorUnit +budgetScope allowedRails expiresAt ``` From ccb4388fca0fdc465d757852afcf5f8ee11a8708 Mon Sep 17 00:00:00 2001 From: NAOR YUVAL Date: Sun, 8 Mar 2026 21:38:01 +0200 Subject: [PATCH 2/3] feat(api): add SBA scopeId support and metadata propagation Introduce optional scopeId in signed SBA payloads, propagate it through emitted timeline metadata, and document scope semantics for future non-session budget scopes. Made-with: Cursor --- apps/api/src/db/queries.ts | 6 +++- apps/api/src/routes/gate.ts | 1 + .../services/sessionBudgetAuthorization.ts | 3 ++ .../test/db/queries.session-events.test.ts | 32 +++++++++++++++++ apps/api/test/routes/sessions.test.ts | 36 +++++++++++++++++++ .../sessionBudgetAuthorization.test.ts | 4 +++ docs/SESSION_BUDGET_AUTHORIZATION.md | 17 +++++++++ 7 files changed, 98 insertions(+), 1 deletion(-) diff --git a/apps/api/src/db/queries.ts b/apps/api/src/db/queries.ts index c9c3963..0ec5cf3 100644 --- a/apps/api/src/db/queries.ts +++ b/apps/api/src/db/queries.ts @@ -631,12 +631,15 @@ 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) + 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 { @@ -645,6 +648,7 @@ function standardizeSessionMetadata( ...(currency ? { currency } : {}), ...(minorUnit !== undefined ? { minorUnit } : {}), ...(budgetScope ? { budgetScope } : {}), + ...(scopeId ? { scopeId } : {}), ...(allowedRails.length > 0 ? { allowedRails } : {}), ...(expiresAt ? { expiresAt } : {}), } diff --git a/apps/api/src/routes/gate.ts b/apps/api/src/routes/gate.ts index 2525d20..bba6796 100644 --- a/apps/api/src/routes/gate.ts +++ b/apps/api/src/routes/gate.ts @@ -560,6 +560,7 @@ gateRouter.post('/entry', async (req, res) => { 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, diff --git a/apps/api/src/services/sessionBudgetAuthorization.ts b/apps/api/src/services/sessionBudgetAuthorization.ts index 65d5e48..ddc51c2 100644 --- a/apps/api/src/services/sessionBudgetAuthorization.ts +++ b/apps/api/src/services/sessionBudgetAuthorization.ts @@ -8,6 +8,7 @@ export interface SessionBudgetAuthorization { budgetId: string sessionId: string vehicleId: string + scopeId?: string policyHash: string currency: string minorUnit: number @@ -64,6 +65,7 @@ function parseVerificationPublicKey(): crypto.KeyObject | null { export function createSignedSessionBudgetAuthorization(input: { sessionId: string vehicleId: string + scopeId?: string policyHash: string currency: string minorUnit?: number @@ -82,6 +84,7 @@ 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, diff --git a/apps/api/test/db/queries.session-events.test.ts b/apps/api/test/db/queries.session-events.test.ts index c67d9b6..9221f27 100644 --- a/apps/api/test/db/queries.session-events.test.ts +++ b/apps/api/test/db/queries.session-events.test.ts @@ -164,6 +164,7 @@ describe('db.insertPolicyEvent mirrored session timeline metadata standardizatio currency: 'USD', minorUnit: 2, budgetScope: 'SESSION', + scopeId: 'veh_123', allowedRails: ['xrpl', 'stripe'], expiresAt: '2026-03-08T18:00:00Z', }, @@ -180,6 +181,7 @@ describe('db.insertPolicyEvent mirrored session timeline metadata standardizatio currency: 'USD', minorUnit: 2, budgetScope: 'SESSION', + scopeId: 'veh_123', allowedRails: ['xrpl', 'stripe'], expiresAt: '2026-03-08T18:00:00Z', }, @@ -187,6 +189,36 @@ describe('db.insertPolicyEvent mirrored session timeline metadata standardizatio ) }) + 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({ diff --git a/apps/api/test/routes/sessions.test.ts b/apps/api/test/routes/sessions.test.ts index 9df49f1..04fb12b 100644 --- a/apps/api/test/routes/sessions.test.ts +++ b/apps/api/test/routes/sessions.test.ts @@ -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', + }, + }) + }) }) }) diff --git a/apps/api/test/services/sessionBudgetAuthorization.test.ts b/apps/api/test/services/sessionBudgetAuthorization.test.ts index 21816df..d24c124 100644 --- a/apps/api/test/services/sessionBudgetAuthorization.test.ts +++ b/apps/api/test/services/sessionBudgetAuthorization.test.ts @@ -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, @@ -39,6 +40,7 @@ describe('sessionBudgetAuthorization service', () => { }) expect(envelope).not.toBeNull() expect(envelope!.authorization.budgetScope).toBe('SESSION') + expect(envelope!.authorization.scopeId).toBe('veh_123') const decision = { decisionId: 'dec-1', @@ -77,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', @@ -125,6 +128,7 @@ describe('sessionBudgetAuthorization service', () => { policyHash: 'ph-1', currency: 'USD', budgetScope: 'DAY', + scopeId: 'veh_123', maxAmountMinor: '5000', allowedRails: ['stripe'], allowedAssets: [], diff --git a/docs/SESSION_BUDGET_AUTHORIZATION.md b/docs/SESSION_BUDGET_AUTHORIZATION.md index 8c5c95c..d136cf7 100644 --- a/docs/SESSION_BUDGET_AUTHORIZATION.md +++ b/docs/SESSION_BUDGET_AUTHORIZATION.md @@ -100,6 +100,7 @@ Example: "budgetId": "bud_6f92c", "sessionId": "sess_8ac21", "vehicleId": "veh_92c1", + "scopeId": "sess_8ac21", "policyHash": "a93fd2...", "currency": "USD", "minorUnit": 2, @@ -126,6 +127,18 @@ Example: 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. +Examples: `budgetScope: "VEHICLE"` with `scopeId: "veh_123"`, or `budgetScope: "FLEET"` with `scopeId: "fleet_abc"`. + --- # Field Definitions @@ -136,6 +149,7 @@ Future implementations may support broader scopes such as `DAY`, `VEHICLE`, or ` | 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) | @@ -218,6 +232,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: ``` @@ -315,6 +331,7 @@ maxAmountMinor currency minorUnit budgetScope +scopeId allowedRails expiresAt ``` From a437edebb300a5bfe43c15448b9d7ce9b5ae7912 Mon Sep 17 00:00:00 2001 From: NAOR YUVAL Date: Sun, 8 Mar 2026 21:54:33 +0200 Subject: [PATCH 3/3] docs(sba): clarify SESSION scopeId binding Make the SESSION-scope relationship explicit by stating that scopeId should equal sessionId when budgetScope is SESSION. Made-with: Cursor --- docs/SESSION_BUDGET_AUTHORIZATION.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/SESSION_BUDGET_AUTHORIZATION.md b/docs/SESSION_BUDGET_AUTHORIZATION.md index d136cf7..3b9e393 100644 --- a/docs/SESSION_BUDGET_AUTHORIZATION.md +++ b/docs/SESSION_BUDGET_AUTHORIZATION.md @@ -137,6 +137,7 @@ Future implementations may support broader scopes such as `DAY`, `VEHICLE`, or ` | 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"`. ---