diff --git a/apps/api/src/db/queries.ts b/apps/api/src/db/queries.ts index 9587498..b0fe409 100644 --- a/apps/api/src/db/queries.ts +++ b/apps/api/src/db/queries.ts @@ -630,6 +630,35 @@ function standardizeSessionMetadata( } } + if (sessionEventType === SESSION_EVENTS.SESSION_BUDGET_AUTHORIZATION_ISSUED) { + 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 allowedRails = Array.isArray(payloadRecord.allowedRails) ? payloadRecord.allowedRails : [] + const expiresAt = asString(payloadRecord.expiresAt) + return { + ...(budgetId ? { budgetId } : {}), + ...(maxAmountMinor ? { maxAmountMinor } : {}), + ...(currency ? { currency } : {}), + ...(minorUnit !== undefined ? { minorUnit } : {}), + ...(allowedRails.length > 0 ? { allowedRails } : {}), + ...(expiresAt ? { expiresAt } : {}), + } + } + + if (sessionEventType === SESSION_EVENTS.SIGNED_PAYMENT_AUTHORIZATION_ISSUED) { + const decisionId = asString(payloadRecord.decisionId) ?? input.decisionId ?? inferred.decisionId + const keyId = asString(payloadRecord.keyId) + const policyHash = asString(payloadRecord.policyHash) ?? inferred.policyHash + return { + ...(decisionId ? { decisionId } : {}), + ...(keyId ? { keyId } : {}), + ...(policyHash ? { policyHash } : {}), + } + } + if (sessionEventType === SESSION_EVENTS.SETTLEMENT_VERIFIED) { const settlement = asRecord(payloadRecord.settlement) const decisionId = asString(payloadRecord.decisionId) ?? input.decisionId ?? inferred.decisionId @@ -835,6 +864,21 @@ async function getDecisionPayloadByDecisionId( return eventRows[0]?.payload ?? null } +async function getLatestPolicyEventPayload( + sessionId: string, + eventType: string, +): Promise { + const { rows } = await pool.query( + `SELECT payload + FROM policy_events + WHERE session_id = $1 AND event_type = $2 + ORDER BY created_at DESC, id DESC + LIMIT 1`, + [sessionId, eventType], + ) + return rows[0]?.payload ?? null +} + /** Replay protection: already settled with this tx_hash? */ async function hasSettlementForTxHash(txHash: string): Promise { const { rows } = await pool.query( @@ -1361,6 +1405,7 @@ export const db = { transitionDecisionState, consumeDecisionOnce, getDecisionPayloadByDecisionId, + getLatestPolicyEventPayload, hasSettlementForTxHash, hasSettlementForDecisionRail, getMedianFeeForLot, diff --git a/apps/api/src/events/types.ts b/apps/api/src/events/types.ts index ae65c8e..6357d21 100644 --- a/apps/api/src/events/types.ts +++ b/apps/api/src/events/types.ts @@ -3,7 +3,9 @@ import { LIFECYCLE_EVENT } from '@parker/core' export const SESSION_EVENTS = { SESSION_CREATED: 'SESSION.CREATED', POLICY_GRANT_ISSUED: 'POLICY.GRANT_ISSUED', + SESSION_BUDGET_AUTHORIZATION_ISSUED: 'SESSION_BUDGET_AUTHORIZATION.ISSUED', PAYMENT_DECISION_CREATED: 'PAYMENT.DECISION_CREATED', + SIGNED_PAYMENT_AUTHORIZATION_ISSUED: 'SIGNED_PAYMENT_AUTHORIZATION.ISSUED', SETTLEMENT_VERIFIED: 'SETTLEMENT.VERIFIED', SESSION_CLOSED: 'SESSION.CLOSED', } as const @@ -13,7 +15,11 @@ export type SessionEventType = (typeof SESSION_EVENTS)[keyof typeof SESSION_EVEN const LIFECYCLE_TO_SESSION_EVENT: Partial> = { [LIFECYCLE_EVENT.SESSION_CREATED]: SESSION_EVENTS.SESSION_CREATED, [LIFECYCLE_EVENT.POLICY_GRANT_ISSUED]: SESSION_EVENTS.POLICY_GRANT_ISSUED, + [LIFECYCLE_EVENT.SESSION_BUDGET_AUTHORIZATION_ISSUED]: + SESSION_EVENTS.SESSION_BUDGET_AUTHORIZATION_ISSUED, [LIFECYCLE_EVENT.PAYMENT_DECISION_CREATED]: SESSION_EVENTS.PAYMENT_DECISION_CREATED, + [LIFECYCLE_EVENT.SIGNED_PAYMENT_AUTHORIZATION_ISSUED]: + SESSION_EVENTS.SIGNED_PAYMENT_AUTHORIZATION_ISSUED, [LIFECYCLE_EVENT.SETTLEMENT_VERIFIED]: SESSION_EVENTS.SETTLEMENT_VERIFIED, [LIFECYCLE_EVENT.SESSION_CLOSED]: SESSION_EVENTS.SESSION_CLOSED, } diff --git a/apps/api/src/routes/gate.ts b/apps/api/src/routes/gate.ts index 7d56864..b35d16f 100644 --- a/apps/api/src/routes/gate.ts +++ b/apps/api/src/routes/gate.ts @@ -48,6 +48,11 @@ import { buildEntryPolicyStack } from '../services/policyStack' import { enforceOrReject, evaluateExitPolicy, buildAssetsOffered } from '../services/policy' import { sessionLifecycleService } from '../services/sessionLifecycle' import { createSignedPaymentAuthorization } from '../services/paymentAuthorization' +import { + createSignedSessionBudgetAuthorization, + verifySignedSessionBudgetAuthorizationForDecision, + type SignedSessionBudgetAuthorization, +} from '../services/sessionBudgetAuthorization' export const gateRouter = Router() @@ -104,6 +109,17 @@ const LOT_ID_REGEX = /^[A-Za-z0-9_-]{1,64}$/ const XRPL_PAYLOAD_UUID_REGEX = /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-5][0-9a-fA-F]{3}-[89aAbB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}$/ +function hasSignedSbaEnvelope(value: unknown): value is SignedSessionBudgetAuthorization { + if (!value || typeof value !== 'object') return false + const v = value as Record + return ( + typeof v.signature === 'string' && + typeof v.keyId === 'string' && + typeof v.authorization === 'object' && + v.authorization !== null + ) +} + /** * Resolve a plate number from either the provided string or an image via ALPR. * Returns the plate string or null if nothing could be resolved. @@ -473,6 +489,7 @@ gateRouter.post('/entry', async (req, res) => { // Create session in DB (includes Hedera serial if minted) let session + let sessionBudgetAuthorization: SignedSessionBudgetAuthorization | undefined try { session = await sessionLifecycleService.activateSession({ plateNumber: plate, @@ -514,6 +531,42 @@ gateRouter.post('/entry', async (req, res) => { }, sessionId: session.id, }) + + const maxAmountMinor = + grant.maxSpend?.perSessionMinor ?? + grant.maxSpend?.perTxMinor ?? + grant.maxSpend?.perDayMinor + if (typeof maxAmountMinor === 'string' && maxAmountMinor.length > 0) { + const destinationAllowlist = lot.operatorWallet ? [lot.operatorWallet] : [] + const signedSba = createSignedSessionBudgetAuthorization({ + sessionId: session.id, + vehicleId: plate, + policyHash: grant.policyHash, + currency: lot.currency || 'USD', + minorUnit: 2, + maxAmountMinor, + allowedRails: grant.allowedRails as Rail[], + allowedAssets: grant.allowedAssets as Asset[], + destinationAllowlist, + expiresAt: grant.expiresAtISO, + }) + if (signedSba) { + sessionBudgetAuthorization = signedSba + await db.insertPolicyEvent({ + eventType: LIFECYCLE_EVENT.SESSION_BUDGET_AUTHORIZATION_ISSUED, + payload: { + budgetId: signedSba.authorization.budgetId, + maxAmountMinor: signedSba.authorization.maxAmountMinor, + currency: signedSba.authorization.currency, + minorUnit: signedSba.authorization.minorUnit, + allowedRails: signedSba.authorization.allowedRails, + expiresAt: signedSba.authorization.expiresAt, + sessionBudgetAuthorization: signedSba, + }, + sessionId: session.id, + }) + } + } } catch (dbErr) { // DB write failed but NFT was minted — session exists on-chain and can be recovered. // Log a warning and still open the gate (the NFT is the proof of entry). @@ -544,7 +597,11 @@ gateRouter.post('/entry', async (req, res) => { }) // reply() calls completeIdempotency(responseBody), so retries return this same body (session.policyGrantId, policyHash, approvalRequiredBeforePayment) - return reply(201, { session, ...(alprResult && { alpr: alprResult }) }) + return reply(201, { + session, + ...(sessionBudgetAuthorization && { sessionBudgetAuthorization }), + ...(alprResult && { alpr: alprResult }), + }) } catch (error) { console.error('Gate entry failed:', error) return reply(500, { error: 'Gate entry failed' }) @@ -844,6 +901,30 @@ gateRouter.post('/exit', async (req, res) => { })() : undefined, } + + if (session?.id) { + const sbaPayload = await db.getLatestPolicyEventPayload( + session.id, + LIFECYCLE_EVENT.SESSION_BUDGET_AUTHORIZATION_ISSUED, + ) + const envelopeCandidate = + (sbaPayload as { sessionBudgetAuthorization?: unknown } | null) + ?.sessionBudgetAuthorization ?? sbaPayload + if (hasSignedSbaEnvelope(envelopeCandidate)) { + const sbaCheck = verifySignedSessionBudgetAuthorizationForDecision(envelopeCandidate, { + sessionId: session.id, + decision: decisionToPersist, + }) + if (!sbaCheck.ok) { + paymentFailuresTotal.inc({ reason: `session_budget_authorization_${sbaCheck.reason}` }) + return reply(403, { + error: 'Payment denied by session budget authorization', + reason: sbaCheck.reason, + }) + } + } + } + const paymentAuthorization = createSignedPaymentAuthorization(sessionId, decisionToPersist) const decisionPayloadForStorage = paymentAuthorization ? ({ @@ -873,6 +954,18 @@ gateRouter.post('/exit', async (req, res) => { sessionId, decisionId: finalDecision.decisionId, }) + if (paymentAuthorization) { + await db.insertPolicyEvent({ + eventType: LIFECYCLE_EVENT.SIGNED_PAYMENT_AUTHORIZATION_ISSUED, + payload: { + decisionId: finalDecision.decisionId, + policyHash: finalDecision.policyHash, + keyId: paymentAuthorization.keyId, + }, + sessionId, + decisionId: finalDecision.decisionId, + }) + } if (finalDecision.action === 'REQUIRE_APPROVAL') { await db.insertPolicyEvent({ eventType: LIFECYCLE_EVENT.PAYMENT_APPROVAL_REQUIRED, diff --git a/apps/api/src/services/sessionBudgetAuthorization.ts b/apps/api/src/services/sessionBudgetAuthorization.ts new file mode 100644 index 0000000..1caa6ea --- /dev/null +++ b/apps/api/src/services/sessionBudgetAuthorization.ts @@ -0,0 +1,161 @@ +import crypto, { createHash, randomUUID } from 'node:crypto' +import type { Asset, PaymentPolicyDecision, Rail } from '@parker/policy-core' + +export interface SessionBudgetAuthorization { + version: 1 + budgetId: string + sessionId: string + vehicleId: string + policyHash: string + currency: string + minorUnit: number + maxAmountMinor: string + allowedRails: Rail[] + allowedAssets: Asset[] + destinationAllowlist: string[] + expiresAt: string +} + +export interface SignedSessionBudgetAuthorization { + authorization: SessionBudgetAuthorization + signature: string + keyId: string +} + +function canonicalJson(value: unknown): string { + if (value === null || typeof value !== 'object') return JSON.stringify(value) + if (Array.isArray(value)) return `[${value.map((v) => canonicalJson(v)).join(',')}]` + const obj = value as Record + const keys = Object.keys(obj).sort() + return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(obj[k])}`).join(',')}}` +} + +function hashAuthorization(authorization: SessionBudgetAuthorization): Buffer { + return createHash('sha256').update(canonicalJson(authorization)).digest() +} + +function getExpectedKeyId(): string { + return process.env.PARKER_SBA_SIGNING_KEY_ID || 'parker-budget-signing-key-1' +} + +function parseSigningPrivateKey(): crypto.KeyObject | null { + const pem = process.env.PARKER_SBA_SIGNING_PRIVATE_KEY_PEM + if (!pem) return null + try { + return crypto.createPrivateKey(pem) + } catch { + return null + } +} + +function parseVerificationPublicKey(): crypto.KeyObject | null { + const pem = process.env.PARKER_SBA_SIGNING_PUBLIC_KEY_PEM + if (!pem) return null + try { + return crypto.createPublicKey(pem) + } catch { + return null + } +} + +export function createSignedSessionBudgetAuthorization(input: { + sessionId: string + vehicleId: string + policyHash: string + currency: string + minorUnit?: number + maxAmountMinor: string + allowedRails: Rail[] + allowedAssets: Asset[] + destinationAllowlist: string[] + expiresAt: string +}): SignedSessionBudgetAuthorization | null { + const privateKey = parseSigningPrivateKey() + if (!privateKey) return null + + const authorization: SessionBudgetAuthorization = { + version: 1, + budgetId: randomUUID(), + sessionId: input.sessionId, + vehicleId: input.vehicleId, + policyHash: input.policyHash, + currency: input.currency, + minorUnit: input.minorUnit ?? 2, + maxAmountMinor: input.maxAmountMinor, + allowedRails: input.allowedRails, + allowedAssets: input.allowedAssets, + destinationAllowlist: input.destinationAllowlist, + expiresAt: input.expiresAt, + } + + const signature = crypto.sign(null, hashAuthorization(authorization), privateKey).toString('base64') + return { + authorization, + signature, + keyId: getExpectedKeyId(), + } +} + +function assetMatches(a: Asset, b: Asset): boolean { + return canonicalJson(a) === canonicalJson(b) +} + +export function verifySignedSessionBudgetAuthorizationForDecision( + envelope: SignedSessionBudgetAuthorization, + input: { sessionId: string; decision: PaymentPolicyDecision; nowMs?: number }, +): { ok: true } | { ok: false; reason: 'invalid_signature' | 'expired' | 'mismatch' } { + if (envelope.keyId !== getExpectedKeyId()) { + return { ok: false, reason: 'invalid_signature' } + } + const publicKey = parseVerificationPublicKey() + if (!publicKey) return { ok: false, reason: 'invalid_signature' } + + const isValid = crypto.verify( + null, + hashAuthorization(envelope.authorization), + publicKey, + Buffer.from(envelope.signature, 'base64'), + ) + if (!isValid) return { ok: false, reason: 'invalid_signature' } + + const nowMs = typeof input.nowMs === 'number' ? input.nowMs : Date.now() + if (Date.parse(envelope.authorization.expiresAt) <= nowMs) { + return { ok: false, reason: 'expired' } + } + + const { authorization } = envelope + const { decision } = input + if (authorization.sessionId !== input.sessionId || authorization.policyHash !== decision.policyHash) { + return { ok: false, reason: 'mismatch' } + } + if (decision.rail && !authorization.allowedRails.includes(decision.rail)) { + return { ok: false, reason: 'mismatch' } + } + if ( + decision.asset && + authorization.allowedAssets.length > 0 && + !authorization.allowedAssets.some((allowedAsset) => assetMatches(allowedAsset, decision.asset!)) + ) { + return { ok: false, reason: 'mismatch' } + } + + if (decision.priceFiat?.amountMinor) { + const budgetMinor = BigInt(authorization.maxAmountMinor) + const decisionMinor = BigInt(decision.priceFiat.amountMinor) + if (decisionMinor > budgetMinor) { + return { ok: false, reason: 'mismatch' } + } + } + + const quoteId = decision.chosen?.quoteId + const chosenQuote = quoteId + ? decision.settlementQuotes?.find((q) => q.quoteId === quoteId) + : decision.settlementQuotes?.find((q) => q.rail === decision.rail) + if (chosenQuote && authorization.destinationAllowlist.length > 0) { + if (!authorization.destinationAllowlist.includes(chosenQuote.destination)) { + return { ok: false, reason: 'mismatch' } + } + } + + return { ok: true } +} diff --git a/apps/api/test/db/queries.session-events.test.ts b/apps/api/test/db/queries.session-events.test.ts index 024f426..f908c6c 100644 --- a/apps/api/test/db/queries.session-events.test.ts +++ b/apps/api/test/db/queries.session-events.test.ts @@ -153,6 +153,38 @@ describe('db.insertPolicyEvent mirrored session timeline metadata standardizatio expect(emitSessionEvent).not.toHaveBeenCalled() }) + it('standardizes session budget authorization metadata including minorUnit', async () => { + const sessionId = '66666666-6666-4666-8666-666666666666' + await db.insertPolicyEvent({ + eventType: LIFECYCLE_EVENT.SESSION_BUDGET_AUTHORIZATION_ISSUED, + sessionId, + payload: { + budgetId: 'bud-1', + maxAmountMinor: '3000', + currency: 'USD', + minorUnit: 2, + allowedRails: ['xrpl', 'stripe'], + expiresAt: '2026-03-08T18:00:00Z', + }, + }) + + expect(emitSessionEvent).toHaveBeenCalledWith( + expect.objectContaining({ query: expect.any(Function) }), + expect.objectContaining({ + sessionId, + eventType: SESSION_EVENTS.SESSION_BUDGET_AUTHORIZATION_ISSUED, + metadata: { + budgetId: 'bud-1', + maxAmountMinor: '3000', + currency: 'USD', + minorUnit: 2, + allowedRails: ['xrpl', 'stripe'], + expiresAt: '2026-03-08T18:00:00Z', + }, + }), + ) + }) + 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/gate.test.ts b/apps/api/test/routes/gate.test.ts index b10b1e1..6859288 100644 --- a/apps/api/test/routes/gate.test.ts +++ b/apps/api/test/routes/gate.test.ts @@ -28,6 +28,7 @@ vi.mock('../../src/db', () => ({ insertPolicyEvent: vi.fn(), insertPolicyDecision: vi.fn(), getDecisionPayloadByDecisionId: vi.fn(), + getLatestPolicyEventPayload: vi.fn(), getMedianFeeForLot: vi.fn(), getPolicyGrantByGrantId: vi.fn(), consumeDecisionOnce: vi.fn(), @@ -165,6 +166,7 @@ describe('gate routes', () => { vi.mocked(db.updateSessionPolicyGrant).mockResolvedValue(undefined) vi.mocked(db.insertPolicyEvent).mockResolvedValue(undefined) vi.mocked(db.getDecisionPayloadByDecisionId).mockResolvedValue(null) + vi.mocked(db.getLatestPolicyEventPayload).mockResolvedValue(null) vi.mocked(db.getMedianFeeForLot).mockResolvedValue(null) vi.mocked(db.getPolicyGrantByGrantId).mockResolvedValue(null) vi.mocked(db.transitionSession).mockImplementation(async (session: any, input: any) => ({ diff --git a/apps/api/test/services/sessionBudgetAuthorization.test.ts b/apps/api/test/services/sessionBudgetAuthorization.test.ts new file mode 100644 index 0000000..59d9de4 --- /dev/null +++ b/apps/api/test/services/sessionBudgetAuthorization.test.ts @@ -0,0 +1,115 @@ +import crypto from 'node:crypto' +import { afterEach, describe, expect, it } from 'vitest' +import type { PaymentPolicyDecision } from '@parker/policy-core' +import { + createSignedSessionBudgetAuthorization, + verifySignedSessionBudgetAuthorizationForDecision, +} from '../../src/services/sessionBudgetAuthorization' + +const ORIGINAL_ENV = { + privateKey: process.env.PARKER_SBA_SIGNING_PRIVATE_KEY_PEM, + publicKey: process.env.PARKER_SBA_SIGNING_PUBLIC_KEY_PEM, + keyId: process.env.PARKER_SBA_SIGNING_KEY_ID, +} + +afterEach(() => { + process.env.PARKER_SBA_SIGNING_PRIVATE_KEY_PEM = ORIGINAL_ENV.privateKey + process.env.PARKER_SBA_SIGNING_PUBLIC_KEY_PEM = ORIGINAL_ENV.publicKey + process.env.PARKER_SBA_SIGNING_KEY_ID = ORIGINAL_ENV.keyId +}) + +describe('sessionBudgetAuthorization service', () => { + it('creates and verifies signed SBA envelope for a matching decision', () => { + 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() + process.env.PARKER_SBA_SIGNING_KEY_ID = 'budget-key-1' + + const envelope = createSignedSessionBudgetAuthorization({ + sessionId: '11111111-1111-4111-8111-111111111111', + vehicleId: '1234567', + policyHash: 'ph-1', + currency: 'USD', + minorUnit: 2, + maxAmountMinor: '3000', + allowedRails: ['xrpl'], + allowedAssets: [{ kind: 'IOU', currency: 'RLUSD', issuer: 'rIssuer' }], + destinationAllowlist: ['rDestination'], + 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: 'xrpl', + asset: { kind: 'IOU', currency: 'RLUSD', issuer: 'rIssuer' }, + priceFiat: { amountMinor: '2500', currency: 'USD' }, + chosen: { rail: 'xrpl', quoteId: 'q1' }, + settlementQuotes: [ + { + quoteId: 'q1', + rail: 'xrpl', + amount: { amount: '19440000', decimals: 6 }, + destination: 'rDestination', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + asset: { kind: 'IOU', currency: 'RLUSD', issuer: 'rIssuer' }, + }, + ], + } as unknown as PaymentPolicyDecision + + const verification = verifySignedSessionBudgetAuthorizationForDecision(envelope!, { + sessionId: '11111111-1111-4111-8111-111111111111', + decision, + }) + expect(verification).toEqual({ ok: true }) + }) + + it('rejects decision that exceeds budget amount', () => { + 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', + maxAmountMinor: '1000', + 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: '1200', currency: 'USD' }, + chosen: { rail: 'stripe', quoteId: 'q1' }, + settlementQuotes: [ + { + quoteId: 'q1', + rail: 'stripe', + amount: { amount: '1200', decimals: 2 }, + destination: '', + expiresAt: new Date(Date.now() + 60_000).toISOString(), + }, + ], + } 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 new file mode 100644 index 0000000..e79a134 --- /dev/null +++ b/docs/SESSION_BUDGET_AUTHORIZATION.md @@ -0,0 +1,371 @@ +# Session Budget Authorization (SBA) + +## Purpose + +A **Session Budget Authorization (SBA)** is a signed policy artifact issued at **session entry** that defines the maximum allowed spending envelope for the session. + +It provides deterministic guarantees for autonomous agents (vehicles, fleet systems, wallets) that a parking session can later be paid within a predefined budget and rule set. + +SBA complements the existing Parker payment architecture: + +``` +Entry → Policy Grant → Session Budget Authorization +Exit → Payment Decision → Signed Payment Authorization +Settlement → Enforcement → Session Close +``` + +The SBA ensures that all later payment decisions remain **bounded by a pre-authorized budget envelope**. + +--- + +# Problem Statement + +In an autonomous payment environment (autonomous vehicles, fleet systems, robot delivery platforms), relying only on **exit-time payment decisions** introduces uncertainty: + +- vehicles may reach exit without guaranteed payment approval +- fleet policies may be violated unexpectedly +- payment authorization could fail due to policy mismatch +- operators cannot guarantee deterministic execution + +Fleet operators need guarantees that a session **cannot exceed an approved spending envelope**. + +The Session Budget Authorization solves this by defining: + +- maximum spend +- allowed rails +- allowed assets +- allowed payment destinations +- expiry window + +and cryptographically signing the budget envelope. + +--- + +# Design Goals + +SBA must: + +• Bind a spending envelope to a **specific session** +• Bind the envelope to a **specific policy snapshot** +• Allow autonomous agents to verify the budget offline +• Ensure all payment decisions remain within the authorized envelope +• Support multiple payment rails (XRPL, EVM, Stripe) + +--- + +# Non-Goals + +SBA does **not**: + +• Reserve or escrow funds +• Guarantee wallet balances +• Replace settlement verification +• Replace Signed Payment Authorization + +SBA is **a policy authorization artifact**, not a financial hold. + +--- + +# Architecture Placement + +The Parker lifecycle becomes: + +``` +SESSION.CREATED + ↓ +POLICY.GRANT_ISSUED + ↓ +SESSION_BUDGET_AUTHORIZATION.ISSUED + ↓ +PAYMENT.DECISION_CREATED + ↓ +SIGNED_PAYMENT_AUTHORIZATION.ISSUED + ↓ +SETTLEMENT.VERIFIED + ↓ +SESSION.CLOSED +``` + +SBA therefore represents the **maximum allowed envelope** while SPA represents the **specific payment instruction**. + +--- + +# Authorization Object Schema + +Example: + +```json +{ + "version": 1, + "budgetId": "bud_6f92c", + "sessionId": "sess_8ac21", + "vehicleId": "veh_92c1", + "policyHash": "a93fd2...", + "currency": "USD", + "minorUnit": 2, + "maxAmountMinor": "3000", + "allowedRails": ["xrpl", "stripe"], + "allowedAssets": [ + { + "kind": "IOU", + "currency": "RLUSD", + "issuer": "rIssuer..." + } + ], + "destinationAllowlist": [ + "rDestination..." + ], + "expiresAt": "2026-03-08T18:00:00Z" +} +``` + +`allowedAssets` is only enforced for rails that require an on-chain asset (`xrpl`, `evm`). It is ignored for hosted payment rails such as `stripe`. + +--- + +# Field Definitions + +| Field | Description | +|------|-------------| +| version | SBA schema version | +| budgetId | unique identifier | +| sessionId | Parker session | +| vehicleId | vehicle identifier | +| policyHash | hash of policy snapshot | +| currency | fiat currency reference | +| minorUnit | decimal precision of the currency (e.g., USD = 2) | +| maxAmountMinor | max spend allowed | +| allowedRails | permitted payment rails | +| allowedAssets | permitted crypto assets | +| destinationAllowlist | allowed payment destinations | +| expiresAt | expiration timestamp | + +--- + +# Signature Envelope + +SBA is distributed as a signed artifact: + +``` +{ + "authorization": { ...SBA object... }, + "signature": "base64(signature)", + "keyId": "parker-budget-signing-key-1" +} +``` + +--- + +# Signing Model + +Recommended signature algorithm: + +``` +Ed25519 +``` + +Signing process: + +``` +canonical_json(authorization) + ↓ +SHA256 + ↓ +Ed25519 Sign +``` + +The signature must cover **only the authorization object**. + +--- + +# Trust Model + +Clients trust the Parker signing key. + +Verification steps: + +``` +1 verify signature +2 verify expiration +3 verify policyHash +4 verify sessionId +5 enforce payment limits +``` + +The Parker backend later enforces settlement against both: + +``` +Signed Payment Authorization +Session Budget Authorization +``` + +--- + +# Decision Constraint Rules + +Any payment decision must satisfy: + +``` +decision.amount ≤ budget.maxAmountMinor +decision.rail ∈ budget.allowedRails +decision.asset ∈ budget.allowedAssets +decision.destination ∈ destinationAllowlist +``` + +If any rule fails: + +``` +PAYMENT_DECISION_DENIED +``` + +--- + +# Settlement Constraint Rules + +Settlement enforcement must confirm: + +``` +settlement.amount ≤ budget.maxAmountMinor +settlement.destination ∈ destinationAllowlist +settlement.asset ∈ allowedAssets +``` + +Additionally: + +``` +SPA constraints must also pass +``` + +--- + +# Replay Protection + +Replay prevention relies on: + +• session lifecycle enforcement +• settlement replay detection +• budget expiration window + +Budget reuse across sessions is not permitted. + +--- + +# Storage + +SBA may optionally be stored in: + +``` +policy_decisions +sessions +``` + +However the artifact can be reconstructed using: + +``` +budgetId +policyHash +sessionId +``` + +Storing the envelope is recommended for debugging. + +--- + +# API Integration + +Entry API may return: + +``` +POST /api/sessions/entry +``` + +Response: + +``` +{ + session, + policyGrant, + sessionBudgetAuthorization +} +``` + +Exit decisions must verify the budget before issuing a SPA. + +--- + +# Timeline Event + +When issued, emit: + +``` +SESSION_BUDGET_AUTHORIZATION.ISSUED +``` + +Metadata: + +``` +budgetId +maxAmountMinor +currency +minorUnit +allowedRails +expiresAt +``` + +--- + +# XRPL Example + +XRPL payment must satisfy: + +``` +Destination == destinationAllowlist +Amount ≤ maxAmountMinor +Currency/Issuer ∈ allowedAssets +``` + +The SPA generated later must also match the XRPL payment exactly. + +--- + +# Security Model + +SBA provides: + +• deterministic payment envelope +• protection against price manipulation +• fleet spending controls +• autonomous wallet verification + +However it does **not** guarantee wallet solvency. + +Wallet solvency must be validated by the payer wallet itself. + +--- + +# Future Extensions + +Possible evolutions: + +• multi-session fleet budgets +• daily vehicle budgets +• DID-based identity binding +• multi-operator destination lists +• escrow integration +• prepaid parking models + +--- + +# Summary + +Session Budget Authorization establishes a **pre-authorized spending envelope** for a parking session. + +Together with: + +``` +Policy Grant +Signed Payment Authorization +Settlement Enforcement +``` + +it creates a **complete, deterministic payment lifecycle suitable for autonomous systems**. \ No newline at end of file diff --git a/docs/STATE_MACHINE_SPRINT.md b/docs/STATE_MACHINE_SPRINT.md index 21a987e..33ce935 100644 --- a/docs/STATE_MACHINE_SPRINT.md +++ b/docs/STATE_MACHINE_SPRINT.md @@ -228,6 +228,15 @@ policyHash This makes debugging production issues dramatically easier. +9.1 Lifecycle Event Update (SBA + SPA) + +To reflect budget-first authorization and then decision-specific authorization, the lifecycle event chain is: + +POLICY.GRANT_ISSUED +SESSION_BUDGET_AUTHORIZATION.ISSUED +PAYMENT.DECISION_CREATED +SIGNED_PAYMENT_AUTHORIZATION.ISSUED + 10. Definition of Done Sprint A is complete when: diff --git a/packages/core/src/lifecycle-events.ts b/packages/core/src/lifecycle-events.ts index 8bef56b..79962cd 100644 --- a/packages/core/src/lifecycle-events.ts +++ b/packages/core/src/lifecycle-events.ts @@ -2,8 +2,10 @@ export const LIFECYCLE_EVENT = { SESSION_CREATED: 'SESSION_CREATED', SESSION_DENIED: 'SESSION_DENIED', POLICY_GRANT_ISSUED: 'POLICY_GRANT_ISSUED', + SESSION_BUDGET_AUTHORIZATION_ISSUED: 'SESSION_BUDGET_AUTHORIZATION.ISSUED', POLICY_GRANT_DENIED: 'POLICY_GRANT_DENIED', PAYMENT_DECISION_CREATED: 'PAYMENT_DECISION_CREATED', + SIGNED_PAYMENT_AUTHORIZATION_ISSUED: 'SIGNED_PAYMENT_AUTHORIZATION.ISSUED', PAYMENT_DECISION_EXPIRED: 'PAYMENT_DECISION_EXPIRED', PAYMENT_APPROVAL_REQUIRED: 'PAYMENT_APPROVAL_REQUIRED', PAYMENT_APPROVED: 'PAYMENT_APPROVED',