Skip to content
Open
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/validate-settle-credentials.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added pure credential validation and explicit credential settlement APIs.
39 changes: 39 additions & 0 deletions src/Method.ts
Original file line number Diff line number Diff line change
Expand Up @@ -117,12 +117,33 @@ export type VerifyContext<method extends Method> = {
request: z.input<method['schema']['request']>
}

/** Validation hook parameters for a single method. */
export type ValidateContext<method extends Method> = VerifyContext<method>

/** Response hook parameters for a single method. */
export type RespondContext<method extends Method> = VerifyContext<method> & {
input: globalThis.Request
receipt: Receipt.Receipt
}

/** Non-mutating method-specific validation result. */
export type Validation<method extends Method = Method, details = unknown> = Readonly<{
challenge: Challenge.Challenge<
z.output<method['schema']['request']>,
method['intent'],
method['name']
>
credential: Credential.Credential<
z.output<method['schema']['credential']['payload']>,
Challenge.Challenge<z.output<method['schema']['request']>, method['intent'], method['name']>
>
details: details
intent: method['intent']
method: method['name']
request: z.output<method['schema']['request']>
source?: string | undefined
}>

/**
* A server-side configured method with verification logic.
*/
Expand All @@ -141,8 +162,10 @@ export type Server<
preflight?: PreflightFn<method> | undefined
request?: RequestFn<method> | undefined
respond?: RespondFn<method> | undefined
settle?: SettleFn<method> | undefined
stableBinding?: StableBindingFn<method> | undefined
transport?: transportOverride | undefined
validate?: ValidateFn<method> | undefined
verify: VerifyFn<method>
}
export type AnyServer = Server<any, any, any, any, any>
Expand Down Expand Up @@ -226,6 +249,16 @@ export type VerifyFn<method extends Method> = (
parameters: VerifyContext<method>,
) => Promise<Receipt.Receipt>

/** Non-mutating validation function for a single method. */
export type ValidateFn<method extends Method> = (
parameters: ValidateContext<method>,
) => Promise<Validation<method>>

/** Mutating settlement function for a single method. */
export type SettleFn<method extends Method> = (
parameters: VerifyContext<method>,
) => Promise<Receipt.Receipt>

