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
5 changes: 5 additions & 0 deletions .changeset/loud-stars-joke.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added scope-bound challenge metadata for route replay protection, scope-aware `verifyCredential()` checks, and adapter auto-scoping for Hono and proxy routes.
96 changes: 95 additions & 1 deletion src/middlewares/hono.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { serve } from '@hono/node-server'
import { Hono } from 'hono'
import { Receipt } from 'mppx'
import { Challenge, Credential, Method, Receipt, z } from 'mppx'
import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client'
import { Mppx, discovery } from 'mppx/hono'
import { tempo as tempo_server } from 'mppx/server'
Expand All @@ -26,6 +26,40 @@ function createServer(app: Hono) {

const secretKey = 'test-secret-key'

const scopeMethod = 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(),
decimals: z.number(),
recipient: z.string(),
}),
},
}),
{
async verify() {
return {
method: 'mock',
reference: 'tx-mock',
status: 'success' as const,
timestamp: new Date().toISOString(),
}
},
},
)

function createScopeHarness() {
return Mppx.create({
methods: [scopeMethod],
realm: 'api.example.com',
secretKey,
})
}

function createChargeHarness(feePayer: boolean) {
const mppx = Mppx.create({
methods: [
Expand Down Expand Up @@ -126,6 +160,66 @@ describe('charge', () => {
})
})

describe('scope binding', () => {
const scopeOpts = {
amount: '1',
currency: '0x0000000000000000000000000000000000000001',
decimals: 6,
recipient: '0x0000000000000000000000000000000000000002',
}

test('auto-injects route scope and blocks same-economics replay across routes', async () => {
const mppx = createScopeHarness()

const app = new Hono()
app.get('/alpha/:id', mppx.charge(scopeOpts), (c) => c.json({ route: 'alpha' }))
app.get('/beta/:id', mppx.charge(scopeOpts), (c) => c.json({ route: 'beta' }))

const server = await createServer(app)
const challengeResponse = await fetch(`${server.url}/alpha/1`)
expect(challengeResponse.status).toBe(402)

const challenge = Challenge.fromResponse(challengeResponse)
expect(challenge.opaque).toEqual({ _mppx_scope: 'GET /alpha/:id' })

const credential = Credential.from({ challenge, payload: { token: 'valid' } })
const replay = await fetch(`${server.url}/beta/1`, {
headers: { Authorization: Credential.serialize(credential) },
})

expect(replay.status).toBe(402)
server.close()
})

test('manual scope overrides adapter-derived route scope', async () => {
const mppx = createScopeHarness()

const app = new Hono()
app.get('/alpha/:id', mppx.charge({ ...scopeOpts, scope: 'shared-scope' }), (c) =>
c.json({ route: 'alpha' }),
)
app.get('/beta/:id', mppx.charge({ ...scopeOpts, scope: 'shared-scope' }), (c) =>
c.json({ route: 'beta' }),
)

const server = await createServer(app)
const challengeResponse = await fetch(`${server.url}/alpha/1`)
expect(challengeResponse.status).toBe(402)

const challenge = Challenge.fromResponse(challengeResponse)
expect(challenge.opaque).toEqual({ _mppx_scope: 'shared-scope' })

const credential = Credential.from({ challenge, payload: { token: 'valid' } })
const replay = await fetch(`${server.url}/beta/2`, {
headers: { Authorization: Credential.serialize(credential) },
})

expect(replay.status).toBe(200)
expect(await replay.json()).toEqual({ route: 'beta' })
server.close()
})
})

describe('session', () => {
let escrowContract: Address

Expand Down
7 changes: 6 additions & 1 deletion src/middlewares/hono.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Hono, MiddlewareHandler } from 'hono'

import { generate, type GenerateConfig, type RouteConfig } from '../discovery/OpenApi.js'
import * as Scope from '../server/internal/scope.js'
import * as Mppx_core from '../server/Mppx.js'
import * as Mppx_internal from './internal/mppx.js'

Expand Down Expand Up @@ -56,7 +57,11 @@ export function payment<const intent extends Mppx_internal.AnyMethodFn>(
options: intent extends (options: infer options) => any ? options : never,
): MiddlewareHandler {
return async (c, next) => {
const result = await intent(options)(c.req.raw)
const request =
options.scope === undefined && Scope.read(options.meta) === undefined
? Scope.attach(c.req.raw, `${c.req.method.toUpperCase()} ${c.req.routePath || c.req.path}`)
: c.req.raw
const result = await intent(options)(request)
if (result.status === 402) return result.challenge
await next()
c.res = result.withReceipt(c.res)
Expand Down
116 changes: 116 additions & 0 deletions src/proxy/Proxy.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,40 @@ const mppx_server = Mppx_server.create({
secretKey,
})

const scopeMethod = 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(),
decimals: z.number(),
recipient: z.string(),
}),
},
}),
{
async verify() {
return {
method: 'mock',
reference: 'tx-mock',
status: 'success' as const,
timestamp: new Date().toISOString(),
}
},
},
)

function createScopeServer() {
return Mppx_server.create({
methods: [scopeMethod],
realm: 'api.example.com',
secretKey,
})
}

