import { serve } from '@hono/node-server' import * as Accounts from 'accounts/server' import { Hono, type Context } from 'hono' import { requestId } from 'hono/request-id' import { exportJWK, generateKeyPair } from 'jose' import { Challenge, Receipt, Store as MppStore } from 'mppx' import { Mppx, tempo } from 'mppx/hono' import { Session } from 'mppx/tempo' import { RateLimit, Store } from 'tapimo/server' import { createClient, custom, decodeFunctionData, isAddressEqual, maxUint256 } from 'viem' import { getTransaction } from 'viem/actions' import { Actions, Transaction } from 'viem/tempo' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import * as App from '../App.js' import * as ApiKey from '../ApiKey.js' import * as Management from '../apps/management/App.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('isolates an explicit endpoint bucket from the shared scope quota', async () => { const app = createScopedApp({ routes: [ { path: '/other', policy: { apiKey: { rateLimit: { limit: 1, period: 'minute' }, scopes: ['data:read'] }, }, }, { path: '/protected', policy: { apiKey: { bucket: 'protected', rateLimit: { limit: 1, period: 'minute' }, scopes: ['data:read'], }, }, }, ], }) const options = { headers: { 'tempo-api-key': token } } const other = await app.request('/other', options) const protected_ = await app.request('/protected', options) const overflow = await app.request('/protected', options) expect([other.status, protected_.status, overflow.status]).toEqual([200, 200, 429]) }) 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).toHaveBeenCalledTimes(1) 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('isolates an explicit endpoint bucket from unrelated paid requests', async () => { const auth = { apiKey: { resolve: TestApp.resolver({ keys: [{ ...key, token }] }) }, mpp: createMppHandler(), rateLimit: RateLimit.memory(), } satisfies Auth.Context const app = createScopedApp({ auth, routes: [ { path: '/other', policy: { mpp: { rateLimit: { limit: 1, period: 'minute' }, session: { amount: '1' } }, }, }, { path: '/protected', policy: { mpp: { bucket: 'protected', rateLimit: { limit: 1, period: 'minute' }, session: { amount: '1' }, }, }, }, ], }) const options = { headers: { authorization: 'Payment paid' } } const other = await app.request('/other', options) const protected_ = await app.request('/protected', options) const overflow = await app.request('/protected', options) expect([other.status, protected_.status, overflow.status]).toEqual([200, 200, 429]) }) 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 settles the accepted voucher on-chain', async () => { const mpp = createMppServer({ settlementSchedule: { units: 1 } }) 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) expect(state.settled).toBe(BigInt(receipt.acceptedCumulative)) expect(state.closeRequestedAt).toBe(0) const transaction = await getTransaction(Tempo.client, { hash: receipt.txHash! }) expect(transaction.nonceKey).toBe(maxUint256) expect(transaction.nonce).toBe(0) }) test('settles independent session channels concurrently', async () => { const mpp = createMppServer({ settlementSchedule: { units: 1 } }) 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 responses = await Promise.all([ createMppClient(app).fetch('http://tempo-api.test/protected'), createMppClient(app).fetch('http://tempo-api.test/protected'), ]) const receipts = responses.map(sessionReceipt) const transactions = await Promise.all( receipts.map((receipt) => getTransaction(Tempo.client, { hash: receipt.txHash! })), ) expect(responses.map((response) => response.status)).toEqual([200, 200]) expect(receipts[0]!.channelId).not.toBe(receipts[1]!.channelId) expect(transactions.map((transaction) => [transaction.nonceKey, transaction.nonce])).toEqual([ [maxUint256, 0], [maxUint256, 0], ]) }) test('rolls back the request charge when scheduled settlement fails', async () => { const failures: Session.Server.SessionSettlementFailureContext[] = [] const store = MppStore.memory() const mpp = createMppServer({ account: Tempo.accounts[3], onSessionSettlementFailure: (context) => { failures.push(context) }, recipient: Tempo.accounts[3].address, settlementSchedule: { units: 1 }, store, }) 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(402) expect(failures.length).toBeGreaterThan(0) for (const failure of failures) { expect(failure).toMatchObject({ chainId: Tempo.chain.id, outcome: 'failed', stage: 'settlement', trigger: 'scheduled', }) expect(failure.error).toBeInstanceOf(Error) } const channels = Session.Server.ChannelStore.fromStore(store) for (const channelId of new Set(failures.map(({ channelId }) => channelId))) { const channel = await channels.getChannel(channelId) expect({ spent: channel?.spent, units: channel?.units }).toEqual({ spent: 0n, units: 0 }) } }) test('leaves request charges untouched when settlement submission is ambiguous', async () => { const failures: Session.Server.SessionSettlementFailureContext[] = [] const store = MppStore.memory() const client = createClient({ account: Tempo.accounts[2], chain: Tempo.chain, transport: custom({ request(parameters) { if (parameters.method === 'eth_sendRawTransaction') { const transaction = Transaction.deserialize(parameters.params[0] as `0x76${string}`) const isSettlement = transaction.calls.some( (call) => call.to && call.data && isAddressEqual(call.to, Session.Precompile.Protocol.tip20ChannelEscrow) && decodeFunctionData({ abi: Session.Precompile.escrowAbi, data: call.data }) .functionName === 'settle', ) if (isSettlement) throw new Error('settlement submission timed out') } return Tempo.client.request(parameters as never) }, }), }) const mpp = createMppServer({ getClient: () => client, onSessionSettlementFailure: (context) => { failures.push(context) }, settlementSchedule: { units: 1 }, store, }) 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(402) expect(failures.length).toBeGreaterThan(0) const channels = Session.Server.ChannelStore.fromStore(store) for (const failure of failures) { expect(failure).toMatchObject({ chainId: Tempo.chain.id, outcome: 'ambiguous', stage: 'settlement', trigger: 'scheduled', }) await expect(channels.getChannel(failure.channelId)).resolves.toMatchObject({ spent: 0n, units: 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('isolates an explicit endpoint bucket from unrelated public requests', async () => { const app = createScopedApp({ routes: [ { path: '/other', policy: { public: { rateLimit: { limit: 1, period: 'minute' } } }, }, { path: '/protected', policy: { public: { bucket: 'protected', rateLimit: { limit: 1, period: 'minute' }, }, }, }, ], }) const other = await app.request('/other') const protected_ = await app.request('/protected') const overflow = await app.request('/protected') expect([other.status, protected_.status, overflow.status]).toEqual([200, 200, 429]) }) 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({ account: Tempo.accounts[2], 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]>, | 'account' | 'chainId' | 'getClient' | 'onSessionSettlementFailure' | 'recipient' | 'settlementSchedule' | '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) } const origin = 'https://api.example.com' const secret = 'test-better-auth-secret-at-least-32-characters' describe('Google sign-in', () => { test('resolves a Google session without an email sender', async () => { const db = TestApp.database() const app = TestApp.create({ db, session: { google: { clientId: 'google-client-id', clientSecret: 'google-client-secret', }, secret, wallet: { origin }, }, }) const now = new Date() const sessionToken = 'google-session-token' try { await db.kysely .insertInto('users') .values({ address: null, createdAt: now.toISOString(), email: 'developer@example.com', emailVerified: true, id: 'usr_google', image: null, name: 'Tempo Developer', updatedAt: now.toISOString(), }) .execute() await db.kysely .insertInto('auth_sessions') .values({ createdAt: now, expiresAt: new Date(now.getTime() + 60_000), id: 'ses_google', ipAddress: null, provider: 'google', token: sessionToken, updatedAt: now, userAgent: null, userId: 'usr_google', }) .execute() const sessionKey = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(secret), { hash: 'SHA-256', name: 'HMAC' }, false, ['sign'], ) const signature = await crypto.subtle.sign( 'HMAC', sessionKey, new TextEncoder().encode(sessionToken), ) const cookie = `${sessionToken}.${btoa(String.fromCharCode(...new Uint8Array(signature)))}` const response = await app.request(`${origin}/v1/me`, { headers: { cookie: `tempo_auth.session_token=${cookie}` }, }) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ email: 'developer@example.com', id: 'usr_google', }) } finally { await db.close() } }) test('uses a trusted forwarded Console origin for its callback', async () => { const consoleOrigin = 'https://console.example.com' const app = TestApp.create({ session: { google: { clientId: 'google-client-id', clientSecret: 'google-client-secret', }, secret, trustedOrigins: [consoleOrigin], wallet: { origin }, }, }) const response = await app.request(`${origin}/v1/auth/sign-in/social`, { body: JSON.stringify({ callbackURL: `${consoleOrigin}/`, provider: 'google' }), headers: { 'content-type': 'application/json', origin: consoleOrigin, 'x-forwarded-host': 'console.example.com', 'x-forwarded-proto': 'https', }, method: 'POST', }) expect(response.status).toBe(200) const body = (await response.json()) as { url: string } expect(new URL(body.url).searchParams.get('redirect_uri')).toBe( `${consoleOrigin}/v1/auth/callback/google`, ) }) }) describe('email OTP', () => { test.each([ { body: '{', name: 'malformed challenge JSON', path: '/v1/auth/siwe/challenge', }, { body: JSON.stringify([]), name: 'schema-invalid challenge JSON', path: '/v1/auth/siwe/challenge', }, { body: JSON.stringify({}), name: 'schema-invalid verification JSON', path: '/v1/auth/siwe' }, ])('validates $name', async ({ body, path }) => { const app = TestApp.create({ session: { secret, wallet: { origin } } }) const response = await app.request(`${origin}${path}`, { body, headers: { 'content-type': 'application/json' }, method: 'POST', }) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: { code: 'body_invalid', message: 'Invalid request body' }, }) }) test('leaves unmatched auth requests available to later routes', async () => { const app = TestApp.create({ session: { secret, wallet: { origin } }, }).route( '/', new Hono().post('/v1/auth/dev-oidc/token', async (c) => c.json({ idToken: 'test', request: await c.req.json() }), ), ) const response = await app.request( '/v1/auth/dev-oidc/token', json({ subject: 'developer@example.com' }), ) expect(response.status).toBe(200) expect(await response.json()).toEqual({ idToken: 'test', request: { subject: 'developer@example.com' }, }) }) test('keeps Better Auth routes out of OpenAPI while using the session scheme', async () => { const app = TestApp.create({ session: { secret, wallet: { origin } }, }) const spec = await (await app.request('/openapi.json')).json() expect(spec.paths['/v1/auth/email-otp/send-verification-otp']).toBeUndefined() expect(spec.paths['/v1/auth/sign-in/email-otp']).toBeUndefined() expect(spec.paths['/v1/auth/sign-out']).toBeUndefined() expect(spec.paths['/v1/auth/email-otp']).toBeUndefined() expect(spec.paths['/v1/auth/siwe/logout']).toBeUndefined() expect(spec.components.securitySchemes.emailSession).toBeUndefined() expect(spec.components.securitySchemes.session).toMatchObject({ in: 'header', name: 'Cookie', type: 'apiKey', }) expect(spec.paths['/v1/auth/logout'].post.responses['204']).toBeDefined() expect(spec.paths['/v1/auth/logout'].post.security).toEqual([]) expect(spec.paths['/v1/me'].get.security).toContainEqual({ session: [] }) }) test('sends email OTP without the management group', async () => { const db = TestApp.database() const messages: App.Email.Message[] = [] const app = App.create({ auth: { session: { secret, wallet: { origin } } }, db, email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, kv: { store: Store.memory() }, }) try { const response = await app.request( `${origin}/v1/auth/email-otp/send-verification-otp`, json({ email: 'developer@example.com', type: 'sign-in' }), ) expect(response.status).toBe(200) expect(messages).toHaveLength(1) } finally { await db.close() } }) test('accepts the legacy management email option', async () => { const db = TestApp.database() const messages: App.Email.Message[] = [] const app = App.create({ auth: { session: { secret, wallet: { origin } } }, db, kv: { store: Store.memory() }, }).route( '/', Management.management({ email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, }), ) try { const response = await app.request( `${origin}/v1/auth/email-otp/send-verification-otp`, json({ email: 'developer@example.com', type: 'sign-in' }), ) expect(response.status).toBe(200) expect(messages).toHaveLength(1) } finally { await db.close() } }) test('creates a canonical Tempo user and Better Auth session', async () => { const db = TestApp.database() const messages: App.Email.Message[] = [] const app = TestApp.create({ db, email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, session: { secret, wallet: { origin }, }, }) try { const sent = await app.request( `${origin}/v1/auth/email-otp/send-verification-otp`, json({ email: 'Developer@Example.com', type: 'sign-in' }), ) expect(sent.status).toBe(200) expect(await sent.json()).toEqual({ success: true }) expect(messages).toHaveLength(1) const otp = messages[0]!.text.match(/\b\d{6}\b/)?.[0] expect(otp).toBeDefined() expect(messages[0]).toMatchObject({ from: 'noreply@example.com', subject: `${otp} is your Tempo sign-in code`, to: 'developer@example.com', }) const verification = await db.kysely .selectFrom('auth_verifications') .select(['value']) .executeTakeFirstOrThrow() expect(verification.value).not.toContain(otp!) const verified = await app.request( `${origin}/v1/auth/sign-in/email-otp`, json({ email: 'developer@example.com', otp }), ) expect(verified.status).toBe(200) expect(await verified.json()).toMatchObject({ user: { email: 'developer@example.com' } }) const existing = await Users.getByEmail(db, 'developer@example.com') expect(existing).toBeDefined() const setCookie = verified.headers.get('set-cookie') expect(setCookie).toContain('tempo_auth.session_token=') expect(setCookie).toContain('Max-Age=86400') expect(setCookie).toContain('Path=/') expect(setCookie).toContain('HttpOnly') expect(setCookie).toContain('Secure') expect(setCookie).toContain('SameSite=Lax') const session = await app.request(`${origin}/v1/auth/get-session`, { headers: { cookie: setCookie!.split(';', 1)[0]! }, }) expect(await session.json()).toMatchObject({ session: { provider: 'email' } }) const response = await app.request(`${origin}/v1/me`, { headers: { cookie: setCookie!.split(';', 1)[0]! }, }) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ email: 'developer@example.com', id: existing!.id, }) const users = await db.kysely.selectFrom('users').selectAll().execute() expect(users).toHaveLength(1) expect(users[0]).toMatchObject({ email: 'developer@example.com', emailVerified: true, }) expect(users[0]!.createdAt).toMatch(/Z$/) expect(users[0]!.updatedAt).toMatch(/Z$/) } finally { await db.close() } }) test('selects the oldest user for a duplicate email', async () => { const db = TestApp.database() const messages: App.Email.Message[] = [] const app = TestApp.create({ db, email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, session: { secret, wallet: { origin } }, }) try { await db.kysely .insertInto('users') .values([ { address: null, createdAt: '2025-01-01T00:00:00.000Z', email: 'developer@example.com', emailVerified: true, id: 'usr_duplicate_oldest', image: null, name: 'Oldest user', updatedAt: '2025-01-01T00:00:00.000Z', }, { address: null, createdAt: '2026-01-01T00:00:00.000Z', email: 'developer@example.com', emailVerified: true, id: 'usr_duplicate_newest', image: null, name: 'Newest user', updatedAt: '2026-01-01T00:00:00.000Z', }, ]) .execute() const cookie = await signInEmail(app, { email: 'developer@example.com', messages, }) const response = await app.request(`${origin}/v1/me`, { headers: { cookie }, }) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ id: 'usr_duplicate_oldest' }) } finally { await db.close() } }) test('mounts the Better Auth handler surface', async () => { const app = TestApp.create({ session: { secret, wallet: { origin } }, }) const response = await app.request(`${origin}/v1/auth/get-session`) expect(response.status).toBe(200) expect(await response.json()).toBeNull() }) test('mounts Better Auth under the app base path', async () => { const app = TestApp.create({ path: '/api', session: { secret, wallet: { origin } }, }) const response = await app.request(`${origin}/api/v1/auth/get-session`) expect(response.status).toBe(200) expect(await response.json()).toBeNull() }) test('handles SIWE routes under an application path', async () => { const app = TestApp.create({ path: '/api', session: { secret, wallet: { origin } }, }) const account = privateKeyToAccount(generatePrivateKey()) const { verifyResponse } = await TestApp.signIn( { request: (path, init) => app.request(`/api${path}`, init) }, account, ) expect(verifyResponse.status).toBe(200) }) test('returns the canonical error envelope when the SIWE store fails', async () => { const entries: Log.Entry[] = [] const app = TestApp.create({ kv: { store: Store.from({ ...Store.memory(), async put() { throw new Error('Session store unavailable') }, }), }, logger: (entry) => void entries.push(entry), session: { secret, wallet: { origin } }, }) const response = await app.request(`${origin}/v1/auth/siwe/challenge`, json({})) expect(response.status).toBe(500) expect(await response.json()).toMatchObject({ error: { code: 'internal_error', message: 'Internal server error' }, }) expect(entries.at(-1)).toMatchObject({ errorCode: 'internal_error', level: 'error' }) }) test('does not mount Better Auth without session authentication', async () => { const app = TestApp.create({ auth: false }) const response = await app.request(`${origin}/v1/auth/get-session`) expect(response.status).toBe(404) }) test('signs out through the Better Auth handler', async () => { const messages: App.Email.Message[] = [] const app = TestApp.create({ email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, session: { secret, wallet: { origin } }, }) const cookie = await signInEmail(app, { email: 'developer@example.com', messages, }) const response = await app.request(`${origin}/v1/auth/sign-out`, { headers: { cookie }, method: 'POST', }) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ success: true }) expect(response.headers.get('set-cookie')).toContain('tempo_auth.session_token=') expect( await app.request(`${origin}/v1/me`, { headers: { cookie }, }), ).toMatchObject({ status: 401 }) }) test('rejects unsupported OTP purposes before delivery', async () => { const messages: App.Email.Message[] = [] const app = TestApp.create({ email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, session: { secret, wallet: { origin } }, }) const response = await app.request( `${origin}/v1/auth/email-otp/send-verification-otp`, json({ email: 'developer@example.com', type: 'email-verification' }), ) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: { code: 'body_invalid' } }) expect(messages).toHaveLength(0) }) test('signs out Better Auth through the canonical logout route', async () => { const messages: App.Email.Message[] = [] const app = TestApp.create({ email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, session: { secret, wallet: { origin } }, }) const cookie = await signInEmail(app, { email: 'developer@example.com', messages, }) const response = await app.request(`${origin}/v1/auth/logout`, { headers: { cookie }, method: 'POST', }) expectSessionsCleared(response) expect( await app.request(`${origin}/v1/me`, { headers: { cookie }, }), ).toMatchObject({ status: 401 }) }) test('signs out wallet sessions without email configuration', async () => { const app = TestApp.create({ session: { secret, wallet: { origin } } }) const account = privateKeyToAccount(generatePrivateKey()) const { cookie } = await TestApp.signIn(app, account) const response = await app.request(`${origin}/v1/auth/logout`, { headers: { cookie: cookie! }, method: 'POST', }) expectSessionsCleared(response) expect( await app.request(`${origin}/v1/me`, { headers: { cookie: cookie! }, }), ).toMatchObject({ status: 401 }) }) test('reports wallet session-store logout failures', async () => { const entries: Log.Entry[] = [] const source = Store.memory() let failDeletes = false const app = TestApp.create({ kv: { store: Store.from({ ...source, async delete(key) { if (failDeletes) throw new Error('Session store unavailable') await source.delete(key) }, }), }, logger: (entry) => void entries.push(entry), session: { secret, wallet: { origin } }, }) const account = privateKeyToAccount(generatePrivateKey()) const { cookie } = await TestApp.signIn(app, account) entries.length = 0 failDeletes = true const response = await app.request(`${origin}/v1/auth/logout`, { headers: { cookie: cookie! }, method: 'POST', }) expect(response.status).toBe(500) expect(await response.json()).toMatchObject({ error: { code: 'session_logout_failed', message: 'Session logout failed' }, }) expect(entries).toHaveLength(1) expect(entries[0]).toMatchObject({ errorCode: 'session_logout_failed', level: 'error' }) }) test('does not resolve email sessions when email is unconfigured', async () => { const db = TestApp.database() const messages: App.Email.Message[] = [] const email = TestApp.create({ db, email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, session: { secret, wallet: { origin } }, }) const cookie = await signInEmail(email, { email: 'developer@example.com', messages, }) const wallet = TestApp.create({ db, session: { secret, wallet: { origin } }, }) try { const unauthorized = await wallet.request(`${origin}/v1/me`, { headers: { cookie }, }) expect(unauthorized.status).toBe(401) const logout = await wallet.request(`${origin}/v1/auth/logout`, { headers: { cookie }, method: 'POST', }) expectSessionsCleared(logout) const session = await email.request(`${origin}/v1/auth/get-session`, { headers: { cookie }, }) expect(await session.json()).toBeNull() } finally { await db.close() } }) test('clears invalid sessions idempotently', async () => { const app = TestApp.create({ session: { secret, wallet: { origin } } }) const response = await app.request(`${origin}/v1/auth/logout`, { headers: { cookie: 'accounts_auth=expired; tempo_auth.session_token=expired', }, method: 'POST', }) expectSessionsCleared(response) }) test('reports session database failures', async () => { const db = TestApp.database() const messages: App.Email.Message[] = [] const app = TestApp.create({ db, email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, session: { secret, wallet: { origin } }, }) const cookie = await signInEmail(app, { email: 'developer@example.com', messages, }) await db.close() const response = await app.request(`${origin}/v1/me`, { headers: { cookie }, }) expect(response.status).toBe(500) expect(await response.json()).toMatchObject({ error: { code: 'internal_error' } }) }) test('rejects consumed and superseded codes without revealing the cause', async () => { const db = TestApp.database() const messages: App.Email.Message[] = [] const app = TestApp.create({ db, email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, session: { secret, wallet: { origin } }, }) try { await app.request( `${origin}/v1/auth/email-otp/send-verification-otp`, json({ email: 'developer@example.com', type: 'sign-in' }), ) const first = messages[0]!.text.match(/\b\d{6}\b/)![0]! await app.request( `${origin}/v1/auth/email-otp/send-verification-otp`, json({ email: 'developer@example.com', type: 'sign-in' }), ) const second = messages[1]!.text.match(/\b\d{6}\b/)![0]! const superseded = await app.request( `${origin}/v1/auth/sign-in/email-otp`, json({ email: 'developer@example.com', otp: first }), ) expect(superseded.status).toBe(400) expect(await superseded.json()).toMatchObject({ code: 'INVALID_OTP' }) const verified = await app.request( `${origin}/v1/auth/sign-in/email-otp`, json({ email: 'developer@example.com', otp: second }), ) expect(verified.status).toBe(200) const consumed = await app.request( `${origin}/v1/auth/sign-in/email-otp`, json({ email: 'developer@example.com', otp: second }), ) expect(consumed.status).toBe(400) expect(await consumed.json()).toMatchObject({ code: 'INVALID_OTP' }) } finally { await db.close() } }) test('rejects expired codes through Better Auth', async () => { const db = TestApp.database() const messages: App.Email.Message[] = [] const app = TestApp.create({ db, email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, session: { secret, wallet: { origin } }, }) try { await app.request( `${origin}/v1/auth/email-otp/send-verification-otp`, json({ email: 'developer@example.com', type: 'sign-in' }), ) const otp = messages[0]!.text.match(/\b\d{6}\b/)![0]! await db.kysely .updateTable('auth_verifications') .set({ expiresAt: new Date(0) }) .execute() const response = await app.request( `${origin}/v1/auth/sign-in/email-otp`, json({ email: 'developer@example.com', otp }), ) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ code: 'OTP_EXPIRED' }) } finally { await db.close() } }) test('uses Better Auth rate limits across request-scoped instances', async () => { const app = TestApp.create({ auth: { clientIp: (request) => request.headers.get('x-trusted-ip') ?? undefined }, email: { from: 'noreply@example.com', send: async () => {} }, rateLimit: { now: () => new Date('2026-01-01T00:00:00.000Z') }, session: { secret, wallet: { origin } }, }) for (let index = 0; index < 3; index++) { const response = await app.request(`${origin}/v1/auth/email-otp/send-verification-otp`, { ...json({ email: `developer-${index}@example.com`, type: 'sign-in' }), headers: { 'cf-connecting-ip': `198.51.100.${index}`, 'content-type': 'application/json', 'x-trusted-ip': '203.0.113.10', }, }) expect(response.status).toBe(200) } const response = await app.request(`${origin}/v1/auth/email-otp/send-verification-otp`, { ...json({ email: 'developer-3@example.com', type: 'sign-in' }), headers: { 'cf-connecting-ip': '198.51.100.4', 'content-type': 'application/json', 'x-trusted-ip': '203.0.113.10', }, }) expect(response.status).toBe(429) expect(response.headers.get('x-retry-after')).toBe('60') expect(await response.json()).toMatchObject({ message: expect.any(String) }) }) test('limits verification attempts', async () => { const messages: App.Email.Message[] = [] const app = TestApp.create({ email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, session: { secret, wallet: { origin } }, }) await app.request( `${origin}/v1/auth/email-otp/send-verification-otp`, json({ email: 'developer@example.com', type: 'sign-in' }), ) const otp = messages[0]!.text.match(/\b\d{6}\b/)![0]! const wrong = `${otp[0] === '0' ? '1' : '0'}${otp.slice(1)}` for (let index = 0; index < 3; index++) { const response = await app.request( `${origin}/v1/auth/sign-in/email-otp`, json({ email: 'developer@example.com', otp: wrong }), ) expect(response.status).toBe(400) } const response = await app.request( `${origin}/v1/auth/sign-in/email-otp`, json({ email: 'developer@example.com', otp: wrong }), ) expect(response.status).toBe(429) expect(await response.json()).toMatchObject({ message: expect.any(String) }) }) test('accepts matching email and wallet sessions', async () => { const db = TestApp.database() const messages: App.Email.Message[] = [] const app = TestApp.create({ db, email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, session: { secret, wallet: { origin } }, }) try { const account = privateKeyToAccount(generatePrivateKey()) const wallet = await TestApp.signIn(app, account) expect(wallet.verifyResponse.status).toBe(200) const user = await Users.getByAddress(db, account.address) expect(user).toBeDefined() await Users.setEmail(db, user!.id, 'developer@example.com') const email = await signInEmail(app, { email: 'developer@example.com', messages, }) const response = await app.request(`${origin}/v1/me`, { headers: { cookie: `${email}; ${wallet.cookie}` }, }) expect(response.status).toBe(200) expect(await response.json()).toMatchObject({ email: 'developer@example.com', id: user!.id }) const logout = await app.request(`${origin}/v1/auth/logout`, { headers: { cookie: `${email}; ${wallet.cookie}` }, method: 'POST', }) expect(logout.status).toBe(204) expect( await app.request(`${origin}/v1/me`, { headers: { cookie: email }, }), ).toMatchObject({ status: 401 }) expect( await app.request(`${origin}/v1/me`, { headers: { cookie: wallet.cookie! }, }), ).toMatchObject({ status: 401 }) } finally { await db.close() } }) test('rejects conflicting email and wallet sessions', async () => { const db = TestApp.database() const messages: App.Email.Message[] = [] const app = TestApp.create({ db, email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, session: { secret, wallet: { origin }, }, }) try { const account = privateKeyToAccount(generatePrivateKey()) const wallet = await TestApp.signIn(app, account) expect(wallet.verifyResponse.status).toBe(200) const email = await signInEmail(app, { email: 'developer@example.com', messages, }) const response = await app.request(`${origin}/v1/me`, { headers: { cookie: `${email}; ${wallet.cookie}` }, }) expect(response.status).toBe(401) const logout = await app.request(`${origin}/v1/auth/logout`, { headers: { cookie: `${email}; ${wallet.cookie}` }, method: 'POST', }) expectSessionsCleared(logout) const emailSession = await app.request(`${origin}/v1/auth/get-session`, { headers: { cookie: email }, }) expect(emailSession.status).toBe(200) expect(await emailSession.json()).toBeNull() expect( await app.request(`${origin}/v1/me`, { headers: { cookie: email }, }), ).toMatchObject({ status: 401 }) expect( await app.request(`${origin}/v1/me`, { headers: { cookie: wallet.cookie! }, }), ).toMatchObject({ status: 401 }) } finally { await db.close() } }) test('signs out conflicting wallet bearer and email cookie sessions', async () => { const db = TestApp.database() const messages: App.Email.Message[] = [] const app = TestApp.create({ db, email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, session: { secret, wallet: { origin } }, }) try { const account = privateKeyToAccount(generatePrivateKey()) const wallet = await TestApp.signIn(app, account, { returnToken: true }) expect(wallet.verifyResponse.status).toBe(200) const { token } = (await wallet.verifyResponse.json()) as { token: string } const email = await signInEmail(app, { email: 'developer@example.com', messages, }) const response = await app.request(`${origin}/v1/me`, { headers: { authorization: `Bearer ${token}`, cookie: email }, }) expect(response.status).toBe(401) const logout = await app.request(`${origin}/v1/auth/logout`, { headers: { authorization: `Bearer ${token}`, cookie: email }, method: 'POST', }) expectSessionsCleared(logout) expect( await app.request(`${origin}/v1/me`, { headers: { cookie: email }, }), ).toMatchObject({ status: 401 }) expect( await app.request(`${origin}/v1/me`, { headers: { authorization: `Bearer ${token}` }, }), ).toMatchObject({ status: 401 }) } finally { await db.close() } }) test('signs out wallet cookies when a bearer credential is also present', async () => { const app = TestApp.create({ session: { secret, wallet: { origin } } }) const account = privateKeyToAccount(generatePrivateKey()) const wallet = await TestApp.signIn(app, account) const logout = await app.request(`${origin}/v1/auth/logout`, { headers: { authorization: 'Bearer unrelated-credential', cookie: wallet.cookie!, }, method: 'POST', }) expectSessionsCleared(logout) expect( await app.request(`${origin}/v1/me`, { headers: { cookie: wallet.cookie! }, }), ).toMatchObject({ status: 401 }) }) test('rate limits unauthenticated logout requests', async () => { const app = TestApp.create({ session: { secret, wallet: { origin } } }) for (let index = 0; index < 60; index++) expect( await app.request(`${origin}/v1/auth/logout`, { method: 'POST', }), ).toMatchObject({ status: 204 }) const response = await app.request(`${origin}/v1/auth/logout`, { method: 'POST', }) expect(response.status).toBe(429) expect(await response.json()).toMatchObject({ error: { code: 'rate_limit_exceeded' } }) }) }) describe('one-time session tokens', () => { test('redeems the current Better Auth session once', async () => { const db = TestApp.database() const messages: App.Email.Message[] = [] const app = TestApp.create({ db, email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, session: { secret, wallet: { origin } }, }) try { const cookie = await signInEmail(app, { email: 'developer@example.com', messages, }) const generated = await app.request(`${origin}/v1/auth/one-time-token/generate`, { headers: { cookie }, }) const { token } = (await generated.json()) as { token: string } const redeem = () => app.request(`${origin}/v1/auth/one-time-token/verify`, json({ token })) expect(generated.status).toBe(200) const first = await redeem() expect(first.status).toBe(200) expect(first.headers.get('set-cookie')).toBeNull() expect(await first.json()).toMatchObject({ user: { email: 'developer@example.com', emailVerified: true }, }) expect((await redeem()).status).toBe(400) } finally { await db.close() } }) }) describe('identity sign-in', () => { test('rejects cross-site form bodies', async () => { const app = TestApp.create({ session: { identity: { audience: origin }, secret, wallet: { origin } }, }) const response = await app.request(`${origin}/v1/auth/identity`, { body: JSON.stringify({ idToken: 'attacker-token', padding: '' }), headers: { 'content-type': 'text/plain', origin: 'https://attacker.example.com', }, method: 'POST', }) expect(response.status).toBe(400) expect(await response.json()).toMatchObject({ error: { code: 'body_invalid', message: 'Invalid request body' }, }) expect(response.headers.get('set-cookie')).toBeNull() }) test('verifies a Wallet identity and creates an API session', async () => { const db = TestApp.database() const issuer = await createIdentityIssuer({ email: 'developer@example.com' }) const account = privateKeyToAccount(generatePrivateKey()) const app = TestApp.create({ db, session: { identity: { audience: origin, issuer: issuer.url }, secret, wallet: { origin }, }, }) try { const response = await app.request( `${origin}/v1/auth/identity`, json({ idToken: await issuer.mint({ address: account.address, audience: origin }) }), ) const cookie = response.headers.get('set-cookie')?.split(';', 1)[0] expect(response.status).toBe(200) expect(cookie).toMatch(/^tempo_identity=/) expect(response.headers.get('set-cookie')).toContain('Max-Age=86400') expect(response.headers.get('set-cookie')).toContain('HttpOnly') expect(response.headers.get('set-cookie')).toContain('SameSite=Lax') expect(response.headers.get('set-cookie')).toContain('Secure') const me = await app.request(`${origin}/v1/me`, { headers: { cookie: cookie! } }) expect(me.status).toBe(200) expect(await me.json()).toMatchObject({ address: account.address.toLowerCase(), email: 'developer@example.com', }) const logout = await app.request(`${origin}/v1/auth/logout`, { headers: { cookie: cookie! }, method: 'POST', }) expect(logout.status).toBe(204) expect(logout.headers.getSetCookie()).toEqual( expect.arrayContaining([expect.stringContaining('tempo_identity=')]), ) expect(await app.request(`${origin}/v1/me`, { headers: { cookie: cookie! } })).toMatchObject({ status: 401, }) } finally { await issuer.close() await db.close() } }) test('clears the identity cookie when session revocation fails', async () => { const db = TestApp.database() const issuer = await createIdentityIssuer({ email: 'developer@example.com' }) const source = Store.memory() let failDeletes = false const app = TestApp.create({ db, kv: { store: Store.from({ ...source, async delete(key) { if (failDeletes) throw new Error('Session store unavailable') await source.delete(key) }, }), }, session: { identity: { audience: origin, issuer: issuer.url }, secret, wallet: { origin }, }, }) const account = privateKeyToAccount(generatePrivateKey()) try { const identity = await app.request( `${origin}/v1/auth/identity`, json({ idToken: await issuer.mint({ address: account.address, audience: origin }) }), ) const cookie = identity.headers.get('set-cookie')?.split(';', 1)[0] failDeletes = true const logout = await app.request(`${origin}/v1/auth/logout`, { headers: { cookie: cookie! }, method: 'POST', }) expect(logout.status).toBe(500) expect(logout.headers.getSetCookie()).toEqual( expect.arrayContaining([expect.stringMatching(/^tempo_identity=; Max-Age=0;/)]), ) } finally { await issuer.close() await db.close() } }) test('does not resolve identity sessions when identity is unconfigured', async () => { const db = TestApp.database() const issuer = await createIdentityIssuer({ email: 'developer@example.com' }) const store = Store.memory() const configured = TestApp.create({ db, kv: { store }, session: { identity: { audience: origin, issuer: issuer.url }, secret, wallet: { origin }, }, }) const unconfigured = TestApp.create({ db, kv: { store }, session: { secret, wallet: { origin } }, }) const account = privateKeyToAccount(generatePrivateKey()) try { const identity = await configured.request( `${origin}/v1/auth/identity`, json({ idToken: await issuer.mint({ address: account.address, audience: origin }) }), ) const cookie = identity.headers.get('set-cookie')?.split(';', 1)[0] const response = await unconfigured.request(`${origin}/v1/me`, { headers: { cookie: cookie! }, }) expect(identity.status).toBe(200) expect(response.status).toBe(401) } finally { await issuer.close() await db.close() } }) test('reports identity issuer failures', async () => { const db = TestApp.database() const issuer = await createIdentityIssuer({ email: 'developer@example.com' }) const account = privateKeyToAccount(generatePrivateKey()) const idToken = await issuer.mint({ address: account.address, audience: origin }) const entries: Log.Entry[] = [] const app = TestApp.create({ db, logger: (entry) => void entries.push(entry), session: { identity: { audience: origin, issuer: issuer.url }, secret, wallet: { origin }, }, }) await issuer.close() try { const response = await app.request(`${origin}/v1/auth/identity`, json({ idToken })) expect(response.status).toBe(500) expect(await response.json()).toMatchObject({ error: { code: 'internal_error', message: 'Internal server error' }, }) expect(entries.at(-1)).toMatchObject({ errorCode: 'internal_error', level: 'error' }) } finally { await db.close() } }) test('reconciles the Wallet address into an active email session', async () => { const db = TestApp.database() const issuer = await createIdentityIssuer({ email: 'developer@example.com' }) const messages: App.Email.Message[] = [] const account = privateKeyToAccount(generatePrivateKey()) const app = TestApp.create({ db, email: { from: 'noreply@example.com', send: async (message) => void messages.push(message), }, session: { identity: { audience: origin, issuer: issuer.url }, secret, wallet: { origin }, }, }) try { const emailCookie = await signInEmail(app, { email: 'developer@example.com', messages, }) const identity = await app.request( `${origin}/v1/auth/identity`, json({ idToken: await issuer.mint({ address: account.address, audience: origin }) }), ) const identityCookie = identity.headers.get('set-cookie')?.split(';', 1)[0] const me = await app.request(`${origin}/v1/me`, { headers: { cookie: `${emailCookie}; ${identityCookie}` }, }) expect(identity.status).toBe(200) expect(await me.json()).toMatchObject({ address: account.address.toLowerCase(), email: 'developer@example.com', }) } finally { await issuer.close() await db.close() } }) test('rejects an identity issued for another audience', async () => { const issuer = await createIdentityIssuer({ email: 'developer@example.com' }) const account = privateKeyToAccount(generatePrivateKey()) const app = TestApp.create({ session: { identity: { audience: origin, issuer: issuer.url }, secret, wallet: { origin }, }, }) try { const response = await app.request( `${origin}/v1/auth/identity`, json({ idToken: await issuer.mint({ address: account.address, audience: 'https://attacker.example.com', }), }), ) expect(response.status).toBe(401) expect(await response.json()).toMatchObject({ error: { code: 'identity_invalid', message: 'Invalid identity token' }, }) expect(response.headers.get('set-cookie')).toBeNull() } finally { await issuer.close() } }) test('documents identity sign-in only when configured', async () => { const configured = TestApp.create({ session: { identity: { audience: origin }, secret, wallet: { origin } }, }) const unconfigured = TestApp.create({ session: { secret, wallet: { origin } } }) const configuredSpec = await (await configured.request('/openapi.json')).json() const unconfiguredSpec = await (await unconfigured.request('/openapi.json')).json() expect(configuredSpec.paths['/v1/auth/identity'].post).toBeDefined() expect(unconfiguredSpec.paths['/v1/auth/identity']).toBeUndefined() }) }) function json(body: unknown) { return { body: JSON.stringify(body), headers: { 'content-type': 'application/json' }, method: 'POST', } } function expectSessionsCleared(response: globalThis.Response) { expect(response.status).toBe(204) expect(response.headers.getSetCookie()).toEqual( expect.arrayContaining([ expect.stringContaining('accounts_auth='), expect.stringContaining('tempo_auth.session_token='), ]), ) } /** Local Wallet-style OIDC issuer for identity verification tests. */ async function createIdentityIssuer(options: createIdentityIssuer.Options) { const { privateKey, publicKey } = await generateKeyPair('EdDSA', { extractable: true }) let provider!: ReturnType type Listener = { close: () => Promise; url: string } const { close, url } = await new Promise((resolve) => { const server = serve({ fetch: (request) => provider.fetch(request), port: 0 }, (info) => resolve({ close: () => new Promise((done) => server.close(() => done())), url: `http://127.0.0.1:${info.port}`, }), ) }) provider = Accounts.Handler.oidcProvider({ getClaims: () => ({ email: options.email, email_verified: true }), issuer: url, publicKey: JSON.stringify(await exportJWK(publicKey)), signingKey: JSON.stringify(await exportJWK(privateKey)), }) return { close, async mint(parameters: createIdentityIssuer.MintParameters) { const response = await provider.fetch( new Request(`${url}/token`, { body: JSON.stringify({ audience: parameters.audience, subject: parameters.address, }), headers: { 'content-type': 'application/json' }, method: 'POST', }), ) const { idToken } = (await response.json()) as { idToken: string } return idToken }, url, } } declare namespace createIdentityIssuer { type MintParameters = { /** Wallet address used as the token subject. */ address: string /** Origin used as the token audience. */ audience: string } type Options = { /** Verified email included in issued tokens. */ email: string } } async function signInEmail(app: TestApp.signIn.App, options: signInEmail.Options) { await app.request( `${origin}/v1/auth/email-otp/send-verification-otp`, json({ email: options.email, type: 'sign-in' }), ) const otp = options.messages.at(-1)!.text.match(/\b\d{6}\b/)![0]! const response = await app.request( `${origin}/v1/auth/sign-in/email-otp`, json({ email: options.email, otp }), ) expect(response.status).toBe(200) return response.headers.get('set-cookie')!.split(';', 1)[0]! } declare namespace signInEmail { type Options = { /** Email to authenticate. */ email: string /** Captured outbound messages. */ messages: readonly App.Email.Message[] } }