import { Hono, type Context } from 'hono' import { requestId } from 'hono/request-id' import { Challenge, Receipt } from 'mppx' import { Mppx, tempo } from 'mppx/hono' import type { Session } from 'mppx/tempo' import { RateLimit, Store } from 'tapimo' import { Actions } from 'viem/tempo' import * as ApiKey from '../ApiKey.js' import type * as Db from '../db/Db.js' import * as Memberships from '../db/tables/memberships.js' import * as Organizations from '../db/tables/organizations.js' import * as Projects from '../db/tables/projects.js' import * as Users from '../db/tables/users.js' import * as Auth from './Auth.js' import * as Log from './Log.js' import * as Scope from '../Scope.js' import * as Viem from './Viem.js' import * as TestApp from '../../test/App.js' import { createClient as createMppClient } from '../../test/Mppx.js' import * as TestStore from '../../test/Store.js' import * as Tempo from '../../test/Tempo.js' const key = { allowedIps: [], environment: 'production', id: 'key_test', name: 'Test key', orgId: 'org_test', scopes: ['data:read'], } satisfies ApiKey.ApiKey const token = 'secret_test_key' describe('require', () => { describe('apiKey', () => { test('accepts API key from tempo-api-key', async () => { const app = createApp() const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(body).toMatchInlineSnapshot(` { "id": "key_test", "orgId": "org_test", "type": "api_key", } `) }) test('accepts bearer API key when authorization is not payment', async () => { const app = createApp() const response = await app.request('/protected', { headers: { authorization: `Bearer ${token}` }, }) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(body).toMatchInlineSnapshot(` { "id": "key_test", "orgId": "org_test", "type": "api_key", } `) }) test('does not parse payment authorization as bearer fallback', async () => { const app = createApp() const response = await app.request('/protected', { headers: { authorization: 'Payment paid' }, }) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`401`) expect(body).toMatchInlineSnapshot(` { "error": { "code": "api_key_missing", "message": "Missing API key", }, } `) }) test('accepts API key from key query param', async () => { const app = createApp() const response = await app.request(`/protected?key=${token}`) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.get('cache-control')).toMatchInlineSnapshot(`"private"`) expect(body).toMatchInlineSnapshot(` { "id": "key_test", "orgId": "org_test", "type": "api_key", } `) }) test('keeps query-authenticated responses private after downstream caching', async () => { const auth = { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, rateLimit: RateLimit.memory(), } const app = new Hono() app.use('*', async (c, next) => { c.set('auth', auth) await next() }) app.use('/cached', Auth.require({ apiKey: { scopes: ['data:read'] } })) app.get('/cached', (c) => { c.header('Cache-Control', 'public, max-age=86400') return c.json({ ok: true }) }) const response = await app.request(`/cached?key=${token}`) expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.get('cache-control')).toMatchInlineSnapshot(`"private"`) }) test('strips the key query param before route validation', async () => { // Auth owns `key`, so strict route schemas must not see it. const auth = { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, rateLimit: RateLimit.memory(), } const app = new Hono() app.use('*', async (c, next) => { c.set('auth', auth) await next() }) app.use('/strict', Auth.require({ apiKey: { scopes: ['data:read'] } })) app.get('/strict', (c) => c.json({ key: c.req.query('key') ?? null, limit: c.req.query('limit') ?? null }), ) const response = await app.request(`/strict?limit=5&key=${token}`) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(body).toMatchInlineSnapshot(` { "key": null, "limit": "5", } `) }) test('rejects invalid API key without falling back to public quota', async () => { const app = createApp({ policy: { apiKey: { scopes: ['data:read'] }, public: { rateLimit: { limit: 60, period: 'minute' } }, }, }) const response = await app.request('/protected', { headers: { 'tempo-api-key': 'wrong' }, }) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`401`) expect(body).toMatchInlineSnapshot(` { "error": { "code": "api_key_invalid", "message": "Invalid API key", }, } `) }) test('records auth error codes for request logs', async () => { const entries: Log.Entry[] = [] const app = createApp({ logger: (entry) => void entries.push(entry), policy: { apiKey: { scopes: ['data:read'] }, public: { rateLimit: { limit: 60, period: 'minute' } }, }, }) const response = await app.request('/protected', { headers: { 'tempo-api-key': 'wrong' }, }) expect(response.status).toMatchInlineSnapshot(`401`) expect(entries[0]?.errorCode).toMatchInlineSnapshot(`"api_key_invalid"`) }) test('records resolved key principal for forbidden-scope logs', async () => { const entries: Log.Entry[] = [] const app = createApp({ logger: (entry) => void entries.push(entry), policy: { apiKey: { scopes: ['webhooks:write'] } }, }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) expect(response.status).toMatchInlineSnapshot(`403`) expect(entries[0]?.errorCode).toMatchInlineSnapshot(`"api_key_forbidden"`) expect(entries[0]?.principal).toMatchInlineSnapshot(` { "billingActive": false, "environment": "production", "id": "key_test", "orgId": "org_test", "type": "api_key", } `) }) test('rejects malformed API key', async () => { const app = createApp() const response = await app.request('/protected', { headers: { authorization: 'Basic wrong' }, }) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`400`) expect(body).toMatchInlineSnapshot(` { "error": { "code": "api_key_malformed", "message": "Malformed API key", }, } `) }) test('rejects missing API key when no public or payment lane exists', async () => { const app = createApp() const response = await app.request('/protected') const body = await response.json() expect(response.status).toMatchInlineSnapshot(`401`) expect(body).toMatchInlineSnapshot(` { "error": { "code": "api_key_missing", "message": "Missing API key", }, } `) }) test('rejects missing scope', async () => { const app = createApp({ policy: { apiKey: { scopes: ['webhooks:write'] } }, }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`403`) expect(body).toMatchInlineSnapshot(` { "error": { "code": "api_key_forbidden", "message": "API key missing required scope", }, } `) }) test('wildcard scope satisfies any required scope', async () => { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, scopes: [Scope.wildcard], token }], }), }, rateLimit: RateLimit.memory(), }, policy: { apiKey: { scopes: ['data:read', 'webhooks:write'] }, }, }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(body).toMatchInlineSnapshot(` { "id": "key_test", "orgId": "org_test", "type": "api_key", } `) }) test.each([ ['tempo-api-key', '/protected', { 'tempo-api-key': token }], ['Bearer', '/protected', { authorization: `Bearer ${token}` }], ['x-api-key', '/protected', { 'x-api-key': token }], ['query key', `/protected?key=${token}`, {}], ] as const)('enforces an allowlist for the %s credential', async (_name, path, credential) => { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, allowedIps: ['203.0.113.0/24'], token }], }), }, rateLimit: RateLimit.memory(), }, }) const response = await app.request(path, { headers: { ...credential, 'cf-connecting-ip': '198.51.100.7' }, }) expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ error: { code: 'api_key_ip_forbidden' } }) }) test('rejects a missing client IP', async () => { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, allowedIps: ['203.0.113.0/24'], token }], }), }, rateLimit: RateLimit.memory(), }, }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ error: { code: 'api_key_ip_forbidden' } }) }) test('does not trust a Cloudflare IP header outside Cloudflare', async () => { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, allowedIps: ['203.0.113.0/24'], token }], }), }, rateLimit: RateLimit.memory(), }, }) const response = await app.request('/protected', { headers: { 'cf-connecting-ip': '203.0.113.7', 'tempo-api-key': token, }, }) expect(response.status).toBe(403) expect(await response.json()).toMatchObject({ error: { code: 'api_key_ip_forbidden' } }) }) test('uses the Cloudflare IP header with Cloudflare request metadata', async () => { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, allowedIps: ['203.0.113.0/24'], token }], }), }, rateLimit: RateLimit.memory(), }, }) const request = Object.assign( new Request('http://localhost/protected', { headers: { 'cf-connecting-ip': '203.0.113.7', 'tempo-api-key': token, }, }), { cf: {} }, ) const response = await app.fetch(request) expect(response.status).toBe(200) }) test('uses the configured trusted client-IP resolver', async () => { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, allowedIps: ['203.0.113.0/24'], token }], }), }, publicClientIp: (request) => request.headers.get('x-real-ip') ?? undefined, rateLimit: RateLimit.memory(), }, }) const response = await app.request('/protected', { headers: { 'cf-connecting-ip': '198.51.100.7', 'tempo-api-key': token, 'x-real-ip': '203.0.113.7', }, }) expect(response.status).toBe(200) }) test('treats an empty allowlist as unrestricted', async () => { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, allowedIps: [], token }] }), }, rateLimit: RateLimit.memory(), }, }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) expect(response.status).toBe(200) }) }) describe('session', () => { const sessionPrincipal = { id: 'usr_test', identity: { provider: 'wallet', subject: '0x0000000000000000000000000000000000000001' }, type: 'session', } as const satisfies Auth.SessionPrincipal /** Harness with the session capability installed on the auth context. */ function createSessionApp( options: { policy?: Auth.require.Policy | undefined resolve?: Auth.Session['resolve'] | undefined } = {}, ) { const auth = { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, rateLimit: RateLimit.memory(), ...(options.resolve ? { session: { resolve: options.resolve } } : {}), } const app = new Hono() app.use('*', async (c, next) => { c.set('auth', auth) await next() }) app.use('/protected', Auth.require(options.policy ?? { session: true })) app.get('/protected', (c) => c.json(serializePrincipal(Auth.getPrincipal(c)))) return app } test('resolves cookie sessions for session-lane routes', async () => { const app = createSessionApp({ resolve: async () => sessionPrincipal }) const response = await app.request('/protected') const body = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(body).toMatchInlineSnapshot(` { "id": "usr_test", "type": "session", } `) }) test('falls through to the session lane on invalid bearer tokens', async () => { const app = createSessionApp({ resolve: async () => sessionPrincipal }) const response = await app.request('/protected', { headers: { authorization: 'Bearer not-an-api-key' }, }) expect(response.status).toMatchInlineSnapshot(`200`) }) test('rejects anonymous requests without a session', async () => { const app = createSessionApp({ resolve: async () => null }) const response = await app.request('/protected') expect(response.status).toMatchInlineSnapshot(`401`) }) test('stays closed without the app session capability', async () => { const app = createSessionApp() const response = await app.request('/protected') expect(response.status).toMatchInlineSnapshot(`401`) }) test('valid API keys win over sessions', async () => { const app = createSessionApp({ policy: { apiKey: { scopes: ['data:read'] }, session: true }, resolve: async () => sessionPrincipal, }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(body).toMatchInlineSnapshot(` { "id": "key_test", "orgId": "org_test", "type": "api_key", } `) }) test('applies the session-only route error before key restrictions', async () => { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, allowedIps: ['203.0.113.0/24'], token }], }), }, publicClientIp: (request) => request.headers.get('x-real-ip') ?? undefined, rateLimit: RateLimit.memory(), }, policy: { session: true }, }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token, 'x-real-ip': '198.51.100.7', }, }) expect(response.status).toBe(403) expect(await response.json()).toMatchInlineSnapshot(` { "error": { "code": "forbidden", "message": "API key not permitted for this route", }, } `) }) test('is not consulted on lane-less routes', async () => { const app = createSessionApp({ policy: {}, resolve: async () => sessionPrincipal, }) const response = await app.request('/protected') expect(response.status).toMatchInlineSnapshot(`401`) }) }) describe('superAdmin', () => { const secret = 'tempo:sk:a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0a0' /** Auth context with the super admin secret configured alongside the test key. */ function withSuperAdmin(): Auth.Context { return { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, rateLimit: RateLimit.memory(), superAdmin: { tokenHash: ApiKey.hash(secret) }, } } test('resolves the super admin secret before key lookup', async () => { const app = createApp({ auth: withSuperAdmin() }) const response = await app.request('/protected', { headers: { 'tempo-api-key': secret }, }) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(body).toMatchInlineSnapshot(` { "actor": "super_admin", "id": "super_admin", "type": "super_admin", } `) }) test('accepts the super admin secret as a bearer token', async () => { const app = createApp({ auth: withSuperAdmin() }) const response = await app.request('/protected', { headers: { authorization: `Bearer ${secret}` }, }) expect(response.status).toMatchInlineSnapshot(`200`) }) test('bypasses scope checks', async () => { const app = createApp({ auth: withSuperAdmin(), policy: { apiKey: { scopes: [Scope.wildcard] } }, }) const response = await app.request('/protected', { headers: { 'tempo-api-key': secret }, }) expect(response.status).toMatchInlineSnapshot(`200`) }) test('non-matching tokens fall through to key lookup', async () => { const app = createApp({ auth: withSuperAdmin() }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(body).toMatchInlineSnapshot(` { "id": "key_test", "orgId": "org_test", "type": "api_key", } `) }) test('is disabled when unconfigured', async () => { const app = createApp() const response = await app.request('/protected', { headers: { 'tempo-api-key': secret }, }) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`401`) expect(body).toMatchInlineSnapshot(` { "error": { "code": "api_key_invalid", "message": "Invalid API key", }, } `) }) test('is the only principal for lane-less routes', async () => { const app = createApp({ auth: withSuperAdmin(), policy: {} }) const superAdmin = await app.request('/protected', { headers: { 'tempo-api-key': secret }, }) expect(superAdmin.status).toMatchInlineSnapshot(`200`) const keyed = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) const body = await keyed.json() expect(keyed.status).toMatchInlineSnapshot(`403`) expect(body).toMatchInlineSnapshot(` { "error": { "code": "forbidden", "message": "API key not permitted for this route", }, } `) const anonymous = await app.request('/protected') expect(anonymous.status).toMatchInlineSnapshot(`401`) }) test('logs the principal actor, never the secret', async () => { const entries: Log.Entry[] = [] const app = createApp({ auth: withSuperAdmin(), logger: (entry) => { entries.push(entry) }, }) await app.request('/protected', { headers: { 'tempo-api-key': secret } }) expect(entries[0]?.principal).toMatchInlineSnapshot(` { "actor": "super_admin", "id": "super_admin", "type": "super_admin", } `) expect(JSON.stringify(entries)).not.toContain(secret) }) }) describe('environment', () => { const sandboxToken = 'secret_sandbox' function createChainApp( options: { public?: boolean publicRateLimit?: RateLimit.Limit rateLimit?: RateLimit.Store sandboxBillingActive?: boolean mpp?: Auth.Context['mpp'] } = {}, ) { const auth: Auth.Context = { apiKey: { resolve: TestApp.resolver({ keys: [ { ...key, scopes: [Scope.wildcard], token }, { environment: 'sandbox', id: 'key_sandbox', orgId: 'org_test', scopes: [Scope.wildcard], token: sandboxToken, ...(options.sandboxBillingActive === undefined ? {} : { billingActive: options.sandboxBillingActive }), }, ], }), }, rateLimit: options.rateLimit ?? RateLimit.memory(), ...(options.publicRateLimit ? { publicRateLimit: options.publicRateLimit } : {}), ...(options.mpp ? { mpp: options.mpp } : {}), } const policy: Auth.require.Policy = { apiKey: { scopes: [Scope.wildcard] }, ...(options.public === false ? {} : { public: { rateLimit: { limit: 60, period: 'minute' } } }), ...(options.mpp ? { mpp: { session: { amount: '1' } } } : {}), } const app = new Hono() app.use('*', async (c, next) => { c.set('auth', auth) // Mirror the app default (mainnet) so the effective chain is observable. c.set('chainId' as never, Viem.defaultChainId as never) await next() }) app.use('/protected', Auth.require(policy)) app.get('/protected', (c) => { // Mirror how real handlers resolve the effective chain: explicit query // wins, else the context default. Auth no longer steers this value. const raw = c.req.query('chainId') const chainId = raw ? Number(raw) : ((c.get('chainId' as never) as number | null) ?? null) return c.json({ chainId, principal: Auth.getPrincipal(c)?.type ?? null }) }) return app } test('rejects a sandbox key on explicit mainnet', async () => { const app = createChainApp() const response = await app.request('/protected?chainId=4217', { headers: { 'tempo-api-key': sandboxToken }, }) expect(response.status).toMatchInlineSnapshot(`403`) expect(await response.json()).toMatchInlineSnapshot(` { "error": { "code": "api_key_forbidden", "message": "Sandbox API keys only support testnet. Pass a testnet \`chainId\`.", }, } `) }) test('rejects a sandbox key on the mainnet alias', async () => { const app = createChainApp() const response = await app.request('/protected?chainId=mainnet', { headers: { 'tempo-api-key': sandboxToken }, }) expect(response.status).toMatchInlineSnapshot(`403`) expect((await response.json()).error.code).toMatchInlineSnapshot(`"api_key_forbidden"`) }) test('rejects a sandbox key on explicit mainnet even with no public lane', async () => { const app = createChainApp({ public: false }) const response = await app.request('/protected?chainId=4217', { headers: { 'tempo-api-key': sandboxToken }, }) // A resolved-but-forbidden key is a 403, not anonymous fall-through. expect(response.status).toMatchInlineSnapshot(`403`) expect((await response.json()).error.code).toMatchInlineSnapshot(`"api_key_forbidden"`) }) test('uses a sandbox key on an explicit non-mainnet chain', async () => { const app = createChainApp() const response = await app.request('/protected?chainId=42431', { headers: { 'tempo-api-key': sandboxToken }, }) expect(response.status).toMatchInlineSnapshot(`200`) expect(await response.json()).toMatchInlineSnapshot(` { "chainId": 42431, "principal": "api_key", } `) }) test('authenticates a sandbox key with no query chain without steering', async () => { const app = createChainApp() const response = await app.request('/protected', { headers: { 'tempo-api-key': sandboxToken }, }) // Auth no longer mutates `chainId`; the mainnet default is left for the // data group's own sandbox chain guard to reject. expect(response.status).toMatchInlineSnapshot(`200`) expect(await response.json()).toMatchInlineSnapshot(` { "chainId": 4217, "principal": "api_key", } `) }) test('uses a production key on mainnet', async () => { const app = createChainApp() const response = await app.request('/protected?chainId=4217', { headers: { 'tempo-api-key': token }, }) expect(response.status).toMatchInlineSnapshot(`200`) expect(await response.json()).toMatchInlineSnapshot(` { "chainId": 4217, "principal": "api_key", } `) }) test('throttles a sandbox key to the public quota when billing is inactive', async () => { const app = createChainApp({ public: true, // policy.public.rateLimit is { limit: 60, period: 'minute' } sandboxBillingActive: false, }) const response = await app.request('/protected?chainId=42431', { headers: { 'tempo-api-key': sandboxToken }, }) expect(response.status).toBe(200) // The public ceiling (60), not the API-key default (10,000). expect(response.headers.get('RateLimit-Limit')).toMatchInlineSnapshot(`"60"`) }) test('grants a sandbox key the full API-key quota when billing is active', async () => { const app = createChainApp({ public: true, sandboxBillingActive: true, }) const response = await app.request('/protected?chainId=42431', { headers: { 'tempo-api-key': sandboxToken }, }) expect(response.status).toBe(200) // The API-key default (10,000), not the public ceiling (60). expect(response.headers.get('RateLimit-Limit')).toMatchInlineSnapshot(`"10000"`) }) test('throttles a sandbox key to the public quota when billing is unknown', async () => { // A sandbox key minted before billing was ever active carries no // `billingActive` flag; it falls back to the public quota. const app = createChainApp({ public: true }) const response = await app.request('/protected?chainId=42431', { headers: { 'tempo-api-key': sandboxToken }, }) expect(response.status).toBe(200) expect(response.headers.get('RateLimit-Limit')).toMatchInlineSnapshot(`"60"`) }) test('throttles to the configured public default on API-key-only routes', async () => { const app = createChainApp({ public: false, // no per-route public lane publicRateLimit: { limit: 7, period: 'minute' }, sandboxBillingActive: false, }) const response = await app.request('/protected?chainId=42431', { headers: { 'tempo-api-key': sandboxToken }, }) expect(response.status).toBe(200) expect(response.headers.get('RateLimit-Limit')).toMatchInlineSnapshot(`"7"`) }) test('returns 429 (never a payment challenge) for a throttled sandbox key over quota', async () => { const app = createChainApp({ mpp: createMppServer(), rateLimit: overQuota(), sandboxBillingActive: false, }) const response = await app.request('/protected?chainId=42431', { headers: { 'tempo-api-key': sandboxToken }, }) // A throttled sandbox key must activate billing, not pay per request, so // the over-quota response is a plain 429 rather than a 402 MPP challenge. expect(response.status).toMatchInlineSnapshot(`429`) expect((await response.json()).error.code).toMatchInlineSnapshot(`"rate_limit_exceeded"`) }) test('ignores a payment credential for a throttled sandbox key under quota', async () => { const app = createChainApp({ mpp: createMppServer(), sandboxBillingActive: false, }) const response = await app.request('/protected?chainId=42431', { headers: { 'tempo-api-key': sandboxToken, authorization: 'Payment paid' }, }) // The payment lane can't buy past the throttle; the request runs on the // public quota instead of being charged. expect(response.status).toBe(200) expect(response.headers.get('RateLimit-Limit')).toMatchInlineSnapshot(`"60"`) }) }) describe('mpp', () => { test('uses a default paid-request limit with a normalized IPv6 identity', async () => { const rateLimit = underQuota() const consume = vi.spyOn(rateLimit, 'consume') const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp: createMppHandler(), rateLimit, }, policy: { mpp: { session: { amount: '1' } } }, }) const response = await app.request('/protected', { headers: { authorization: 'Payment paid', 'cf-connecting-ip': '2001:db8:abcd:12::1', }, }) expect(response.status).toBe(200) expect(response.headers.get('RateLimit-Limit')).toBe('100') expect(response.headers.get('RateLimit-Scope')).toBe('mpp') expect(consume).toHaveBeenCalledWith({ key: 'mpp:public:2001:db8:abcd:12', limit: { limit: 100, period: 'minute' }, }) }) test('ignores untrusted forwarding headers for public identity', async () => { const rateLimit = underQuota() const consume = vi.spyOn(rateLimit, 'consume') const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp: createMppHandler(), rateLimit, }, policy: { mpp: { session: { amount: '1' } } }, }) const response = await app.request('/protected', { headers: { authorization: 'Payment paid', 'x-forwarded-for': '203.0.113.10', 'x-real-ip': '203.0.113.11', 'x-test-client-ip': '203.0.113.12', }, }) expect(response.status).toBe(200) expect(consume).toHaveBeenCalledWith({ key: 'mpp:public:anonymous', limit: { limit: 100, period: 'minute' }, }) }) test('uses a configured trusted client-IP resolver outside Cloudflare', async () => { const rateLimit = underQuota() const consume = vi.spyOn(rateLimit, 'consume') const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp: createMppHandler(), publicClientIp: (request) => request.headers.get('x-real-ip') ?? undefined, rateLimit, }, policy: { mpp: { session: { amount: '1' } } }, }) const response = await app.request('/protected', { headers: { authorization: 'Payment paid', 'x-real-ip': '2001:db8:abcd:12::99', }, }) expect(response.status).toBe(200) expect(consume).toHaveBeenCalledWith({ key: 'mpp:public:2001:db8:abcd:12', limit: { limit: 100, period: 'minute' }, }) }) test('buckets paid API-key requests by key ID', async () => { const rateLimit = underQuota() const consume = vi.spyOn(rateLimit, 'consume') const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp: createMppHandler(), rateLimit, }, policy: { apiKey: { scopes: ['data:read'] }, mpp: { session: { amount: '1' } }, }, }) const response = await app.request('/protected', { headers: { authorization: 'Payment paid', 'tempo-api-key': token, 'cf-connecting-ip': '203.0.113.10', }, }) expect(response.status).toBe(200) expect(consume).toHaveBeenCalledWith({ key: 'mpp:api_key:key_test', limit: { limit: 100, period: 'minute' }, }) }) test('uses the configured global paid-request limit', async () => { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp: createMppHandler({ rateLimit: { limit: 25, period: 'minute' } }), rateLimit: underQuota(), }, policy: { mpp: { session: { amount: '1' } } }, }) const response = await app.request('/protected', { headers: { authorization: 'Payment paid' }, }) expect(response.status).toBe(200) expect(response.headers.get('RateLimit-Limit')).toBe('25') }) test('route paid-request limit overrides the global limit', async () => { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp: createMppHandler({ rateLimit: { limit: 25, period: 'minute' } }), rateLimit: underQuota(), }, policy: { mpp: { rateLimit: { limit: 5, period: 'minute' }, session: { amount: '1' }, }, }, }) const response = await app.request('/protected', { headers: { authorization: 'Payment paid' }, }) expect(response.status).toBe(200) expect(response.headers.get('RateLimit-Limit')).toBe('5') }) test('rejects paid requests over quota before accepting payment', async () => { const onPayment = vi.fn() const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp: createMppHandler({ onPayment }), rateLimit: overQuota(), }, policy: { mpp: { session: { amount: '1' } } }, }) const response = await app.request('/protected', { headers: { authorization: 'Payment paid' }, }) expect(response.status).toBe(429) expect(response.headers.get('RateLimit-Limit')).toBe('100') expect((await response.json()).error.code).toBe('rate_limit_exceeded') expect(onPayment).not.toHaveBeenCalled() }) test('returns payment challenge for public over-quota requests', async () => { const mpp = createMppServer() const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp, rateLimit: overQuota(), }, policy: { mpp: { session: { amount: '1', description: 'Protected data' } }, public: { rateLimit: { limit: 1, period: 'minute' } }, }, }) const response = await app.request('/protected') expect(response.status).toMatchInlineSnapshot(`402`) expect( response.headers.get('www-authenticate')?.startsWith('Payment '), ).toMatchInlineSnapshot(`true`) }) test('accepts paid public retry without consuming public quota', async () => { const mpp = createMppServer() const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp, rateLimit: overQuotaExceptMpp(), }, policy: { mpp: { session: { amount: '1', description: 'Protected data' } }, public: { rateLimit: { limit: 1, period: 'minute' } }, }, }) const response = await createMppClient(app).fetch('http://tempo-api.test/protected') const body = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.has('payment-receipt')).toMatchInlineSnapshot(`true`) expect(normalizePaymentPayer(body)).toMatchInlineSnapshot(` { "id": "anonymous", "payment": { "payer": "did:pkh:eip155:...:0x...", "reason": "public_over_quota", "type": "mpp", }, "type": "public", } `) }) test('returns payment challenge for API-key over-quota requests', async () => { const mpp = createMppServer() const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp, rateLimit: overQuota(), }, policy: { apiKey: { rateLimit: { limit: 1, period: 'minute' }, scopes: ['data:read'], }, mpp: { session: { amount: '1', description: 'Protected data' } }, }, }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) expect(response.status).toMatchInlineSnapshot(`402`) expect( response.headers.get('www-authenticate')?.startsWith('Payment '), ).toMatchInlineSnapshot(`true`) }) test('accepts paid API-key overflow retry', async () => { const mpp = createMppServer() const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp, rateLimit: overQuotaExceptMpp(), }, policy: { apiKey: { rateLimit: { limit: 1, period: 'minute' }, scopes: ['data:read'], }, mpp: { session: { amount: '1', description: 'Protected data' } }, }, }) const response = await createMppClient(app).fetch('http://tempo-api.test/protected', { headers: { 'tempo-api-key': token, }, }) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.has('payment-receipt')).toMatchInlineSnapshot(`true`) expect(normalizePaymentPayer(body)).toMatchInlineSnapshot(` { "id": "key_test", "orgId": "org_test", "payment": { "payer": "did:pkh:eip155:...:0x...", "reason": "api_key_over_quota", "type": "mpp", }, "type": "api_key", } `) }) test('session challenge advertises the configured request defaults', async () => { const mpp = createMppServer() const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp, rateLimit: overQuota(), }, policy: { mpp: { session: { amount: '1', description: 'Protected data' } }, public: { rateLimit: { limit: 1, period: 'minute' } }, }, }) const response = await app.request('/protected') const challenge = Challenge.fromHeaders(response.headers) expect(response.status).toBe(402) expect(challenge.method).toBe('tempo') expect(challenge.intent).toBe('session') expect(challenge.realm).toBe('tempo-api-test') expect(challenge.description).toBe('Protected data') expect(challenge.request).toMatchObject({ // Wire amounts are raw token units: '1' at 6 decimals. amount: '1000000', currency: Tempo.currency, methodDetails: { chainId: Tempo.chain.id, escrowContract: '0x4d50500000000000000000000000000000000000', sessionProtocol: 'v2', }, recipient: Tempo.accounts[2].address, unitType: 'request', }) }) test('session challenge uses the explicit request chain', async () => { const mpp = createMppServer({ chainId: Viem.chainId.mainnet, getClient: ({ chainId = Viem.chainId.mainnet }) => Viem.getClient({ chainId }), }) const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp, rateLimit: overQuota(), }, policy: { mpp: { session: { amount: '1' } }, public: { rateLimit: { limit: 1, period: 'minute' } }, }, }) const defaultResponse = await app.request('/protected') const testnetResponse = await app.request('/protected?chainId=testnet') expect(Challenge.fromHeaders(defaultResponse.headers).request).toMatchObject({ methodDetails: { chainId: Viem.chainId.mainnet, }, }) expect(Challenge.fromHeaders(testnetResponse.headers).request).toMatchObject({ methodDetails: { chainId: Viem.chainId.testnet, }, }) }) test('session challenge rejects a chain outside the configured allowlist', async () => { const mpp = Object.assign( createMppServer({ chainId: Viem.chainId.mainnet, getClient: ({ chainId = Viem.chainId.mainnet }) => Viem.getClient({ chainId }), }), { sessionChainIds: new Set([Viem.chainId.mainnet, Viem.chainId.testnet]) }, ) const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp, rateLimit: overQuota(), }, policy: { mpp: { session: { amount: '1' } }, public: { rateLimit: { limit: 1, period: 'minute' } }, }, }) const response = await app.request('/protected?chainId=31318') const body = await response.json() expect(response.status).toBe(429) expect(response.headers.has('www-authenticate')).toBe(false) expect(body).toMatchObject({ error: { code: 'payment_required' } }) }) test('route session overrides propagate to the challenge', async () => { const mpp = createMppServer() const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp, rateLimit: overQuota(), }, policy: { mpp: { session: { amount: '2.5', suggestedDeposit: '10' } }, public: { rateLimit: { limit: 1, period: 'minute' } }, }, }) const response = await app.request('/protected') const challenge = Challenge.fromHeaders(response.headers) expect(response.status).toBe(402) expect(challenge.request).toMatchObject({ // Wire amounts are raw token units at 6 decimals. amount: '2500000', suggestedDeposit: '10000000', }) }) test('paid retry opens a funded on-chain channel', async () => { const mpp = createMppServer() const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp, rateLimit: overQuotaExceptMpp(), }, policy: { mpp: { session: { amount: '1', description: 'Protected data' } }, public: { rateLimit: { limit: 1, period: 'minute' } }, }, }) const response = await createMppClient(app).fetch('http://tempo-api.test/protected') expect(response.status).toBe(200) const receipt = sessionReceipt(response) expect(receipt.method).toBe('tempo') expect(receipt.intent).toBe('session') expect(receipt.status).toBe('success') expect(receipt.channelId).toMatch(/^0x[0-9a-fA-F]{64}$/) // One paid request at amount '1' with 6 decimals. expect(receipt.spent).toBe('1000000') expect(BigInt(receipt.acceptedCumulative)).toBeGreaterThanOrEqual(1_000_000n) const state = await Actions.channel.getStates(Tempo.client, { channel: receipt.channelId, }) // The client deposits the request amount when no deposit is suggested. expect(state.deposit).toBe(1_000_000n) // No settlement schedule configured, so nothing settles inline. expect(state.settled).toBe(0n) expect(state.closeRequestedAt).toBe(0) }) test('reuses the session channel across paid requests', async () => { const mpp = createMppServer() const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp, rateLimit: overQuotaExceptMpp(), }, policy: { // The suggested deposit pre-funds the channel for both requests. mpp: { session: { amount: '1', suggestedDeposit: '2' } }, public: { rateLimit: { limit: 1, period: 'minute' } }, }, }) const client = createMppClient(app) const first = await client.fetch('http://tempo-api.test/protected') const second = await client.fetch('http://tempo-api.test/protected') expect(first.status).toBe(200) expect(second.status).toBe(200) const receipt1 = sessionReceipt(first) const receipt2 = sessionReceipt(second) expect(receipt2.channelId).toBe(receipt1.channelId) expect(BigInt(receipt2.acceptedCumulative)).toBeGreaterThan( BigInt(receipt1.acceptedCumulative), ) expect(receipt2.spent).toBe('2000000') const state = await Actions.channel.getStates(Tempo.client, { channel: receipt1.channelId, }) expect(state.deposit).toBe(2_000_000n) }) test('reuses persisted session state after server restart', async () => { const { namespace } = TestStore.durableObjectNamespace() const createStore = () => Store.durableObject(namespace) let app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp: createMppServer({ store: createStore() }), rateLimit: overQuotaExceptMpp(), }, policy: { mpp: { session: { amount: '1', suggestedDeposit: '2' } }, public: { rateLimit: { limit: 1, period: 'minute' } }, }, }) const client = createMppClient({ fetch: (request) => app.fetch(request) }) const first = await client.fetch('http://tempo-api.test/protected') app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp: createMppServer({ store: createStore() }), rateLimit: overQuotaExceptMpp(), }, policy: { mpp: { session: { amount: '1', suggestedDeposit: '2' } }, public: { rateLimit: { limit: 1, period: 'minute' } }, }, }) const second = await client.fetch('http://tempo-api.test/protected') expect(first.status).toBe(200) expect(second.status).toBe(200) expect(sessionReceipt(second)).toMatchObject({ channelId: sessionReceipt(first).channelId, spent: '2000000', }) }) test('tops up the channel before signing a voucher that exceeds its deposit', async () => { const mpp = createMppServer() const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp, rateLimit: overQuotaExceptMpp(), }, policy: { mpp: { session: { amount: '1' } }, public: { rateLimit: { limit: 1, period: 'minute' } }, }, }) const client = createMppClient(app) // The opening deposit covers one request. The client tops up the channel // before signing the second voucher. const first = await client.fetch('http://tempo-api.test/protected') const second = await client.fetch('http://tempo-api.test/protected') expect(first.status).toBe(200) expect(second.status).toBe(200) const receipt1 = sessionReceipt(first) const receipt2 = sessionReceipt(second) expect(receipt2).toMatchObject({ channelId: receipt1.channelId, spent: '2000000', }) const state = await Actions.channel.getStates(Tempo.client, { channel: receipt1.channelId, }) expect(state.deposit).toBe(2_000_000n) }) test('rejects a malformed payment credential with a fresh challenge', async () => { const mpp = createMppServer() const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp, rateLimit: underQuota(), }, policy: { mpp: { session: { amount: '1' } }, public: { rateLimit: { limit: 1, period: 'minute' } }, }, }) const response = await app.request('/protected', { headers: { authorization: 'Payment not-a-credential' }, }) expect(response.status).toBe(402) expect(response.headers.get('www-authenticate')?.startsWith('Payment ')).toBe(true) }) }) describe('rateLimit', () => { test('allows anonymous public requests under quota', async () => { const app = createApp({ policy: { public: { rateLimit: { limit: 60, period: 'minute' } } }, }) const response = await app.request('/protected', { headers: { 'cf-connecting-ip': '203.0.113.10' }, }) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.get('ratelimit-limit')).toMatchInlineSnapshot(`"60"`) expect(response.headers.get('ratelimit-remaining')).toMatchInlineSnapshot(`"59"`) expect(body).toMatchInlineSnapshot(` { "id": "203.0.113.10", "type": "public", } `) }) test('returns rate-limit error without payment context', async () => { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, rateLimit: overQuota(), }, policy: { public: { rateLimit: { limit: 1, period: 'minute' } } }, }) const response = await app.request('/protected') const body = await response.json() expect(response.status).toMatchInlineSnapshot(`429`) expect(body).toMatchInlineSnapshot(` { "error": { "code": "rate_limit_exceeded", "message": "Rate limit exceeded", }, } `) }) test('fails open when the public rate-limit store errors', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}) try { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, rateLimit: { async consume() { throw new Error('Network connection lost.') }, }, }, policy: { public: { rateLimit: { limit: 1, period: 'minute' } } }, }) const response = await app.request('/protected', { headers: { 'cf-connecting-ip': '203.0.113.10' }, }) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.has('ratelimit-limit')).toMatchInlineSnapshot(`false`) expect(body).toMatchInlineSnapshot(` { "id": "203.0.113.10", "type": "public", } `) expect(error.mock.calls.length).toMatchInlineSnapshot(`1`) } finally { error.mockRestore() } }) test('fails open when the API-key rate-limit store errors', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}) try { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, rateLimit: { async consume() { throw new Error('Network connection lost.') }, }, }, }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.has('ratelimit-limit')).toMatchInlineSnapshot(`false`) expect(error.mock.calls.length).toMatchInlineSnapshot(`1`) } finally { error.mockRestore() } }) test('fails open (at warn) when the rate-limit store stalls past the timeout', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) vi.useFakeTimers() try { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, // A hot Durable Object under a storage-latency blip never returns; // the consume must not block the request indefinitely. rateLimit: { consume: () => new Promise(() => {}) }, }, policy: { public: { rateLimit: { limit: 1, period: 'minute' } } }, }) const pending = app.request('/protected', { headers: { 'cf-connecting-ip': '203.0.113.10' }, }) // Advance past the 300ms consume timeout so it fails open. await vi.advanceTimersByTimeAsync(300) const response = await pending const body = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.has('ratelimit-limit')).toMatchInlineSnapshot(`false`) expect(body).toMatchInlineSnapshot(` { "id": "203.0.113.10", "type": "public", } `) // A timeout is an expected degradation: warn, never error. expect(warn.mock.calls.length).toMatchInlineSnapshot(`1`) expect(error.mock.calls.length).toMatchInlineSnapshot(`0`) } finally { vi.useRealTimers() warn.mockRestore() error.mockRestore() } }) test('fails open (at warn) when the rate-limit Durable Object is overloaded', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, rateLimit: { async consume() { throw Object.assign( new Error('Durable Object is overloaded. Requests queued for too long.'), { overloaded: true }, ) }, }, }, policy: { public: { rateLimit: { limit: 1, period: 'minute' } } }, }) const response = await app.request('/protected', { headers: { 'cf-connecting-ip': '203.0.113.10' }, }) const body = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.has('ratelimit-limit')).toMatchInlineSnapshot(`false`) expect(body).toMatchInlineSnapshot(` { "id": "203.0.113.10", "type": "public", } `) expect(warn.mock.calls.length).toMatchInlineSnapshot(`1`) expect(error.mock.calls.length).toMatchInlineSnapshot(`0`) } finally { warn.mockRestore() error.mockRestore() } }) test('fails open (at warn) when the rate-limit Durable Object errors retryably', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, rateLimit: { async consume() { // A transient transport fault whose single retry also failed. throw Object.assign(new Error('Network connection lost.'), { retryable: true }) }, }, }, policy: { public: { rateLimit: { limit: 1, period: 'minute' } } }, }) const response = await app.request('/protected', { headers: { 'cf-connecting-ip': '203.0.113.10' }, }) expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.has('ratelimit-limit')).toMatchInlineSnapshot(`false`) expect(warn.mock.calls.length).toMatchInlineSnapshot(`1`) expect(error.mock.calls.length).toMatchInlineSnapshot(`0`) } finally { warn.mockRestore() error.mockRestore() } }) test('fails open (at warn) on a redacted Durable Object internal error', async () => { const error = vi.spyOn(console, 'error').mockImplementation(() => {}) const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}) try { const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, rateLimit: { async consume() { throw new Error(`internal error; reference = ${'ab12'.repeat(6)}`) }, }, }, policy: { public: { rateLimit: { limit: 1, period: 'minute' } } }, }) const response = await app.request('/protected', { headers: { 'cf-connecting-ip': '203.0.113.10' }, }) expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.has('ratelimit-limit')).toMatchInlineSnapshot(`false`) expect(warn.mock.calls.length).toMatchInlineSnapshot(`1`) expect(error.mock.calls.length).toMatchInlineSnapshot(`0`) } finally { warn.mockRestore() error.mockRestore() } }) }) describe('rateLimit per-scope', () => { test('consumes the route scope bucket and emits the scope header', async () => { const app = createScopedApp({ routes: [{ path: '/protected', policy: { apiKey: { scopes: ['data:read'] } } }], }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.get('ratelimit-scope')).toMatchInlineSnapshot(`"data:read"`) }) test('does not share quota across scopes', async () => { const auth = { apiKey: { rateLimits: { 'data:read': { limit: 1, period: 'minute' }, 'webhooks:write': { limit: 1, period: 'minute' }, }, resolve: TestApp.resolver({ keys: [{ ...key, scopes: [Scope.wildcard], token }], }), }, rateLimit: RateLimit.memory(), } satisfies Auth.Context const app = createScopedApp({ auth, routes: [ { path: '/read', policy: { apiKey: { scopes: ['data:read'] } } }, { path: '/write', policy: { apiKey: { scopes: ['webhooks:write'] } } }, ], }) await app.request('/read', { headers: { 'tempo-api-key': token } }) const readAgain = await app.request('/read', { headers: { 'tempo-api-key': token } }) const write = await app.request('/write', { headers: { 'tempo-api-key': token } }) // Exhausting the `read` bucket leaves the `write` bucket untouched. expect(readAgain.status).toMatchInlineSnapshot(`429`) expect(write.status).toMatchInlineSnapshot(`200`) expect(write.headers.get('ratelimit-scope')).toMatchInlineSnapshot(`"webhooks:write"`) }) test('uses the * bucket for a zero-scope route', async () => { const app = createScopedApp({ routes: [{ path: '/protected', policy: { apiKey: { scopes: [] } } }], }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.get('ratelimit-scope')).toMatchInlineSnapshot(`"*"`) }) test('buckets a multi-scope route by its first required scope', async () => { const app = createScopedApp({ routes: [ { path: '/protected', policy: { apiKey: { scopes: ['data:read', 'webhooks:write'] } } }, ], }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) expect(response.status).toMatchInlineSnapshot(`200`) expect(response.headers.get('ratelimit-scope')).toMatchInlineSnapshot(`"data:read"`) }) test('route cap overrides the configured scope limit', async () => { const auth = { apiKey: { rateLimits: { 'data:read': { limit: 1_000, period: 'minute' } }, resolve: TestApp.resolver({ keys: [{ ...key, scopes: [Scope.wildcard], token }], }), }, rateLimit: RateLimit.memory(), } satisfies Auth.Context const app = createScopedApp({ auth, routes: [ { path: '/protected', policy: { apiKey: { rateLimit: { limit: 5, period: 'minute' }, scopes: ['data:read'] }, }, }, ], }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) expect(response.headers.get('ratelimit-limit')).toMatchInlineSnapshot(`"5"`) expect(response.headers.get('ratelimit-scope')).toMatchInlineSnapshot(`"data:read"`) }) test('per-key default overrides config default but not a route cap', async () => { const resolve = TestApp.resolver({ keys: [ { ...key, rateLimits: { '*': { limit: 7, period: 'minute' } }, scopes: [Scope.wildcard], token, }, ], }) const open = createScopedApp({ auth: { apiKey: { rateLimits: { '*': { limit: 1_000, period: 'minute' } }, resolve }, rateLimit: RateLimit.memory(), }, routes: [{ path: '/protected', policy: { apiKey: { scopes: ['data:read'] } } }], }) const openResponse = await open.request('/protected', { headers: { 'tempo-api-key': token }, }) const capped = createScopedApp({ auth: { apiKey: { rateLimits: { '*': { limit: 1_000, period: 'minute' } }, resolve }, rateLimit: RateLimit.memory(), }, routes: [ { path: '/protected', policy: { apiKey: { rateLimit: { limit: 5, period: 'minute' }, scopes: ['data:read'] }, }, }, ], }) const cappedResponse = await capped.request('/protected', { headers: { 'tempo-api-key': token }, }) // Per-key `'*'` beats the config default (1000)… expect(openResponse.headers.get('ratelimit-limit')).toMatchInlineSnapshot(`"7"`) // …but a route's protective cap (5) still wins over it. expect(cappedResponse.headers.get('ratelimit-limit')).toMatchInlineSnapshot(`"5"`) }) test('per-key per-scope overrides everything including a route cap', async () => { const auth = { apiKey: { resolve: TestApp.resolver({ keys: [ { ...key, rateLimits: { 'data:read': { limit: 9, period: 'minute' } }, scopes: [Scope.wildcard], token, }, ], }), }, rateLimit: RateLimit.memory(), } satisfies Auth.Context const app = createScopedApp({ auth, routes: [ { path: '/protected', policy: { apiKey: { rateLimit: { limit: 5, period: 'minute' }, scopes: ['data:read'] }, }, }, ], }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) expect(response.headers.get('ratelimit-limit')).toMatchInlineSnapshot(`"9"`) }) test('emits the scope header alongside a 429 over-quota error', async () => { const entries: Log.Entry[] = [] const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, rateLimit: overQuota(), }, logger: (entry) => void entries.push(entry), policy: { apiKey: { rateLimit: { limit: 1, period: 'minute' }, scopes: ['data:read'] } }, }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) expect(response.status).toMatchInlineSnapshot(`429`) expect(response.headers.get('ratelimit-scope')).toMatchInlineSnapshot(`"data:read"`) expect(response.headers.get('ratelimit-limit')).toMatchInlineSnapshot(`"1"`) expect(entries[0]?.principal).toMatchInlineSnapshot(` { "billingActive": false, "environment": "production", "id": "key_test", "orgId": "org_test", "type": "api_key", } `) }) test('still returns a protocol-native payment challenge on overflow', async () => { const mpp = createMppServer() const app = createApp({ auth: { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp, rateLimit: overQuota(), }, policy: { apiKey: { rateLimit: { limit: 1, period: 'minute' }, scopes: ['data:read'] }, mpp: { session: { amount: '1', description: 'Protected data' } }, }, }) const response = await app.request('/protected', { headers: { 'tempo-api-key': token }, }) // The 402 challenge is owned by the payment protocol (mppx returns its own // Response), so rate-limit headers ride the 429 path, not the challenge. expect(response.status).toMatchInlineSnapshot(`402`) expect( response.headers.get('www-authenticate')?.startsWith('Payment '), ).toMatchInlineSnapshot(`true`) }) }) }) describe('ensureProject', () => { test('authorizes an organization-attributed API key', async () => { const db = TestApp.database() const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' }) const project = await Projects.create(db, { name: 'Test project', orgId: organization.id }) const response = await createProjectGuardApp( db, apiKeyPrincipal({ orgId: organization.id }), ).request(`/v1/projects/${project.id}/resource`) const { orgId, projectId, ...body } = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(orgId).toBe(organization.id) expect(projectId).toBe(project.id) expect(body).toMatchInlineSnapshot(` { "membership": null, } `) }) test('authorizes a project-attributed API key for its project', async () => { const db = TestApp.database() const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' }) const project = await Projects.create(db, { name: 'Test project', orgId: organization.id }) const response = await createProjectGuardApp( db, apiKeyPrincipal({ orgId: organization.id, projectId: project.id }), ).request(`/v1/projects/${project.id}/resource`) const { orgId, projectId, ...body } = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(orgId).toBe(organization.id) expect(projectId).toBe(project.id) expect(body).toMatchInlineSnapshot(` { "membership": null, } `) }) test('hides another project from a project-attributed API key', async () => { const db = TestApp.database() const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' }) const project = await Projects.create(db, { name: 'Test project', orgId: organization.id }) const response = await createProjectGuardApp( db, apiKeyPrincipal({ orgId: organization.id, projectId: 'prj_other' }), ).request(`/v1/projects/${project.id}/resource`) expect(response.status).toMatchInlineSnapshot(`404`) expect(await response.json()).toMatchInlineSnapshot(` { "error": { "code": "project_not_found", "message": "Project not found", }, } `) }) test('hides a flat project from an API key in another organization', async () => { const db = TestApp.database() const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' }) const project = await Projects.create(db, { name: 'Test project', orgId: organization.id }) const response = await createProjectGuardApp( db, apiKeyPrincipal({ orgId: 'org_foreign' }), ).request(`/v1/projects/${project.id}/resource`) expect(response.status).toMatchInlineSnapshot(`404`) expect(await response.json()).toMatchInlineSnapshot(` { "error": { "code": "project_not_found", "message": "Project not found", }, } `) }) test('resolves session membership for a flat project route', async () => { const db = TestApp.database() const user = await Users.upsertByAddress(db, { address: '0x0000000000000000000000000000000000000001', }) const organization = await Organizations.create(db, { name: 'Test org' }) await Memberships.create(db, { orgId: organization.id, role: 'member', userId: user.id }) const project = await Projects.create(db, { name: 'Test project', orgId: organization.id }) const response = await createProjectGuardApp(db, sessionPrincipal(user.id)).request( `/v1/projects/${project.id}/resource`, ) const { orgId, projectId, ...body } = await response.json() expect(response.status).toMatchInlineSnapshot(`200`) expect(orgId).toBe(organization.id) expect(projectId).toBe(project.id) expect(body).toMatchInlineSnapshot(` { "membership": "member", } `) }) test('preserves nested organization role authorization', async () => { const db = TestApp.database() const user = await Users.upsertByAddress(db, { address: '0x0000000000000000000000000000000000000001', }) const organization = await Organizations.create(db, { name: 'Test org' }) await Memberships.create(db, { orgId: organization.id, role: 'member', userId: user.id }) const project = await Projects.create(db, { name: 'Test project', orgId: organization.id }) const response = await createNestedProjectGuardApp(db, sessionPrincipal(user.id)).request( `/v1/orgs/${organization.id}/projects/${project.id}/resource`, ) expect(response.status).toMatchInlineSnapshot(`403`) expect(await response.json()).toMatchInlineSnapshot(` { "error": { "code": "forbidden", "message": "Requires the admin role", }, } `) }) test('hides another nested project from a project-attributed API key', async () => { const db = TestApp.database() const organization = await Organizations.create(db, { id: key.orgId, name: 'Test org' }) const project = await Projects.create(db, { name: 'Test project', orgId: organization.id }) const response = await createNestedProjectGuardApp( db, apiKeyPrincipal({ orgId: organization.id, projectId: 'prj_other' }), ).request(`/v1/orgs/${organization.id}/projects/${project.id}/resource`) expect(response.status).toMatchInlineSnapshot(`404`) expect(await response.json()).toMatchInlineSnapshot(` { "error": { "code": "project_not_found", "message": "Project not found", }, } `) }) }) function createScopedApp(options: { auth?: Auth.Context | undefined routes: readonly { path: string; policy: Auth.require.Policy }[] }) { const auth = options.auth ?? { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, scopes: [Scope.wildcard], token }] }), }, rateLimit: RateLimit.memory(), } const app = new Hono() app.use('*', async (c, next) => { c.set('auth', auth) await next() }) for (const route of options.routes) { app.use(route.path, Auth.require(route.policy)) app.get(route.path, (c) => c.json(serializePrincipal(Auth.getPrincipal(c)))) } return app } type GuardEnvironment = { Variables: Auth.Variables & { db: Db.Source } } function createProjectGuardApp(db: Db.Source, principal: Auth.Principal) { const app = new Hono() app.use('*', async (c, next) => { c.set('db', db) c.set('principal', principal) await next() }) app.get('/v1/projects/:projectId/resource', Auth.ensureProject(), projectGuardHandler) return app } function createNestedProjectGuardApp(db: Db.Source, principal: Auth.Principal) { const app = new Hono() app.use('*', async (c, next) => { c.set('db', db) c.set('principal', principal) await next() }) app.get( '/v1/orgs/:orgId/projects/:projectId/resource', Auth.ensureOrg({ role: 'admin' }), Auth.ensureProject(), projectGuardHandler, ) return app } function projectGuardHandler(c: Context) { return c.json({ membership: Auth.membership(c)?.role ?? null, orgId: Auth.org(c).id, projectId: Auth.project(c).id, }) } function apiKeyPrincipal(options: { orgId: string projectId?: string | undefined }): Auth.Principal { const apiKey = { ...key, orgId: options.orgId, ...(options.projectId === undefined ? {} : { projectId: options.projectId }), } return { apiKey, environment: apiKey.environment, id: apiKey.id, orgId: options.orgId, ...(options.projectId === undefined ? {} : { projectId: options.projectId }), type: 'api_key', } } function sessionPrincipal(id: string): Auth.Principal { return { id, identity: { provider: 'wallet', subject: '0x0000000000000000000000000000000000000001', }, type: 'session', } } type Options = { auth?: Auth.Context | undefined logger?: Log.Emit | undefined policy?: Auth.require.Policy | undefined } function createApp(options: Options = {}) { const auth = options.auth ?? { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, rateLimit: RateLimit.memory(), } const app = new Hono() if (options.logger) { app.use('*', requestId()) app.use('*', Log.middleware({ emit: options.logger })) } app.use('*', async (c, next) => { c.set('auth', auth) await next() }) app.use('/protected', Auth.require(options.policy ?? { apiKey: { scopes: ['data:read'] } })) app.get('/protected', (c) => c.json(serializePrincipal(Auth.getPrincipal(c)))) return app } function createMppServer(options: createMppServer.Options = {}) { return Mppx.create({ methods: [ tempo.session({ bootstrap: false, chainId: Tempo.chain.id, currency: Tempo.currency, decimals: 6, getClient: () => Tempo.client, recipient: Tempo.accounts[2].address, ...options, unitType: 'request', }), ], realm: 'tempo-api-test', secretKey: 'secret_test_key_0123456789abcdef', }) } declare namespace createMppServer { type Options = Pick< NonNullable[0]>, 'chainId' | 'getClient' | 'store' > } function createMppHandler( options: createMppHandler.Options = {}, ): NonNullable { return { ...(options.rateLimit ? { rateLimit: options.rateLimit } : {}), session: () => async (_c, next) => { options.onPayment?.() await next() }, } } declare namespace createMppHandler { type Options = { onPayment?: (() => void) | undefined rateLimit?: RateLimit.Limit | undefined } } /** Reads the session receipt attached to a paid response. */ function sessionReceipt(response: Response) { return Receipt.fromResponse(response) as Session.Precompile.Protocol.SessionReceipt } function overQuota(): RateLimit.Store { return { async consume(options) { return { allowed: false, limit: options.limit.limit, remaining: 0, reset: Math.ceil(Date.now() / 1_000) + 60, } }, } } function underQuota(): RateLimit.Store { return { async consume(options) { return { allowed: true, limit: options.limit.limit, remaining: Math.max(options.limit.limit - 1, 0), reset: Math.ceil(Date.now() / 1_000) + 60, } }, } } function overQuotaExceptMpp(): RateLimit.Store { return { async consume(options) { if (options.key.startsWith('mpp:')) return { allowed: true, limit: options.limit.limit, remaining: Math.max(options.limit.limit - 1, 0), reset: Math.ceil(Date.now() / 1_000) + 60, } return { allowed: false, limit: options.limit.limit, remaining: 0, reset: Math.ceil(Date.now() / 1_000) + 60, } }, } } function serializePrincipal(principal: Auth.Principal | null) { if (!principal) return null const payment = principal.payment ? { payment: principal.payment } : {} if (principal.type === 'api_key') return { id: principal.id, orgId: principal.orgId, ...payment, type: principal.type, } if (principal.type === 'super_admin') return { actor: principal.actor, id: principal.id, ...payment, type: principal.type, } return { id: principal.id, ...payment, type: principal.type, } } function normalizePaymentPayer(value: unknown) { if (!isRecord(value) || !isRecord(value['payment'])) return value // The payer account is generated from a random mnemonic each run. Keep the // chain-qualified DID shape in snapshots without pinning the address. return { ...value, payment: { ...value['payment'], payer: typeof value['payment']['payer'] === 'string' ? value['payment']['payer'].replace( /^did:pkh:eip155:\d+:0x[0-9a-fA-F]{40}$/, 'did:pkh:eip155:...:0x...', ) : value['payment']['payer'], }, } } function isRecord(value: unknown): value is Record { return !!value && typeof value === 'object' && !Array.isArray(value) }