const mppx_client = Mppx_client.create({
polyfill: false,
methods: [
Expand Down Expand Up @@ -707,6 +741,88 @@ describe('create', () => {
const res = await fetch(`${proxyServer.url}/api/v1/search?q=hello&limit=10`)
expect(await res.json()).toEqual({ search: '?q=hello&limit=10' })
})

test('auto-injects proxy route scope and blocks same-economics replay across routes', async () => {
const scopedServer = createScopeServer()
const proxy = ApiProxy.create({
services: [
Service.from('api', {
baseUrl: 'https://api.example.com',
routes: {
'GET /v1/alpha': scopedServer.charge({
amount: '1',
currency: '0x0000000000000000000000000000000000000001',
decimals: 6,
recipient: '0x0000000000000000000000000000000000000002',
}),
'GET /v1/beta': scopedServer.charge({
amount: '1',
currency: '0x0000000000000000000000000000000000000001',
decimals: 6,
recipient: '0x0000000000000000000000000000000000000002',
}),
},
}),
],
})
proxyServer = await Http.createServer(proxy.listener)

const challengeResponse = await fetch(`${proxyServer.url}/api/v1/alpha`)
expect(challengeResponse.status).toBe(402)

const challenge = Challenge.fromResponse(challengeResponse)
expect(challenge.opaque).toEqual({ _mppx_scope: 'GET /api/v1/alpha' })

const credential = Credential.from({ challenge, payload: { token: 'valid' } })
const replay = await fetch(`${proxyServer.url}/api/v1/beta`, {
headers: { Authorization: Credential.serialize(credential) },
})

expect(replay.status).toBe(402)
})

test('manual scope overrides proxy route scope', async () => {
const scopedServer = createScopeServer()
upstream = await createUpstream(() => Response.json({ ok: true }))
const proxy = ApiProxy.create({
services: [
Service.from('api', {
baseUrl: upstream.url,
routes: {
'GET /v1/alpha': scopedServer.charge({
amount: '1',
currency: '0x0000000000000000000000000000000000000001',
decimals: 6,
recipient: '0x0000000000000000000000000000000000000002',
scope: 'shared-scope',
}),
'GET /v1/beta': scopedServer.charge({
amount: '1',
currency: '0x0000000000000000000000000000000000000001',
decimals: 6,
recipient: '0x0000000000000000000000000000000000000002',
scope: 'shared-scope',
}),
},
}),
],
})
proxyServer = await Http.createServer(proxy.listener)

const challengeResponse = await fetch(`${proxyServer.url}/api/v1/alpha`)
expect(challengeResponse.status).toBe(402)

const challenge = Challenge.fromResponse(challengeResponse)
expect(challenge.opaque).toEqual({ _mppx_scope: 'shared-scope' })

const credential = Credential.from({ challenge, payload: { token: 'valid' } })
const replay = await fetch(`${proxyServer.url}/api/v1/beta`, {
headers: { Authorization: Credential.serialize(credential) },
})

expect(replay.status).toBe(200)
expect(await replay.json()).toEqual({ ok: true })
})
})

describe.runIf(isLocalnet)('plain HTTP session proxy', () => {
Expand Down
28 changes: 27 additions & 1 deletion src/proxy/Proxy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import type * as http from 'node:http'

import * as Credential from '../Credential.js'
import { generateProxy } from '../discovery/OpenApi.js'
import * as Scope from '../server/internal/scope.js'
import * as Request from '../server/Request.js'
import * as Headers from './internal/Headers.js'
import * as Route from './internal/Route.js'
Expand Down Expand Up @@ -129,7 +130,16 @@ export function create(config: create.Config): Proxy {
if (endpoint === true) return proxyUpstream({ request, service, ctx, proxy })

const handler = typeof endpoint === 'function' ? endpoint : endpoint.pay
const result = await handler(request)
const scope =
getConfiguredScope(handler) ??
deriveRouteScope({
basePath: config.basePath,
routeKey: matched.key,
serviceId,
})
const result = await handler(
getConfiguredScope(handler) ? request : Scope.attach(request, scope),
)
if (result.status === 402) return result.challenge

const managementResponse = (() => {
Expand Down Expand Up @@ -242,6 +252,22 @@ function buildDiscoveryRoutes(services: Service.Service[]) {
)
}

function getConfiguredScope(handler: Service.IntentHandler): string | undefined {
if (!('_internal' in handler)) return undefined
const internal = handler._internal as { meta?: Record<string, string>; scope?: string }
return Scope.read(internal.meta) ?? internal.scope
}

function deriveRouteScope(parameters: {
basePath?: string | undefined
routeKey: string
serviceId: string
}): string {
const { basePath, routeKey, serviceId } = parameters
const { method, pattern } = Route.parseRouteKey(routeKey)
return `${method ?? '*'} ${withBasePath(basePath, `/${serviceId}${pattern}`)}`
}

function buildServiceInfo(config: create.Config): { categories?: string[]; docs?: Service.Docs } {
const categories =
config.categories ??
Expand Down
3 changes: 2 additions & 1 deletion src/proxy/internal/Route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,8 @@ export function matchPath(
return match
}

function parseRouteKey(key: string): { method: string | undefined; pattern: string } {
/** Parses a proxy route key like `"POST /v1/messages"` into method + pathname pattern. */
export function parseRouteKey(key: string): { method: string | undefined; pattern: string } {
const tokens = key.trim().split(/\s+/)
if (tokens.length >= 2 && httpMethods.has(tokens[0]!.toUpperCase())) {
return { method: tokens[0]!.toUpperCase(), pattern: tokens.slice(1).join(' ') }
Expand Down
18 changes: 18 additions & 0 deletions src/server/Mppx.test-d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,4 +169,22 @@ describe('Mppx type tests', () => {

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

test('handler options and verifyCredential accept scope', () => {
const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey })

expectTypeOf(
mppx.charge({
amount: '100',
currency: '0x01',
decimals: 6,
recipient: '0x02',
scope: 'GET /premium',
}),
).toBeFunction()

expectTypeOf(mppx.verifyCredential('credential', { scope: 'GET /premium' })).toMatchTypeOf<
Promise<unknown>
>()
})
})
Loading
Loading