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/realm-auto-detection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added `realm` auto-detection from the request `Host` header when not explicitly configured. Resolution order: explicit value → env vars (`MPP_REALM`, `FLY_APP_NAME`, `VERCEL_URL`, etc.) → request URL hostname → `"MPP Payment"` fallback with a one-time warning. Removed the hard-coded `"MPP Payment"` default and deprioritized `HOST`/`HOSTNAME` env vars in favor of platform-specific alternatives.
3 changes: 3 additions & 0 deletions .github/workflows/verify.yml
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ jobs:
matrix:
shard: [1/3, 2/3, 3/3]
env:
# GitHub-hosted runners are ephemeral, so we can skip the Ryuk sidecar
# and avoid Docker Hub rate limits on testcontainers/ryuk pulls.
TESTCONTAINERS_RYUK_DISABLED: true
VITE_TEMPO_TAG: sha-20aecec
steps:
- name: Clone repository
Expand Down
24 changes: 12 additions & 12 deletions src/internal/env.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@ afterEach(() => {
})

describe('Env.get', () => {
test('returns default realm when no env vars are set', () => {
expect(Env.get('realm')).toBe('MPP Payment')
test('returns undefined when no env vars are set', () => {
expect(Env.get('realm')).toBeUndefined()
})

test('returns undefined when MPP_SECRET_KEY is not set', () => {
Expand All @@ -18,25 +18,25 @@ describe('Env.get', () => {
expect(Env.get('secretKey')).toBe('sk_live_abc123')
})

test('MPP_REALM takes precedence over platform vars', () => {
vi.stubEnv('MPP_REALM', 'custom-realm')
vi.stubEnv('FLY_APP_NAME', 'fly-app')
expect(Env.get('realm')).toBe('custom-realm')
})

test('returns FLY_APP_NAME when set', () => {
vi.stubEnv('FLY_APP_NAME', 'my-fly-app')
expect(Env.get('realm')).toBe('my-fly-app')
})

test('FLY_APP_NAME takes precedence over HOST', () => {
test('FLY_APP_NAME takes precedence over VERCEL_URL', () => {
vi.stubEnv('FLY_APP_NAME', 'fly-app')
vi.stubEnv('HOST', 'my-host')
vi.stubEnv('VERCEL_URL', 'my-app.vercel.app')
expect(Env.get('realm')).toBe('fly-app')
})

test('HOST takes precedence over MPP_REALM', () => {
vi.stubEnv('HOST', 'my-host')
vi.stubEnv('MPP_REALM', 'custom-realm')
expect(Env.get('realm')).toBe('my-host')
})

test('falls through to later vars when earlier ones are unset', () => {
vi.stubEnv('MPP_REALM', 'fallback-realm')
expect(Env.get('realm')).toBe('fallback-realm')
vi.stubEnv('VERCEL_URL', 'my-app.vercel.app')
expect(Env.get('realm')).toBe('my-app.vercel.app')
})
})
8 changes: 2 additions & 6 deletions src/internal/env.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
/** Map of configuration keys to environment variable names, checked in order. */
const variables = {
realm: [
'MPP_REALM',
'FLY_APP_NAME',
'HEROKU_APP_NAME',
'HOST',
'HOSTNAME',
'MPP_REALM',
'RAILWAY_PUBLIC_DOMAIN',
'RENDER_EXTERNAL_HOSTNAME',
'VERCEL_URL',
Expand All @@ -15,9 +13,7 @@ const variables = {
} as const satisfies Record<string, readonly string[]>

/** Fallback values when no environment variable is set. */
const defaults = {
realm: 'MPP Payment',
} as const satisfies Partial<Record<keyof typeof variables, string>>
const defaults = {} as const satisfies Partial<Record<keyof typeof variables, string>>

/**
* Resolves a configuration value from environment variables.
Expand Down
216 changes: 216 additions & 0 deletions src/server/Mppx.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1741,3 +1741,219 @@ describe('withReceipt', () => {
server.close()
})
})

describe('realm auto-detection', () => {
beforeEach(() => {
// Clear all env vars that Env.get('realm') probes so realm falls through to request detection
for (const name of [
'MPP_REALM',
'FLY_APP_NAME',
'HEROKU_APP_NAME',
'RAILWAY_PUBLIC_DOMAIN',
'RENDER_EXTERNAL_HOSTNAME',
'VERCEL_URL',
'WEBSITE_HOSTNAME',
])
vi.stubEnv(name, '')
})

afterEach(() => {
vi.unstubAllEnvs()
})

const mockMethod = 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: 'ref',
status: 'success' as const,
timestamp: new Date().toISOString(),
}
},
},
)

test.each([
{ url: 'https://mpp.dev/resource', expected: 'mpp.dev' },
{ url: 'https://api.example.com/v1/resource', expected: 'api.example.com' },
{ url: 'https://localhost:8787/resource', expected: 'localhost' },
{ url: 'https://MPP.DEV/resource', expected: 'mpp.dev' },
{ url: 'http://staging.mpp.dev:3000/api', expected: 'staging.mpp.dev' },
])('derives realm "$expected" from $url', async ({ url, expected }) => {
const handler = Mppx.create({ methods: [mockMethod], secretKey })

const result = await handler.charge({
amount: '100',
currency: '0x0000000000000000000000000000000000000001',
recipient: '0x0000000000000000000000000000000000000002',
})(new Request(url))

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

const challenge = Challenge.fromResponse(result.challenge)
expect(challenge.realm).toBe(expected)
})

test('credential verifies across different casing of same host', async () => {
const handler = Mppx.create({ methods: [mockMethod], secretKey })

const chargeOpts = {
amount: '100',
currency: '0x0000000000000000000000000000000000000001',
recipient: '0x0000000000000000000000000000000000000002',
}

// Get challenge with uppercase host
const result = await handler.charge(chargeOpts)(new Request('https://MPP.DEV/resource'))
expect(result.status).toBe(402)
if (result.status !== 402) throw new Error()

const challenge = Challenge.fromResponse(result.challenge)
const credential = Credential.from({ challenge, payload: { token: 'valid' } })

// Verify with lowercase host — should match since both normalize
const verifyResult = await handler.charge(chargeOpts)(
new Request('https://mpp.dev/resource', {
headers: { Authorization: Credential.serialize(credential) },
}),
)
expect(verifyResult.status).toBe(200)
})

test('explicit realm takes precedence over request url', async () => {
const handler = Mppx.create({ methods: [mockMethod], realm: 'explicit.example.com', secretKey })

const request = new Request('https://other.example.com/resource')
const result = await handler.charge({
amount: '100',
currency: '0x0000000000000000000000000000000000000001',
recipient: '0x0000000000000000000000000000000000000002',
})(request)

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

const challenge = Challenge.fromResponse(result.challenge)
expect(challenge.realm).toBe('explicit.example.com')
})

test('challenge and verification use same auto-detected realm', async () => {
const handler = Mppx.create({ methods: [mockMethod], secretKey })

const url = 'https://mpp.dev/resource'

// Get challenge
const result = await handler.charge({
amount: '100',
currency: '0x0000000000000000000000000000000000000001',
recipient: '0x0000000000000000000000000000000000000002',
})(new Request(url))

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

const challenge = Challenge.fromResponse(result.challenge)
const credential = Credential.from({ challenge, payload: { token: 'valid' } })

// Replay with credential from same host — should verify
const verifyResult = await handler.charge({
amount: '100',
currency: '0x0000000000000000000000000000000000000001',
recipient: '0x0000000000000000000000000000000000000002',
})(new Request(url, { headers: { Authorization: Credential.serialize(credential) } }))

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

test('credential from one host rejected at different host', async () => {
const handler = Mppx.create({ methods: [mockMethod], secretKey })

// Get challenge from host A
const result = await handler.charge({
amount: '100',
currency: '0x0000000000000000000000000000000000000001',
recipient: '0x0000000000000000000000000000000000000002',
})(new Request('https://host-a.example.com/resource'))

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

const challenge = Challenge.fromResponse(result.challenge)
const credential = Credential.from({ challenge, payload: { token: 'valid' } })

// Present at host B — realm mismatch should reject
const verifyResult = await handler.charge({
amount: '100',
currency: '0x0000000000000000000000000000000000000001',
recipient: '0x0000000000000000000000000000000000000002',
})(
new Request('https://host-b.example.com/resource', {
headers: { Authorization: Credential.serialize(credential) },
}),
)

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

test('realm undefined on handler when not explicitly set', () => {
const handler = Mppx.create({ methods: [mockMethod], secretKey })
expect(handler.realm).toBeUndefined()
})

test('falls back to default realm when input has no url', async () => {
const handler = Mppx.create({ methods: [mockMethod], secretKey })
const handle = handler.charge({
amount: '100',
currency: '0x0000000000000000000000000000000000000001',
recipient: '0x0000000000000000000000000000000000000002',
})

// Simulate a non-HTTP input with no .url — should warn and use fallback
const result = await handle({} as any)
expect(result.status).toBe(402)
if (result.status !== 402) throw new Error()
const challenge = Challenge.fromResponse(result.challenge)
expect(challenge.realm).toBe('MPP Payment')
})

test('cross-host rejection reports realm mismatch', async () => {
const handler = Mppx.create({ methods: [mockMethod], secretKey })

const result = await handler.charge({
amount: '100',
currency: '0x0000000000000000000000000000000000000001',
recipient: '0x0000000000000000000000000000000000000002',
})(new Request('https://host-a.example.com/resource'))

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

const challenge = Challenge.fromResponse(result.challenge)
const credential = Credential.from({ challenge, payload: { token: 'valid' } })

const verifyResult = await handler.charge({
amount: '100',
currency: '0x0000000000000000000000000000000000000001',
recipient: '0x0000000000000000000000000000000000000002',
})(
new Request('https://host-b.example.com/resource', {
headers: { Authorization: Credential.serialize(credential) },
}),
)

expect(verifyResult.status).toBe(402)
if (verifyResult.status !== 402) throw new Error()
const body = (await verifyResult.challenge.json()) as { detail: string }
expect(body.detail).toContain('realm')
})
})
41 changes: 36 additions & 5 deletions src/server/Mppx.ts
Original file line number Diff line number Diff line change
Expand Up @@ -153,7 +153,7 @@ export function create<
const transport extends Transport.AnyTransport = Transport.Http,
>(config: create.Config<methods, transport>): Mppx<methods, transport> {
const {
realm = Env.get('realm') ?? 'MPP Payment',
realm = Env.get('realm'),
secretKey = Env.get('secretKey'),
transport = Transport.http() as transport,
} = config
Expand Down Expand Up @@ -222,7 +222,7 @@ export function create<
return {
methods,
compose: composeFn,
realm: realm as string,
realm: realm as string | undefined,
transport,
...handlers,
} as never
Expand All @@ -235,7 +235,7 @@ export declare namespace create {
> = {
/** Array of configured methods. @example [tempo()] */
methods: methods
/** Server realm (e.g., hostname). Auto-detected from environment variables (`MPP_REALM`, `VERCEL_URL`, `RAILWAY_PUBLIC_DOMAIN`, `RENDER_EXTERNAL_HOSTNAME`, `HOST`, `HOSTNAME`), falling back to `"localhost"`. */
/** 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. */
secretKey?: string | undefined
Expand Down Expand Up @@ -283,14 +283,17 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R
: merged
) as never

// Resolve realm: explicit > env var > request Host header.
const effectiveRealm = realm ?? resolveRealmFromRequest(input)

// Recompute challenge from options. The HMAC-bound ID means we don't need to
// store challenges server-side—if the client echoes back a credential with
// a matching ID, we know it was issued by us with these exact parameters.
const challenge = Challenge.fromMethod(method, {
description,
expires,
meta,
realm,
realm: effectiveRealm,
request,
secretKey,
})
Expand Down Expand Up @@ -483,7 +486,7 @@ declare namespace createMethodFn {
> = {
defaults?: defaults
method: method
realm: string
realm: string | undefined
request?: Method.RequestFn<method>
respond?: Method.RespondFn<method>
secretKey: string
Expand All @@ -498,6 +501,34 @@ declare namespace createMethodFn {
> = MethodFn<method, transport, defaults>
}

const defaultRealm = 'MPP Payment'
const Warnings = {
realmFallback: 'realm-fallback',
} as const

const _warned = new Set<string>()
function warnOnce(key: string, message: string) {
if (_warned.has(key)) return
_warned.add(key)
console.warn(`[mppx] ${message}`)
}

/** Extracts hostname from the request URL, falling back to a default. */
function resolveRealmFromRequest(input: unknown): string {
try {
const url = typeof (input as any)?.url === 'string' ? (input as any).url : undefined
if (url) {
const { protocol, hostname } = new URL(url)
if (/^https?:$/.test(protocol) && hostname) return hostname
}
} catch {}
warnOnce(
Warnings.realmFallback,
`Could not auto-detect realm from request. Falling back to "${defaultRealm}". Set \`realm\` in Mppx.create() or the MPP_REALM env var.`,
)
return defaultRealm
}

export type MethodFn<
method extends Method.Method,
transport extends Transport.AnyTransport,
Expand Down
Loading