Skip to content
Draft
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/server-credential-resolver.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added top-level server credential resolver support.
128 changes: 128 additions & 0 deletions src/server/Mppx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,31 @@ describe('create', () => {
})

describe('request handler', () => {
const mockCharge = Method.toServer(
Method.from({
name: 'mock',
intent: 'charge',
schema: {
credential: { payload: z.object({ token: z.string() }) },
request: z.object({
amount: z.string(),
currency: z.string(),
recipient: z.string(),
}),
},
}),
{
async verify() {
return {
method: 'mock',
reference: 'tx-ref',
status: 'success',
timestamp: new Date().toISOString(),
}
},
},
)

test('returns 402 when no Authorization header', async () => {
const handler = Mppx.create({ methods: [method], realm, secretKey })

Expand Down Expand Up @@ -74,6 +99,109 @@ describe('request handler', () => {
`)
})

test('credentialResolver can resolve a credential for no-credential requests', async () => {
const handler = Mppx.create({
methods: [mockCharge],
realm,
secretKey,
credentialResolver({ challenge, input, method, request }) {
expect(input).toBeInstanceOf(Request)
expect(method).toEqual({ intent: 'charge', name: 'mock' })
expect(request).toEqual({
amount: '1000',
currency: 'USD',
recipient: 'merchant',
})
return Credential.from({ challenge, payload: { token: 'api-key-payment' } })
},
})

const result = await handler.charge({
amount: '1000',
currency: 'USD',
expires: new Date(Date.now() + 60_000).toISOString(),
recipient: 'merchant',
})(new Request('https://example.com/resource'))

expect(result.status).toBe(200)
if (result.status !== 200) throw new Error()

const response = result.withReceipt(new Response('ok'))
expect(response.headers.get('Payment-Receipt')).toBeTruthy()
})

test('credentialResolver can return a serialized credential', async () => {
const handler = Mppx.create({
methods: [mockCharge],
realm,
secretKey,
credentialResolver({ challenge }) {
return Credential.serialize(
Credential.from({ challenge, payload: { token: 'serialized' } }),
)
},
})

const result = await handler.charge({
amount: '1000',
currency: 'USD',
expires: new Date(Date.now() + 60_000).toISOString(),
recipient: 'merchant',
})(new Request('https://example.com/resource'))

expect(result.status).toBe(200)
})

test('credentialResolver falls back to 402 when it returns nothing', async () => {
let called = false
const handler = Mppx.create({
methods: [mockCharge],
realm,
secretKey,
credentialResolver() {
called = true
return undefined
},
})

const result = await handler.charge({
amount: '1000',
currency: 'USD',
expires: new Date(Date.now() + 60_000).toISOString(),
recipient: 'merchant',
})(new Request('https://example.com/resource'))

expect(called).toBe(true)
expect(result.status).toBe(402)
})

test('credentialResolver is not called when Authorization provides a credential', async () => {
const handler = Mppx.create({
methods: [mockCharge],
realm,
secretKey,
credentialResolver() {
throw new Error('credentialResolver should not be called')
},
})
const options = {
amount: '1000',
currency: 'USD',
expires: new Date(Date.now() + 60_000).toISOString(),
recipient: 'merchant',
} as const
const challenge = await handler.challenge.mock.charge(options)
const credential = Credential.from({ challenge, payload: { token: 'authorization' } })

const result = await handler.charge(options)(
new Request('https://example.com/resource', {
headers: { Authorization: Credential.serialize(credential) },
}),
)

expect(result.status).toBe(200)
})

test('returns 402 with challenge for malformed credential', async () => {
const request = new Request('https://example.com/resource', {
headers: { Authorization: 'Payment invalid' },
Expand Down
81 changes: 80 additions & 1 deletion src/server/Mppx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -150,6 +150,44 @@ export type VerifyCredentialOptions = {
scope?: string | undefined
}

/**
* Resolves a payment credential for a request that did not include one.
*
* Use this to support local authentication paths, such as API keys, while
* preserving the normal 402 fallback when no credential can be resolved.
*/
export type CredentialResolver<
method extends Method.Method = Method.Method,
transport extends Transport.AnyTransport = Transport.AnyTransport,
> = (
context: CredentialResolver.Context<method, transport>,
) => MaybePromise<
| Credential.Credential<
z.output<method['schema']['credential']['payload']>,
Challenge.Challenge<z.output<method['schema']['request']>, method['intent'], method['name']>
>
| string
| null
| undefined
>

export declare namespace CredentialResolver {
type Context<
method extends Method.Method = Method.Method,
transport extends Transport.AnyTransport = Transport.AnyTransport,
> = Readonly<{
capturedRequest?: Method.CapturedRequest | undefined
challenge: Challenge.Challenge<
z.output<method['schema']['request']>,
method['intent'],
method['name']
>
input: Transport.InputOf<transport>
method: ServerMethodDescriptor<method>
request: z.output<method['schema']['request']>
}>
}

/**
* Payment handler.
*/
Expand Down Expand Up @@ -395,6 +433,7 @@ export function create<
const transport extends Transport.AnyTransport = Transport.Http,
>(config: create.Config<methods, transport>): Mppx<methods, transport> {
const {
credentialResolver,
realm = Env.get('realm'),
secretKey = Env.get('secretKey'),
transport = Transport.http() as transport,
Expand All @@ -420,6 +459,7 @@ export function create<
for (const mi of methods) {
const fn = createMethodFn({
authorize: mi.authorize as never,
credentialResolver: credentialResolver as never,
defaults: mi.defaults,
method: mi,
realm,
Expand Down Expand Up @@ -736,6 +776,8 @@ export declare namespace create {
> = {
/** Array of configured methods. @example [tempo()] */
methods: methods
/** Resolve a credential for no-credential requests before returning the default 402 challenge. */
credentialResolver?: CredentialResolver<FlattenMethods<methods>[number], transport> | undefined
/** Server realm (e.g., hostname). Resolution order: explicit value > env vars (`MPP_REALM`, `FLY_APP_NAME`, `VERCEL_URL`, etc.) > request URL hostname > `"MPP Payment"`. */
realm?: string | undefined
/** Secret key for HMAC-bound challenge IDs for stateless verification. Auto-detected from `MPP_SECRET_KEY` environment variable. Throws if neither provided nor set. */
Expand All @@ -756,6 +798,7 @@ function createMethodFn<
function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.ReturnType {
const {
authorize,
credentialResolver,
defaults,
events,
method,
Expand Down Expand Up @@ -790,7 +833,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
: staticMeta

// Extract credential once — getCredential may have side effects (e.g. SSE transports).
const [credential, credentialError] = (() => {
let [credential, credentialError] = (() => {
try {
const credential = transport.getCredential(input) as Credential.Credential | null
return [credential ? hydrateCredentialMeta(credential) : null, undefined] as const
Expand Down Expand Up @@ -961,6 +1004,41 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
}
}

if (!credential && credentialResolver) {
try {
const resolved = await credentialResolver({
capturedRequest,
challenge,
input,
method: { intent: method.intent, name: method.name },
request: parsedRequest,
} as never)
if (resolved)
credential = hydrateCredentialMeta(
typeof resolved === 'string' ? Credential.deserialize(resolved) : resolved,
)
} catch (e) {
if (!(e instanceof Errors.PaymentError))
console.error('mppx: internal credential resolver error', e)
const error =
e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError()
await emitPaymentFailed({
challenge,
credential: null,
error,
request: parsedRequest,
retryChallenge: challenge,
})
const response = await emitChallenge({
challenge,
request: parsedRequest,
error,
html: method.html,
})
return { challenge: response, status: 402 }
}
}

// No credential provided—issue challenge
if (!credential) {
if (authorize && input instanceof globalThis.Request) {
Expand Down Expand Up @@ -1285,6 +1363,7 @@ declare namespace createMethodFn {
defaults extends Record<string, unknown> = Record<string, unknown>,
> = {
authorize?: Method.AuthorizeFn<method>
credentialResolver?: CredentialResolver<method, transport>
defaults?: defaults
method: method
events: ServerEventDispatcher<readonly [method], transport>
Expand Down
Loading