Skip to content
Closed
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
5 changes: 5 additions & 0 deletions .changeset/veria-226-session-verify-captured-request.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Required captured request context for standalone Tempo session verification.
94 changes: 82 additions & 12 deletions src/server/Mppx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4322,6 +4322,17 @@ describe('verifyCredential', () => {
recipient: '0x0000000000000000000000000000000000000002',
}

const sessionRouteRequest = { amount: '1', unitType: 'request' } as const

function capturedRequest(url: string, method = 'GET'): Method.CapturedRequest {
return {
headers: new Headers(),
hasBody: false,
method,
url: new URL(url),
}
}

test('verifies a serialized credential string (charge)', async () => {
verifyArgs = undefined
const mppx = Mppx.create({
Expand Down Expand Up @@ -4899,7 +4910,13 @@ describe('verifyCredential', () => {
const openCredential = Credential.deserialize(serializedOpenCredential)
expect(openCredential.payload).toMatchObject({ action: 'open' })

const openReceipt = await server.verifyCredential(serializedOpenCredential)
// POST is treated as session management, not a billable content request.
const managementRequest = capturedRequest(httpServer.url, 'POST')

const openReceipt = await server.verifyCredential(serializedOpenCredential, {
capturedRequest: managementRequest,
request: sessionRouteRequest,
})

expect(openReceipt.status).toBe('success')
expect(openReceipt.method).toBe('tempo')
Expand All @@ -4911,7 +4928,10 @@ describe('verifyCredential', () => {
const voucherCredential = Credential.deserialize(serializedVoucherCredential)
expect(voucherCredential.payload).toMatchObject({ action: 'voucher' })

const voucherReceipt = await server.verifyCredential(serializedVoucherCredential)
const voucherReceipt = await server.verifyCredential(serializedVoucherCredential, {
capturedRequest: managementRequest,
request: sessionRouteRequest,
})

expect(voucherReceipt.status).toBe('success')
expect(voucherReceipt.method).toBe('tempo')
Expand Down Expand Up @@ -4955,7 +4975,10 @@ describe('verifyCredential', () => {
const serializedOpenCredential = await clientMppx.createCredential(
openChallengeResponse.challenge,
)
await server.verifyCredential(serializedOpenCredential)
await server.verifyCredential(serializedOpenCredential, {
capturedRequest: capturedRequest('https://example.com/session', 'POST'),
request: sessionRouteRequest,
})

const voucherChallengeResponse = await route(new Request('https://example.com/session'))
expect(voucherChallengeResponse.status).toBe(402)
Expand All @@ -4964,21 +4987,17 @@ describe('verifyCredential', () => {
const serializedVoucherCredential = await clientMppx.createCredential(
voucherChallengeResponse.challenge,
)
const contentRequest = {
headers: new Headers(),
hasBody: false,
method: 'GET',
url: new URL('https://example.com/session'),
} as const
const routeRequest = { amount: '1', unitType: 'request' } as const
// GET is treated as a billable content request, so repeated voucher
// verification must consume additional session units.
const contentRequest = capturedRequest('https://example.com/session')

const firstReceipt = (await server.verifyCredential(serializedVoucherCredential, {
capturedRequest: contentRequest,
request: routeRequest,
request: sessionRouteRequest,
})) as SessionReceipt
const secondReceipt = (await server.verifyCredential(serializedVoucherCredential, {
capturedRequest: contentRequest,
request: routeRequest,
request: sessionRouteRequest,
})) as SessionReceipt

expect(BigInt(firstReceipt.spent)).toBeGreaterThan(0n)
Expand Down Expand Up @@ -5074,4 +5093,55 @@ describe('verifyCredential', () => {

expect(receipt.status).toBe('success')
})

test.each(['open', 'voucher'] as const)(
'standalone tempo session verification requires capturedRequest for %s credentials',
async (action) => {
const tempoSession = Method.from({
name: 'tempo',
intent: 'session',
schema: {
credential: {
payload: z.discriminatedUnion('action', [
z.object({ action: z.literal('open'), token: z.string() }),
z.object({ action: z.literal('voucher'), token: z.string() }),
z.object({ action: z.literal('topUp'), token: z.string() }),
z.object({ action: z.literal('close'), token: z.string() }),
]),
},
request: z.object({
amount: z.string(),
currency: z.string(),
recipient: z.string(),
unitType: z.string(),
}),
},
})
let verifyCalled = false
const server = Mppx.create({
methods: [
Method.toServer(tempoSession, {
async verify() {
verifyCalled = true
return mockReceipt('tempo-session')
},
}),
],
realm,
secretKey,
})
const challenge = await server.challenge.tempo.session({
amount: '1',
currency: 'USD',
recipient: 'merchant',
unitType: 'request',
})
const credential = Credential.serialize(
Credential.from({ challenge, payload: { action, token: 'valid' } }),
)

await expect(server.verifyCredential(credential)).rejects.toThrow('requires capturedRequest')
expect(verifyCalled).toBe(false)
},
)
})
31 changes: 31 additions & 0 deletions src/server/Mppx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -255,6 +255,7 @@ export type Mppx<
* const receipt = await mppx.verifyCredential('eyJjaGFsbGVuZ2...')
* const receipt = await mppx.verifyCredential(credential)
* const receipt = await mppx.verifyCredential(credential, { request: { amount: '1000' } })
* const receipt = await mppx.verifyCredential(credential, { capturedRequest: request })
* ```
*/
verifyCredential(
Expand Down Expand Up @@ -563,6 +564,27 @@ export function create<
throw e
}

// tempo.session bills open/voucher credentials only when the submitted
// request is a content request. Standalone verification has no transport
// input, so callers must provide the captured request snapshot explicitly.
if (
options?.capturedRequest === undefined &&
requiresCapturedRequestForStandaloneVerify(parsedCredential)
) {
const error = new Errors.BadRequestError({
reason:
'standalone tempo.session verification requires capturedRequest for open and voucher credentials',
})
await emitStandalonePaymentFailed({
challenge: credential.challenge,
credential: parsedCredential,
error,
request: credential.challenge.request as Record<string, unknown>,
submittedChallenge: credential.challenge,
})
throw error
}

const expectedMeta = Scope.merge({ meta: options?.meta, scope: options?.scope })

if (options?.scope !== undefined && Scope.read(credential.challenge.meta) !== options.scope) {
Expand Down Expand Up @@ -1943,6 +1965,15 @@ function hydrateCredentialMeta<payload>(
}
}

function requiresCapturedRequestForStandaloneVerify(credential: Credential.Credential): boolean {
const action = (credential.payload as { action?: unknown }).action
return (
credential.challenge.method === 'tempo' &&
credential.challenge.intent === 'session' &&
(action === 'open' || action === 'voucher')
)
}

function withParsedCredentialPayload<payload>(
credential: Credential.Credential,
payload: payload,
Expand Down
Loading