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
45 changes: 45 additions & 0 deletions apps/api/src/db/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -835,6 +864,21 @@ async function getDecisionPayloadByDecisionId(
return eventRows[0]?.payload ?? null
}

async function getLatestPolicyEventPayload(
sessionId: string,
eventType: string,
): Promise<unknown | null> {
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<boolean> {
const { rows } = await pool.query(
Expand Down Expand Up @@ -1361,6 +1405,7 @@ export const db = {
transitionDecisionState,
consumeDecisionOnce,
getDecisionPayloadByDecisionId,
getLatestPolicyEventPayload,
hasSettlementForTxHash,
hasSettlementForDecisionRail,
getMedianFeeForLot,
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/events/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -13,7 +15,11 @@ export type SessionEventType = (typeof SESSION_EVENTS)[keyof typeof SESSION_EVEN
const LIFECYCLE_TO_SESSION_EVENT: Partial<Record<string, SessionEventType>> = {
[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,
}
Expand Down
95 changes: 94 additions & 1 deletion apps/api/src/routes/gate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()

Expand Down Expand Up @@ -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<string, unknown>
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.
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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' })
Expand Down Expand Up @@ -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
? ({
Expand Down Expand Up @@ -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,
Expand Down
161 changes: 161 additions & 0 deletions apps/api/src/services/sessionBudgetAuthorization.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>
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 }
}
Loading
Loading