/**
* Optional respond function for a server-side method.
*
Expand Down Expand Up @@ -336,8 +369,10 @@ export function toServer<
preflight,
request,
respond,
settle,
stableBinding,
transport,
validate,
verify,
} = options
return {
Expand All @@ -350,8 +385,10 @@ export function toServer<
preflight,
request,
respond,
settle,
stableBinding,
transport,
validate,
verify,
} as Server<method, defaults, transportOverride, extensions, toServer.Alias<options>>
}
Expand All @@ -374,8 +411,10 @@ export declare namespace toServer {
preflight?: PreflightFn<method> | undefined
request?: RequestFn<method> | undefined
respond?: RespondFn<method> | undefined
settle?: SettleFn<method> | undefined
stableBinding?: StableBindingFn<method> | undefined
transport?: transportOverride | Transport.AnyTransport | undefined
validate?: ValidateFn<method> | undefined
verify: VerifyFn<method>
}
}
2 changes: 2 additions & 0 deletions src/server/Mppx.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -223,6 +223,8 @@ describe('Mppx type tests', () => {
const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })

expectTypeOf(mppx.verifyCredential).toBeFunction()
expectTypeOf(mppx.settleCredential).toBeFunction()
expectTypeOf(mppx.validateCredential).toBeFunction()
})

test('server events receive typed method context', () => {
Expand Down
228 changes: 228 additions & 0 deletions src/server/Mppx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4869,6 +4869,234 @@ describe('verifyCredential', () => {
expect(verifyArgs).toBeDefined()
})

test('validateCredential uses pure method validation without settlement', async () => {
const calls: string[] = []
const splitServer = Method.toServer(mockCharge, {
async validate({ credential, request }) {
calls.push('validate')
return {
challenge: credential.challenge,
credential,
details: { token: credential.payload.token },
intent: 'charge',
method: 'alpha',
request,
source: credential.source,
}
},
async settle() {
calls.push('settle')
return mockReceipt('settled')
},
async verify() {
calls.push('verify')
return mockReceipt('legacy')
},
})
const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
const challenge = await mppx.challenge.alpha.charge(challengeOpts)
const credential = Credential.from({ challenge, payload: { token: 'valid' } })

const validation = await mppx.validateCredential(credential)

expect(validation.details).toEqual({ token: 'valid' })
expect(calls).toEqual(['validate'])
})

test('settleCredential revalidates and uses method settlement', async () => {
const calls: string[] = []
const splitServer = Method.toServer(mockCharge, {
async validate({ credential, request }) {
calls.push('validate')
return {
challenge: credential.challenge,
credential,
details: {},
intent: 'charge',
method: 'alpha',
request,
source: credential.source,
}
},
async settle() {
calls.push('settle')
return mockReceipt('settled')
},
async verify() {
calls.push('verify')
return mockReceipt('legacy')
},
})
const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
const challenge = await mppx.challenge.alpha.charge(challengeOpts)
const credential = Credential.from({ challenge, payload: { token: 'valid' } })

const receipt = await mppx.settleCredential(credential)

expect(receipt.method).toBe('settled')
expect(calls).toEqual(['validate', 'settle'])
})

test('verifyCredential remains a legacy alias for settlement', async () => {
const calls: string[] = []
const splitServer = Method.toServer(mockCharge, {
async validate({ credential, request }) {
calls.push('validate')
return {
challenge: credential.challenge,
credential,
details: {},
intent: 'charge',
method: 'alpha',
request,
source: credential.source,
}
},
async settle() {
calls.push('settle')
return mockReceipt('settled')
},
async verify() {
calls.push('verify')
return mockReceipt('legacy')
},
})
const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
const challenge = await mppx.challenge.alpha.charge(challengeOpts)
const credential = Credential.from({ challenge, payload: { token: 'valid' } })

const receipt = await mppx.verifyCredential(credential)

expect(receipt.method).toBe('settled')
expect(calls).toEqual(['validate', 'settle'])
})

test('validateCredential rejects legacy-only methods without emitting payment failure', async () => {
const events: string[] = []
const mppx = Mppx.create({ methods: [alphaChargeServer], realm, secretKey })
mppx.onPaymentFailed((context) => {
events.push(context.error.name)
})
const challenge = await mppx.challenge.alpha.charge(challengeOpts)
const credential = Credential.from({ challenge, payload: { token: 'valid' } })

await expect(mppx.validateCredential(credential)).rejects.toThrow(
'does not support non-mutating credential validation',
)

expect(events).toEqual([])
})

test('validateCredential enforces supplied route requirements', async () => {
const splitServer = Method.toServer(mockCharge, {
async validate({ credential, request }) {
return {
challenge: credential.challenge,
credential,
details: { amount: request.amount },
intent: 'charge',
method: 'alpha',
request,
source: credential.source,
}
},
async settle() {
return mockReceipt('settled')
},
async verify() {
return mockReceipt('legacy')
},
})
const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
const challenge = await mppx.challenge.alpha.charge(challengeOpts)
const credential = Credential.from({ challenge, payload: { token: 'valid' } })

const validation = await mppx.validateCredential(credential, { request: challengeOpts })
expect(validation.details).toEqual({ amount: '1000' })

await expect(
mppx.validateCredential(credential, {
request: {
...challengeOpts,
amount: '2000',
},
}),
).rejects.toThrow('credential amount does not match this route')
})

test('settleCredential emits payment failure when split validation fails', async () => {
const calls: string[] = []
const events: string[] = []
const splitServer = Method.toServer(mockCharge, {
async validate() {
calls.push('validate')
throw new Errors.VerificationFailedError({ reason: 'risk denied' })
},
async settle() {
calls.push('settle')
return mockReceipt('settled')
},
async verify() {
calls.push('verify')
return mockReceipt('legacy')
},
})
const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
mppx.onPaymentFailed((context) => {
events.push(context.error.name)
})
const challenge = await mppx.challenge.alpha.charge(challengeOpts)
const credential = Credential.from({ challenge, payload: { token: 'valid' } })

await expect(mppx.settleCredential(credential)).rejects.toThrow('risk denied')

expect(calls).toEqual(['validate'])
expect(events).toEqual(['VerificationFailedError'])
})

test('route handlers revalidate before settlement for split methods', async () => {
const calls: string[] = []
const splitServer = Method.toServer(mockCharge, {
async validate({ credential, request }) {
calls.push('validate')
return {
challenge: credential.challenge,
credential,
details: {},
intent: 'charge',
method: 'alpha',
request,
source: credential.source,
}
},
async settle() {
calls.push('settle')
return mockReceipt('settled')
},
async verify() {
calls.push('verify')
return mockReceipt('legacy')
},
})
const mppx = Mppx.create({ methods: [splitServer], realm, secretKey })
const firstResult = await mppx.charge(challengeOpts)(
new Request('https://api.example.com/resource'),
)
expect(firstResult.status).toBe(402)
if (firstResult.status !== 402) throw new Error()

const challenge = Challenge.fromResponse(firstResult.challenge)
const credential = Credential.from({ challenge, payload: { token: 'valid' } })
const result = await mppx.charge(challengeOpts)(
new Request('https://api.example.com/resource', {
headers: { Authorization: Credential.serialize(credential) },
}),
)

expect(result.status).toBe(200)
expect(calls).toEqual(['validate', 'settle'])
})

test('verifies a parsed Credential object (charge)', async () => {
verifyArgs = undefined
const mppx = Mppx.create({
Expand Down
Loading
Loading