diff --git a/AGENTS.md b/AGENTS.md index 6939523e..79808097 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -171,6 +171,10 @@ Changeset descriptions **must use past tense** (e.g. "Added support for …", "F The changelog is auto-generated from changesets during `changeset version`. +## JSDoc + +All public interfaces — exported functions, types, classes, constants, and namespace members — **must** have JSDoc comments. Keep descriptions concise (one sentence is fine). Include `@param` / `@returns` only when the meaning isn't obvious from the name and type. + ## Commands ```bash diff --git a/examples/charge-wagmi/server.ts b/examples/charge-wagmi/server.ts index 0b709d93..77c506c2 100644 --- a/examples/charge-wagmi/server.ts +++ b/examples/charge-wagmi/server.ts @@ -31,6 +31,7 @@ export async function handler(request: Request): Promise { })(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response const res = await fetch('https://picsum.photos/1024/1024') const photoUrl = res.url diff --git a/examples/charge/src/server.ts b/examples/charge/src/server.ts index f94cb111..a287bc3b 100644 --- a/examples/charge/src/server.ts +++ b/examples/charge/src/server.ts @@ -34,6 +34,7 @@ export async function handler(request: Request): Promise { })(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response const res = await fetch('https://picsum.photos/1024/1024') const photoUrl = res.url diff --git a/examples/session/multi-fetch/src/server.ts b/examples/session/multi-fetch/src/server.ts index 1d24e562..fde92ca7 100644 --- a/examples/session/multi-fetch/src/server.ts +++ b/examples/session/multi-fetch/src/server.ts @@ -136,6 +136,7 @@ export async function handler(request: Request): Promise { // Return the challenge response (402 + WWW-Authenticate header) to the client. // The client's session will automatically parse this, open a channel, and retry. if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response // If we get here, the credential was valid — the client paid for this request. // Generate the content they paid for. diff --git a/examples/session/sse/src/server.ts b/examples/session/sse/src/server.ts index 4c49e32c..02c853b1 100644 --- a/examples/session/sse/src/server.ts +++ b/examples/session/sse/src/server.ts @@ -211,6 +211,7 @@ export async function handler(request: Request): Promise { // `result.challenge` is a Response object with the WWW-Authenticate header // containing the base64url-encoded challenge parameters. if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response // Phases 2–4: `withReceipt` handles everything. // diff --git a/examples/session/ws/src/server.ts b/examples/session/ws/src/server.ts index 24e99e66..c449b2ac 100644 --- a/examples/session/ws/src/server.ts +++ b/examples/session/ws/src/server.ts @@ -154,6 +154,7 @@ export async function handler(request: Request): Promise { // verified here if you choose to mix transports. const result = await route(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response // Once a route has already been paid over HTTP, return a simple message so // it is obvious that WebSocket is the intended content path for this demo. diff --git a/examples/stripe/src/server.ts b/examples/stripe/src/server.ts index 20de44c4..fe79d043 100644 --- a/examples/stripe/src/server.ts +++ b/examples/stripe/src/server.ts @@ -102,6 +102,7 @@ export async function handler(request: Request): Promise { })(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response const fortune = fortunes[Math.floor(Math.random() * fortunes.length)]! return result.withReceipt(Response.json({ fortune })) diff --git a/src/Method.ts b/src/Method.ts index 068742d6..aa785e5e 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -130,10 +130,12 @@ export type Server< defaults extends ExactPartial> = {}, transportOverride = undefined, > = method & { + authorize?: AuthorizeFn | undefined defaults?: defaults | undefined html?: Html.Options | undefined request?: RequestFn | undefined respond?: RespondFn | undefined + stableBinding?: StableBindingFn | undefined transport?: transportOverride | undefined verify: VerifyFn } @@ -155,10 +157,55 @@ export type RequestFn = ( options: RequestContext, ) => MaybePromise> +/** + * Optional authorization hook for a server-side method. + * + * Called after request normalization but before the 402 challenge path. This lets + * a server grant access based on existing application state (for example, an + * active subscription) without requiring a fresh `Payment` credential. + * + * **HTTP-only.** The `input` parameter is a Fetch `Request`; non-HTTP transports + * do not invoke this hook. + */ +export type AuthorizeFn = (parameters: { + challenge: Challenge.Challenge< + z.output, + method['intent'], + method['name'] + > + input: globalThis.Request + request: z.output +}) => MaybePromise + +export type AuthorizeResult = { + receipt: Receipt.Receipt + response?: globalThis.Response | undefined +} + +/** + * A non-402 response returned before payment capture has fully settled. + * + * Methods can use this for async activation/capture flows that need to hand the + * client off to a processor or pollable resource before a receipt exists. + */ +export type PendingResult = { + response: globalThis.Response +} + +/** + * Produces the stable request fields used to bind credentials to a route. + * + * Methods can override this to opt into additional request fields beyond the + * default amount/currency/recipient binding used by generic methods. + */ +export type StableBindingFn = ( + request: z.output, +) => Record + /** Verification function for a single method. */ export type VerifyFn = ( parameters: VerifyContext, -) => Promise +) => Promise /** * Optional respond function for a server-side method. @@ -251,13 +298,15 @@ export function toServer< method: method, options: toServer.Options, ): Server { - const { defaults, html, request, respond, transport, verify } = options + const { authorize, defaults, html, request, respond, stableBinding, transport, verify } = options return { ...method, + authorize, defaults, html, request, respond, + stableBinding, transport, verify, } as Server @@ -269,10 +318,12 @@ export declare namespace toServer { defaults extends RequestDefaults = {}, transportOverride extends Transport.AnyTransport | undefined = undefined, > = { + authorize?: AuthorizeFn | undefined defaults?: defaults | undefined html?: Html.Options | undefined request?: RequestFn | undefined respond?: RespondFn | undefined + stableBinding?: StableBindingFn | undefined transport?: transportOverride | undefined verify: VerifyFn } diff --git a/src/Receipt.ts b/src/Receipt.ts index 0aa2815d..db93d067 100644 --- a/src/Receipt.ts +++ b/src/Receipt.ts @@ -19,6 +19,8 @@ export const Schema = z.object({ reference: z.string(), /** Optional external reference ID echoed from the credential payload. */ externalId: z.optional(z.string()), + /** Optional server-issued subscription identifier for recurring payments. */ + subscriptionId: z.optional(z.string()), /** Payment status. Always "success" — failures use 402 + Problem Details. */ status: z.literal('success'), /** RFC 3339 settlement timestamp. */ diff --git a/src/client/Methods.ts b/src/client/Methods.ts index 725005a6..72ee6281 100644 --- a/src/client/Methods.ts +++ b/src/client/Methods.ts @@ -1,3 +1,4 @@ export { stripe } from '../stripe/client/index.js' +export { subscription } from '../tempo/client/Subscription.js' export { tempo } from '../tempo/client/index.js' export { session } from '../tempo/client/Session.js' diff --git a/src/mcp-sdk/client/McpClient.test.ts b/src/mcp-sdk/client/McpClient.test.ts index 6a0f327c..f7a05624 100644 --- a/src/mcp-sdk/client/McpClient.test.ts +++ b/src/mcp-sdk/client/McpClient.test.ts @@ -50,6 +50,7 @@ describe('McpClient.wrap', () => { })(extra) if (result.status === 402) throw result.challenge + if (result.status === 'pending') throw result.response return result.withReceipt({ content: [{ type: 'text' as const, text: 'Premium tool executed' }], diff --git a/src/middlewares/express.ts b/src/middlewares/express.ts index 4985aac7..baf35292 100644 --- a/src/middlewares/express.ts +++ b/src/middlewares/express.ts @@ -76,6 +76,14 @@ export function payment( return } + if (result.status === 'pending') { + const response = result.response as Response + res.status(response.status) + for (const [key, value] of response.headers) res.setHeader(key, value) + res.send(await response.text()) + return + } + const originalJson = res.json.bind(res) res.json = (body: any) => { const wrapped = result.withReceipt(Response.json(body)) diff --git a/src/middlewares/hono.ts b/src/middlewares/hono.ts index df25e1f6..53b6a505 100644 --- a/src/middlewares/hono.ts +++ b/src/middlewares/hono.ts @@ -63,6 +63,7 @@ export function payment( : c.req.raw const result = await intent(options)(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response as Response await next() c.res = result.withReceipt(c.res) } diff --git a/src/middlewares/nextjs.ts b/src/middlewares/nextjs.ts index 89eb4181..e4176099 100644 --- a/src/middlewares/nextjs.ts +++ b/src/middlewares/nextjs.ts @@ -61,6 +61,7 @@ export function payment( return async (request) => { const result = await intent(options)(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response as Response const response = await handler(request) return result.withReceipt(response) } diff --git a/src/proxy/Proxy.ts b/src/proxy/Proxy.ts index 0b012569..e3ab0eeb 100644 --- a/src/proxy/Proxy.ts +++ b/src/proxy/Proxy.ts @@ -141,6 +141,7 @@ export function create(config: create.Config): Proxy { getConfiguredScope(handler) ? request : Scope.attach(request, scope), ) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response const managementResponse = (() => { try { diff --git a/src/proxy/Service.ts b/src/proxy/Service.ts index 04b422d9..f0ebc065 100644 --- a/src/proxy/Service.ts +++ b/src/proxy/Service.ts @@ -52,6 +52,7 @@ export type IntentHandler = (input: Request) => Promise /** Result of an intent handler — either a 402 challenge or a 200 with receipt attachment. */ export type IntentResult = | { challenge: Response; status: 402 } + | { response: Response; status: 'pending' } | { status: 200; withReceipt: (response: response) => response } /** Context passed to `rewriteRequest`/`rewriteResponse` hooks, including any per-endpoint options. */ diff --git a/src/server/Mppx.authorize.test.ts b/src/server/Mppx.authorize.test.ts new file mode 100644 index 00000000..c7168c4c --- /dev/null +++ b/src/server/Mppx.authorize.test.ts @@ -0,0 +1,160 @@ +import { Challenge, Credential, Method, z } from 'mppx' +import { Mppx } from 'mppx/server' +import { describe, expect, test } from 'vp/test' + +const realm = 'api.example.com' +const secretKey = 'test-secret-key' + +function successReceipt(method = 'mock') { + return { + method, + reference: 'ref-1', + status: 'success', + timestamp: '2025-01-01T00:00:00.000Z', + } as const +} + +describe('authorize hook', () => { + test('grants access without a Payment credential', async () => { + const method = Method.toServer( + Method.from({ + name: 'mock', + intent: 'subscription', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.object({ amount: z.string() }), + }, + }), + { + async authorize() { + return { receipt: successReceipt() } + }, + async verify() { + return successReceipt() + }, + }, + ) + + const handler = Mppx.create({ methods: [method], realm, secretKey }) + const result = await handler['mock/subscription']({ amount: '1' })( + new Request('https://example.com/resource'), + ) + + expect(result.status).toBe(200) + if (result.status !== 200) throw new Error('expected authorize success') + + const response = result.withReceipt(new Response('OK')) + expect(response.headers.get('Payment-Receipt')).toBeTruthy() + }) + + test('compose evaluates authorize hooks sequentially on no-credential requests', async () => { + const calls: string[] = [] + const createMethod = ( + name: 'alpha' | 'beta', + authorizeResult?: ReturnType, + ) => + Method.toServer( + Method.from({ + name, + intent: 'charge', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.object({ amount: z.string() }), + }, + }), + { + async authorize() { + calls.push(`${name}:start`) + await new Promise((resolve) => setTimeout(resolve, 0)) + calls.push(`${name}:end`) + return authorizeResult ? { receipt: authorizeResult } : undefined + }, + async verify() { + return successReceipt(name) + }, + }, + ) + + const alpha = createMethod('alpha') + const beta = createMethod('beta', successReceipt('beta')) + const handler = Mppx.create({ methods: [alpha, beta], realm, secretKey }) + + const result = await handler.compose( + [alpha, { amount: '1' }], + [beta, { amount: '1' }], + )(new Request('https://example.com/resource')) + + expect(result.status).toBe(200) + expect(calls).toEqual(['alpha:start', 'alpha:end', 'beta:start', 'beta:end']) + }) + + test('stableBinding can reject mismatched subscription routes', async () => { + const method = Method.toServer( + Method.from({ + name: 'mock', + intent: 'subscription', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.object({ + amount: z.string(), + chainId: z.optional(z.number()), + currency: z.string(), + periodSeconds: z.string(), + recipient: z.string(), + subscriptionExpires: z.string(), + }), + }, + }), + { + stableBinding(request) { + return { + amount: request.amount, + chainId: request.chainId, + currency: request.currency, + periodSeconds: request.periodSeconds, + recipient: request.recipient, + subscriptionExpires: request.subscriptionExpires, + } + }, + async verify() { + return successReceipt() + }, + }, + ) + + const handler = Mppx.create({ methods: [method], realm, secretKey }) + const first = await handler['mock/subscription']({ + amount: '1', + currency: 'usd', + periodSeconds: '30', + recipient: 'alice', + subscriptionExpires: '2026-01-01T00:00:00Z', + })(new Request('https://example.com/cheap')) + + expect(first.status).toBe(402) + if (first.status !== 402) throw new Error('expected challenge') + + const credential = Credential.from({ + challenge: Challenge.fromResponse(first.challenge), + payload: { token: 'ok' }, + }) + + const second = await handler['mock/subscription']({ + amount: '1', + currency: 'usd', + periodSeconds: '60', + recipient: 'alice', + subscriptionExpires: '2026-01-01T00:00:00Z', + })( + new Request('https://example.com/expensive', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(second.status).toBe(402) + if (second.status !== 402) throw new Error('expected mismatch challenge') + + const body = (await second.challenge.json()) as { detail: string } + expect(body.detail).toContain('periodSeconds') + }) +}) diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 0654549c..a7e9be36 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -231,12 +231,14 @@ export function create< for (const mi of methods) { intentCount[mi.intent] = (intentCount[mi.intent] ?? 0) + 1 handlers[`${mi.name}/${mi.intent}`] = createMethodFn({ + authorize: mi.authorize as never, defaults: mi.defaults, method: mi, realm, request: mi.request as never, respond: mi.respond as never, secretKey, + stableBinding: mi.stableBinding as never, transport: (mi.transport ?? transport) as never, verify: mi.verify as never, }) @@ -351,7 +353,13 @@ export function create< } as Method.VerifiedChallengeEnvelope) : undefined - return mi.verify({ credential, envelope, request } as never) + const result = await mi.verify({ credential, envelope, request } as never) + if (isPendingResult(result)) { + throw new Error( + 'verifyCredential() does not support pending verification results. Use the route handler flow instead.', + ) + } + return result } function composeFn( @@ -412,15 +420,26 @@ function createMethodFn< parameters: createMethodFn.Parameters, ): createMethodFn.ReturnType // biome-ignore lint/correctness/noUnusedVariables: _ -function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.ReturnType { - const { defaults, method, realm, respond, secretKey, transport, verify } = parameters +function createMethodFn(parameters: createMethodFn.Parameters): any { + const { + authorize, + defaults, + method, + realm, + respond, + secretKey, + stableBinding, + transport, + verify, + } = parameters - return (options) => { + return (options: any) => { const { description, meta, scope, ...rest } = options const staticMeta = Scope.merge({ meta, scope }) + const merged = { ...defaults, ...rest } return Object.assign( - async (input: Transport.InputOf): Promise => { + async (input: Transport.InputOf): Promise> => { const expires = 'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5) const capturedRequest = await captureRequest(transport, input) @@ -466,8 +485,84 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R return { challenge: response, status: 402 } } + const success = ( + receiptData: Receipt.Receipt, + options: { + challengeId?: string | undefined + credentialForReceipt?: Credential.Credential | undefined + envelopeForReceipt?: Method.VerifiedChallengeEnvelope | undefined + managementResponse?: globalThis.Response | undefined + } = {}, + ): MethodFn.Response => { + const { + challengeId = challenge.id, + credentialForReceipt = { challenge, payload: {} } as Credential.Credential, + envelopeForReceipt, + managementResponse, + } = options + + return { + status: 200, + withReceipt(response?: response) { + if (managementResponse) { + return transport.respondReceipt({ + challengeId, + credential: credentialForReceipt, + ...(envelopeForReceipt ? { envelope: envelopeForReceipt } : {}), + input, + receipt: receiptData, + response: managementResponse as never, + }) as response + } + if (!response) throw new Error('withReceipt() requires a response argument') + return transport.respondReceipt({ + challengeId, + credential: credentialForReceipt, + ...(envelopeForReceipt ? { envelope: envelopeForReceipt } : {}), + input, + receipt: receiptData, + response: response as never, + }) as response + }, + } + } + + const pending = (response: Transport.ChallengeOutputOf) => + ({ + response, + status: 'pending', + }) as MethodFn.Response + // No credential provided—issue challenge if (!credential) { + if (authorize && input instanceof globalThis.Request) { + try { + const authorized = await authorize({ + challenge, + input, + request: challenge.request, + } as never) + if (authorized) { + if (isPendingResult(authorized)) return pending(authorized.response as never) + return success(authorized.receipt, { + managementResponse: authorized.response, + }) + } + } catch (e) { + if (!(e instanceof Errors.PaymentError)) + console.error('mppx: internal authorization error', e) + const error = + e instanceof Errors.PaymentError ? e : new Errors.VerificationFailedError() + const response = await transport.respondChallenge({ + challenge, + input, + error, + html: method.html, + }) + return { challenge: response, status: 402 } + } + } + const response = await transport.respondChallenge({ challenge, input, @@ -524,7 +619,11 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R // `expires` still is not pinned here because its default is generated // per invocation, and `digest` is already bound by the echoed HMAC. { - const mismatch = getPinnedChallengeMismatch(challenge, credential.challenge) + const mismatch = getChallengeBindingMismatch( + challenge, + credential.challenge, + stableBinding as never, + ) if (mismatch) { const response = await transport.respondChallenge({ challenge, @@ -573,7 +672,9 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R // If verification fails, re-issue the challenge so the client can retry. let receiptData: Receipt.Receipt try { - receiptData = await verify({ credential, envelope, request } as never) + const verification = await verify({ credential, envelope, request } as never) + if (isPendingResult(verification)) return pending(verification.response as never) + receiptData = verification } catch (e) { if (!(e instanceof Errors.PaymentError)) console.error('mppx: internal verification error', e) @@ -595,30 +696,12 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R ? await respond({ credential, envelope, input, receipt: receiptData, request } as never) : undefined - return { - status: 200, - withReceipt(response?: response) { - if (managementResponse) { - return transport.respondReceipt({ - challengeId: credential.challenge.id, - credential, - envelope, - input, - receipt: receiptData, - response: managementResponse as never, - }) as response - } - if (!response) throw new Error('withReceipt() requires a response argument') - return transport.respondReceipt({ - challengeId: credential.challenge.id, - credential, - envelope, - input, - receipt: receiptData, - response: response as never, - }) as response - }, - } + return success(receiptData, { + challengeId: credential.challenge.id, + credentialForReceipt: credential, + envelopeForReceipt: envelope, + managementResponse, + }) }, { _internal: { @@ -628,7 +711,11 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R ...(staticMeta !== undefined ? { meta: staticMeta } : {}), name: method.name, intent: method.intent, - _canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }), + _canonicalRequest: PaymentRequest.fromMethod(method, merged), + _stableBinding: getStableBinding( + PaymentRequest.fromMethod(method, merged), + stableBinding as never, + ), }, }, ) @@ -688,12 +775,14 @@ declare namespace createMethodFn { transport extends Transport.AnyTransport = Transport.Http, defaults extends Record = Record, > = { + authorize?: Method.AuthorizeFn defaults?: defaults method: method realm: string | undefined request?: Method.RequestFn respond?: Method.RespondFn secretKey: string + stableBinding?: Method.StableBindingFn transport: transport verify: Method.VerifyFn } @@ -817,26 +906,60 @@ function captureRequestFromInput(input: unknown): Method.CapturedRequest { } } +type StableBinding = Record const coreBindingFields = ['amount', 'currency', 'recipient'] as const const methodBindingFields = ['chainId', 'memo', 'splits', 'unitType'] as const -const pinnedRequestBindingFields = [...coreBindingFields, ...methodBindingFields] as const type CoreBindingField = (typeof coreBindingFields)[number] type MethodBindingField = (typeof methodBindingFields)[number] -type PinnedRequestBindingField = (typeof pinnedRequestBindingFields)[number] -type PinnedChallengeField = 'method' | 'intent' | 'realm' | 'opaque' | PinnedRequestBindingField +type PinnedRequestBindingField = CoreBindingField | MethodBindingField +type PinnedChallengeField = 'intent' | 'method' | 'opaque' | PinnedRequestBindingField | 'realm' + +type CoreBinding = { + [field in CoreBindingField]?: string +} + +type MethodBinding = { + [field in MethodBindingField]?: unknown +} + +type PinnedRequestBinding = { + coreBinding: CoreBinding + methodBinding: MethodBinding +} + +function getChallengeBindingMismatch( + expectedChallenge: Challenge.Challenge, + actualChallenge: Challenge.Challenge, + stableBinding?: Method.StableBindingFn | undefined, +): string | undefined { + for (const field of ['method', 'intent', 'realm'] as const) { + if (actualChallenge[field] !== expectedChallenge[field]) return field + } + + if (!opaqueValuesMatch(expectedChallenge.opaque, actualChallenge.opaque)) return 'opaque' + + return getRequestBindingMismatch( + getStableBinding(expectedChallenge.request as Record, stableBinding), + getStableBinding(actualChallenge.request as Record, stableBinding), + ) +} + +function getRequestBindingMismatch( + expected: StableBinding, + actual: StableBinding, +): string | undefined { + const fields = [ + ...Object.keys(expected), + ...Object.keys(actual).filter((key) => !(key in expected)), + ] + + return fields.find( + (field) => + !isDeepStrictEqual(normalizeComparable(expected[field]), normalizeComparable(actual[field])), + ) +} -/** - * Compares only the fields that MUST be stable across request-hook transforms. - * - * This is NOT the primary integrity check — the HMAC binding (Challenge.verify) - * already covers every challenge field including opaque, digest, and the full - * serialized request. This function exists as a secondary safety net for the - * case where the `request()` hook produces credential-dependent output, causing - * the recomputed challenge to differ from the original in non-economic fields - * (e.g. `feePayer`). We only need to verify that the economically significant - * subset hasn't drifted. - */ function getPinnedChallengeMismatch( expectedChallenge: Challenge.Challenge, actualChallenge: Challenge.Challenge, @@ -905,6 +1028,25 @@ function getPinnedRequestBinding(request: Record): PinnedReques } } +function getStableBinding( + request: Record, + stableBinding?: Method.StableBindingFn | undefined, +): StableBinding { + if (stableBinding) return stableBinding(request as never) + + const methodDetails = (request.methodDetails ?? {}) as Record + + return { + amount: normalizeScalar(request.amount ?? methodDetails.amount), + chainId: normalizeScalar(request.chainId ?? methodDetails.chainId), + currency: normalizeScalar(request.currency ?? methodDetails.currency), + memo: normalizeHex(methodDetails.memo), + recipient: normalizeScalar(request.recipient ?? methodDetails.recipient), + splits: normalizeComparable(methodDetails.splits), + unitType: normalizeScalar(request.unitType ?? methodDetails.unitType), + } +} + function normalizeScalar(value: unknown): string | undefined { return value === undefined ? undefined : String(value) } @@ -938,17 +1080,8 @@ function opaqueValuesMatch( return isDeepStrictEqual(expected, actual) } -type CoreBinding = { - [field in CoreBindingField]?: string -} - -type MethodBinding = { - [field in MethodBindingField]?: unknown -} - -type PinnedRequestBinding = { - coreBinding: CoreBinding - methodBinding: MethodBinding +function isPendingResult(value: unknown): value is Method.PendingResult { + return !!value && typeof value === 'object' && 'response' in value && !('receipt' in value) } export type MethodFn< @@ -984,6 +1117,10 @@ declare namespace MethodFn { challenge: Transport.ChallengeOutputOf status: 402 } + | { + response: Transport.ChallengeOutputOf + status: 'pending' + } | { status: 200 withReceipt: Transport.WithReceipt @@ -999,6 +1136,7 @@ type ConfiguredHandler = ((input: Request) => Promise | undefined scope?: string | undefined _canonicalRequest: Record + _stableBinding: StableBinding } } @@ -1122,10 +1260,8 @@ export function compose( const internal = (h as ConfiguredHandler)._internal if (!internal || internal.name !== credMethod || internal.intent !== credIntent) return false - const canonical = internal._canonicalRequest - if (!canonical) return true return ( - !getPinnedRequestBindingMismatch(canonical, credReq) && + !getRequestBindingMismatch(internal._stableBinding, getStableBinding(credReq)) && opaqueValuesMatch(internal.meta, credential.challenge.opaque) ) }) @@ -1144,8 +1280,14 @@ export function compose( return handlers[0]!(input) } - // No credential — call all handlers and merge 402 challenges. - const results = await Promise.all(handlers.map((h) => h(input))) + // No credential — evaluate handlers sequentially so authorize()/renewal hooks + // can safely claim the request without racing each other. + const results: MethodFn.Response[] = [] + for (const handler of handlers) { + const result = await handler(input) + if (result.status === 200) return result + results.push(result) + } const challengeEntries = (() => { const entries: { @@ -1295,6 +1437,8 @@ export function toNodeListener( if (result.status === 402) { await NodeListener.sendResponse(res, result.challenge as globalThis.Response) + } else if (result.status === 'pending') { + await NodeListener.sendResponse(res, result.response as globalThis.Response) } else { const wrapped = result.withReceipt(new globalThis.Response()) as globalThis.Response res.setHeader('Payment-Receipt', wrapped.headers.get('Payment-Receipt')!) diff --git a/src/tempo/Methods.test.ts b/src/tempo/Methods.test.ts index 90d6e0d2..75756872 100644 --- a/src/tempo/Methods.test.ts +++ b/src/tempo/Methods.test.ts @@ -247,3 +247,47 @@ describe('session', () => { expect(request.methodDetails?.minVoucherDelta).toBe('100000') }) }) + +describe('subscription', () => { + test('has correct name and intent', () => { + expect(Methods.subscription.intent).toBe('subscription') + expect(Methods.subscription.name).toBe('tempo') + }) + + test('schema: validates request and encodes amount in base units', () => { + const request = Methods.subscription.schema.request.parse({ + amount: '10', + chainId: 4217, + currency: '0x20c0000000000000000000000000000000000001', + decimals: 6, + periodSeconds: '3600', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + subscriptionExpires: '2026-01-01T00:00:00Z', + }) + + expect(request.amount).toBe('10000000') + expect(request.methodDetails?.chainId).toBe(4217) + }) + + test('schema: rejects non-numeric periodSeconds', () => { + const result = Methods.subscription.schema.request.safeParse({ + amount: '10', + currency: '0x20c0000000000000000000000000000000000001', + decimals: 6, + periodSeconds: 'month', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + subscriptionExpires: '2026-01-01T00:00:00Z', + }) + + expect(result.success).toBe(false) + }) + + test('schema: validates key authorization payload', () => { + const result = Methods.subscription.schema.credential.payload.safeParse({ + signature: '0x1234', + type: 'keyAuthorization', + }) + + expect(result.success).toBe(true) + }) +}) diff --git a/src/tempo/Methods.ts b/src/tempo/Methods.ts index 76f96b40..a8ed892b 100644 --- a/src/tempo/Methods.ts +++ b/src/tempo/Methods.ts @@ -199,3 +199,45 @@ export const session = Method.from({ ), }, }) + +/** + * Tempo subscription intent for recurring TIP-20 token transfers. + * + * Uses a signed key authorization that delegates one transfer per billing period. + */ +export const subscription = Method.from({ + name: 'tempo', + intent: 'subscription', + schema: { + credential: { + payload: z.object({ + signature: z.signature(), + type: z.literal('keyAuthorization'), + }), + }, + request: z.pipe( + z.object({ + amount: z.amount(), + chainId: z.optional(z.number()), + currency: z.string(), + decimals: z.number(), + description: z.optional(z.string()), + externalId: z.optional(z.string()), + periodSeconds: z.string().check(z.regex(/^[1-9]\d*$/, 'Invalid periodSeconds')), + recipient: z.string(), + subscriptionExpires: z.datetime(), + }), + z.transform(({ amount, chainId, decimals, ...rest }) => ({ + ...rest, + amount: parseUnits(amount, decimals).toString(), + ...(chainId !== undefined + ? { + methodDetails: { + chainId, + }, + } + : {}), + })), + ), + }, +}) diff --git a/src/tempo/client/Methods.ts b/src/tempo/client/Methods.ts index 4f2428dc..a46908d8 100644 --- a/src/tempo/client/Methods.ts +++ b/src/tempo/client/Methods.ts @@ -1,6 +1,7 @@ import { charge as charge_ } from './Charge.js' import { session as sessionIntent_ } from './Session.js' import { sessionManager as session_ } from './SessionManager.js' +import { subscription as subscription_ } from './Subscription.js' /** * Creates both Tempo `charge` and `session` client methods from shared parameters. @@ -25,4 +26,6 @@ export namespace tempo { export const charge = charge_ /** Creates a client-side streaming session for managing payment channels. */ export const session = session_ + /** Creates a Tempo `subscription` client method for recurring TIP-20 payments. */ + export const subscription = subscription_ } diff --git a/src/tempo/client/Subscription.ts b/src/tempo/client/Subscription.ts new file mode 100644 index 00000000..ba93650b --- /dev/null +++ b/src/tempo/client/Subscription.ts @@ -0,0 +1,126 @@ +import { KeyAuthorization } from 'ox/tempo' +import type { Address } from 'viem' +import { tempo as tempo_chain } from 'viem/chains' +import { Actions } from 'viem/tempo' + +import * as Credential from '../../Credential.js' +import * as Method from '../../Method.js' +import * as Account from '../../viem/Account.js' +import * as Client from '../../viem/Client.js' +import * as z from '../../zod.js' +import * as defaults from '../internal/defaults.js' +import * as Methods from '../Methods.js' +import type { SubscriptionAccessKey } from '../subscription/Types.js' + +export const subscriptionContextSchema = z.object({ + accessKey: z.optional(z.custom()), + account: z.optional(z.custom()), +}) + +export type SubscriptionContext = z.infer + +export function subscription(parameters: subscription.Parameters = {}) { + const getClient = Client.getResolver({ + chain: tempo_chain, + getClient: parameters.getClient, + rpcUrl: defaults.rpcUrl, + }) + const getAccount = Account.getResolver({ account: parameters.account }) + + return Method.toClient(Methods.subscription, { + context: subscriptionContextSchema, + + async createCredential({ challenge, context }) { + const chainId = challenge.request.methodDetails?.chainId ?? defaults.chainId.mainnet + const client = await getClient({ chainId }) + const account = getAccount(client, context) + const accessKey = context?.accessKey ?? parameters.accessKey + if (!accessKey) { + throw new Error( + 'No `accessKey` provided. Pass `accessKey` to parameters or context so the client knows which server key to authorize.', + ) + } + + if (parameters.expectedRecipients) { + const recipient = (challenge.request.recipient as string).toLowerCase() + const allowed = parameters.expectedRecipients.map((address) => address.toLowerCase()) + if (!allowed.includes(recipient)) { + throw new Error(`Unexpected subscription recipient: ${challenge.request.recipient}`) + } + } + + const periodSeconds = Number(challenge.request.periodSeconds) + if (!Number.isSafeInteger(periodSeconds) || periodSeconds <= 0) { + throw new Error('Subscription `periodSeconds` must be a positive safe integer.') + } + + const keyAuthorization = await authorizeAccessKey(client, { + accessKey, + account, + expiry: Math.floor(new Date(challenge.request.subscriptionExpires).getTime() / 1000), + limits: [ + { + token: challenge.request.currency as Address, + limit: BigInt(challenge.request.amount), + }, + ], + } as never) + + return Credential.serialize({ + challenge, + payload: { + signature: KeyAuthorization.serialize(keyAuthorization as never), + type: 'keyAuthorization', + }, + source: `did:pkh:eip155:${chainId}:${account.address}`, + }) + }, + }) +} + +async function authorizeAccessKey( + client: Awaited>>, + parameters: { + accessKey: SubscriptionAccessKey + account: Account.Account + expiry: number + limits: readonly { + token: Address + limit: bigint + }[] + }, +) { + const { accessKey, account, expiry, limits } = parameters + + if (typeof account.signAuthorization === 'function') + return Actions.accessKey.signAuthorization(client, { + accessKey, + account, + expiry, + limits, + } as never) + + const result = (await client.request({ + method: 'wallet_authorizeAccessKey', + params: [ + { + address: accessKey.accessKeyAddress, + expiry, + keyType: accessKey.keyType, + limits, + }, + ], + } as never)) as { + keyAuthorization: Parameters[0] + } + + return KeyAuthorization.fromRpc(result.keyAuthorization) +} + +export declare namespace subscription { + type Parameters = Account.getResolver.Parameters & + Client.getResolver.Parameters & { + accessKey?: SubscriptionAccessKey | undefined + expectedRecipients?: readonly Address[] | undefined + } +} diff --git a/src/tempo/client/index.ts b/src/tempo/client/index.ts index 67f77821..efb18180 100644 --- a/src/tempo/client/index.ts +++ b/src/tempo/client/index.ts @@ -1,5 +1,6 @@ export { charge } from './Charge.js' export { tempo } from './Methods.js' export { session } from './Session.js' +export { subscription } from './Subscription.js' export type { PaymentResponse, SessionManager } from './SessionManager.js' export { sessionManager } from './SessionManager.js' diff --git a/src/tempo/index.ts b/src/tempo/index.ts index 5bc03bed..65875ac7 100644 --- a/src/tempo/index.ts +++ b/src/tempo/index.ts @@ -1,3 +1,4 @@ export * as Proof from './Proof.js' export * as Methods from './Methods.js' export * as Session from './session/index.js' +export * as Subscription from './subscription/index.js' diff --git a/src/tempo/server/Methods.ts b/src/tempo/server/Methods.ts index deeb7e06..bdf549ec 100644 --- a/src/tempo/server/Methods.ts +++ b/src/tempo/server/Methods.ts @@ -1,6 +1,7 @@ import * as Ws_ from '../session/Ws.js' import { charge as charge_ } from './Charge.js' import { session as session_, settle as settle_ } from './Session.js' +import { subscription as subscription_ } from './Subscription.js' /** * Creates both Tempo `charge` and `session` methods from shared parameters. @@ -28,6 +29,8 @@ export namespace tempo { export const charge = charge_ /** Creates a Tempo `session` method for session-based TIP-20 token payments. */ export const session = session_ + /** Creates a Tempo `subscription` method for recurring TIP-20 token payments. */ + export const subscription = subscription_ /** One-shot settle: reads highest voucher from storage and submits on-chain. */ export const settle = settle_ /** Experimental websocket helpers for Tempo sessions. */ diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index ff2c9c22..860597b8 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -65,6 +65,18 @@ const currency = asset let escrowContract: Address let saltCounter = 0 +function expectSettledReceipt(receipt: unknown): SessionReceipt { + if ( + receipt && + typeof receipt === 'object' && + 'response' in receipt && + (receipt as { response?: unknown }).response instanceof Response + ) { + throw new Error('expected settled receipt') + } + return receipt as SessionReceipt +} + beforeAll(async () => { if (!isLocalnet) return escrowContract = await deployEscrow() @@ -131,20 +143,22 @@ describe.runIf(isLocalnet)('session', () => { const { channelId, serializedTransaction } = await createSignedOpenTransaction(10000000n) const server = createServer() - const receipt = await server.verify({ - credential: { - challenge: makeChallenge({ channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '1000000', - signature: await signTestVoucher(channelId, 1000000n), + const receipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(receipt.method).toBe('tempo') expect(receipt.status).toBe('success') @@ -280,20 +294,22 @@ describe.runIf(isLocalnet)('session', () => { const ch1 = await store.getChannel(channelId) expect(ch1!.highestVoucherAmount).toBe(1000000n) - const receipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'open-2', channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '5000000', - signature: await signTestVoucher(channelId, 5000000n), + const receipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-2', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '5000000', + signature: await signTestVoucher(channelId, 5000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(receipt.status).toBe('success') const ch2 = await store.getChannel(channelId) @@ -319,20 +335,22 @@ describe.runIf(isLocalnet)('session', () => { request: makeRequest(), }) - const receipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'open-2', channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '1000000', - signature: await signTestVoucher(channelId, 1000000n), + const receipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-2', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(receipt.status).toBe('success') const ch = await store.getChannel(channelId) @@ -378,20 +396,22 @@ describe.runIf(isLocalnet)('session', () => { await charge(store, channelId, 4000000n) - const reopenReceipt = (await server.verify({ - credential: { - challenge: makeChallenge({ id: 'open-2', channelId: caseVariantChannelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId: caseVariantChannelId, - transaction: serializedTransaction, - cumulativeAmount: '5000000', - signature: await signTestVoucher(caseVariantChannelId, 5000000n), + const reopenReceipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-2', channelId: caseVariantChannelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId: caseVariantChannelId, + transaction: serializedTransaction, + cumulativeAmount: '5000000', + signature: await signTestVoucher(caseVariantChannelId, 5000000n), + }, }, - }, - request: makeRequest(), - })) as SessionReceipt + request: makeRequest(), + }), + ) expect(reopenReceipt.spent).toBe('4000000') await expect(charge(store, caseVariantChannelId, 2000000n)).rejects.toThrow( @@ -446,20 +466,22 @@ describe.runIf(isLocalnet)('session', () => { }) const server = createServer() - const receipt = await server.verify({ - credential: { - challenge: makeChallenge({ channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '1000000', - signature: await signTestVoucher(channelId, 1000000n), + const receipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(receipt.status).toBe('success') }) @@ -497,20 +519,22 @@ describe.runIf(isLocalnet)('session', () => { // 4. Re-open with a new voucher above the settled amount const server2 = createServer() - const receipt = await server2.verify({ - credential: { - challenge: makeChallenge({ id: 'reopen', channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '7000000', - signature: await signTestVoucher(channelId, 7000000n), + const receipt = expectSettledReceipt( + await server2.verify({ + credential: { + challenge: makeChallenge({ id: 'reopen', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '7000000', + signature: await signTestVoucher(channelId, 7000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(receipt.status).toBe('success') @@ -552,20 +576,22 @@ describe.runIf(isLocalnet)('session', () => { // Re-open — receipt.spent must reflect unsettled portion const server2 = createServer() - const receipt = (await server2.verify({ - credential: { - challenge: makeChallenge({ id: 'reopen', channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '8000000', - signature: await signTestVoucher(channelId, 8000000n), + const receipt = expectSettledReceipt( + await server2.verify({ + credential: { + challenge: makeChallenge({ id: 'reopen', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '8000000', + signature: await signTestVoucher(channelId, 8000000n), + }, }, - }, - request: makeRequest(), - })) as SessionReceipt + request: makeRequest(), + }), + ) // spent reflects on-chain settled (3M) so only unsettled portion is available expect(receipt.spent).toBe('3000000') @@ -600,18 +626,20 @@ describe.runIf(isLocalnet)('session', () => { const server = createServer() await openServerChannel(server, channelId, serializedTransaction) - const receipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'challenge-2', channelId }), - payload: { - action: 'voucher' as const, - channelId, - cumulativeAmount: '2000000', - signature: await signTestVoucher(channelId, 2000000n), + const receipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'challenge-2', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '2000000', + signature: await signTestVoucher(channelId, 2000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(receipt.status).toBe('success') @@ -798,13 +826,15 @@ describe.runIf(isLocalnet)('session', () => { const channelAfterFirstAccept = await store.getChannel(channelId) - const replayReceipt = (await server.verify({ - credential: { - challenge: makeChallenge({ id: 'challenge-3', channelId }), - payload, - }, - request: makeRequest(), - })) as SessionReceipt + const replayReceipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'challenge-3', channelId }), + payload, + }, + request: makeRequest(), + }), + ) expect(replayReceipt.status).toBe('success') expect(replayReceipt.acceptedCumulative).toBe('2000000') @@ -1116,19 +1146,21 @@ describe.runIf(isLocalnet)('session', () => { amount: 10000000n, }) - const receipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'challenge-2', channelId }), - payload: { - action: 'topUp' as const, - type: 'transaction' as const, - channelId, - transaction: topUpTx, - additionalDeposit: '10000000', + const receipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'challenge-2', channelId }), + payload: { + action: 'topUp' as const, + type: 'transaction' as const, + channelId, + transaction: topUpTx, + additionalDeposit: '10000000', + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(receipt.status).toBe('success') @@ -1190,19 +1222,21 @@ describe.runIf(isLocalnet)('session', () => { amount: 5000000n, }) - const receipt = (await server.verify({ - credential: { - challenge: makeChallenge({ id: 'challenge-topup', channelId }), - payload: { - action: 'topUp' as const, - type: 'transaction' as const, - channelId, - transaction: topUpTx, - additionalDeposit: '5000000', + const receipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'challenge-topup', channelId }), + payload: { + action: 'topUp' as const, + type: 'transaction' as const, + channelId, + transaction: topUpTx, + additionalDeposit: '5000000', + }, }, - }, - request: makeRequest(), - })) as SessionReceipt + request: makeRequest(), + }), + ) expect(receipt.status).toBe('success') expect(receipt.spent).toBe('800000') @@ -1263,18 +1297,20 @@ describe.runIf(isLocalnet)('session', () => { const server = createServer() await openServerChannel(server, channelId, serializedTransaction) - const receipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'challenge-2', channelId }), - payload: { - action: 'close' as const, - channelId, - cumulativeAmount: '1000000', - signature: await signTestVoucher(channelId, 1000000n), + const receipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'challenge-2', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(receipt.status).toBe('success') @@ -1288,18 +1324,20 @@ describe.runIf(isLocalnet)('session', () => { const server = createServer() await openServerChannel(server, channelId, serializedTransaction) - const receipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'challenge-2', channelId }), - payload: { - action: 'close' as const, - channelId, - cumulativeAmount: '5000000', - signature: await signTestVoucher(channelId, 5000000n), + const receipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'challenge-2', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '5000000', + signature: await signTestVoucher(channelId, 5000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(receipt.status).toBe('success') @@ -1327,18 +1365,20 @@ describe.runIf(isLocalnet)('session', () => { await charge(store, channelId, 500000n) - const receipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'challenge-3', channelId }), - payload: { - action: 'close' as const, - channelId, - cumulativeAmount: '500000', - signature: await signTestVoucher(channelId, 500000n), + const receipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'challenge-3', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '500000', + signature: await signTestVoucher(channelId, 500000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(receipt.status).toBe('success') @@ -1408,18 +1448,20 @@ describe.runIf(isLocalnet)('session', () => { await openServerChannel(server, channelId, serializedTransaction) - const receipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'challenge-zero-close', channelId }), - payload: { - action: 'close' as const, - channelId, - cumulativeAmount: '0', - signature: await signTestVoucher(channelId, 0n), + const receipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'challenge-zero-close', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '0', + signature: await signTestVoucher(channelId, 0n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(receipt.status).toBe('success') @@ -1462,18 +1504,20 @@ describe.runIf(isLocalnet)('session', () => { amount: 10000000n, }) - const receipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'challenge-2', channelId }), - payload: { - action: 'close' as const, - channelId, - cumulativeAmount: '15000000', - signature: await signTestVoucher(channelId, 15000000n), + const receipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'challenge-2', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '15000000', + signature: await signTestVoucher(channelId, 15000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(receipt.status).toBe('success') }) @@ -1503,18 +1547,20 @@ describe.runIf(isLocalnet)('session', () => { const server = createServer({ getClient: () => client }) await openServerChannel(server, channelId, serializedTransaction) - const receipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'challenge-2', channelId }), - payload: { - action: 'close' as const, - channelId, - cumulativeAmount: '1000000', - signature: await signTestVoucher(channelId, 1000000n), + const receipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'challenge-2', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(receipt.status).toBe('success') expect((receipt as SessionReceipt).txHash).toMatch(/^0x/) @@ -1569,49 +1615,55 @@ describe.runIf(isLocalnet)('session', () => { request: makeRequest(), }) - const r2 = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'c2', channelId }), - payload: { - action: 'voucher' as const, - channelId, - cumulativeAmount: '3000000', - signature: await signTestVoucher(channelId, 3000000n), + const r2 = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'c2', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '3000000', + signature: await signTestVoucher(channelId, 3000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(r2.status).toBe('success') - const r3 = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'c3', channelId }), - payload: { - action: 'voucher' as const, - channelId, - cumulativeAmount: '7000000', - signature: await signTestVoucher(channelId, 7000000n), + const r3 = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'c3', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '7000000', + signature: await signTestVoucher(channelId, 7000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(r3.status).toBe('success') const ch = await store.getChannel(channelId) expect(ch!.highestVoucherAmount).toBe(7000000n) - const r4 = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'c4', channelId }), - payload: { - action: 'close' as const, - channelId, - cumulativeAmount: '7000000', - signature: await signTestVoucher(channelId, 7000000n), + const r4 = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'c4', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '7000000', + signature: await signTestVoucher(channelId, 7000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(r4.status).toBe('success') expect(r4.reference).toBe(channelId) @@ -1626,51 +1678,57 @@ describe.runIf(isLocalnet)('session', () => { }) const server = createServer() - const openReceipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'open-delegated', channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '1000000', - signature: await signTestVoucher(channelId, 1000000n, delegatedSigner), + const openReceipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-delegated', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n, delegatedSigner), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(openReceipt.status).toBe('success') const channel = await store.getChannel(channelId) expect(channel?.authorizedSigner).toBe(delegatedSigner.address) - const voucherReceipt = (await server.verify({ - credential: { - challenge: makeChallenge({ id: 'voucher-delegated', channelId }), - payload: { - action: 'voucher' as const, - channelId, - cumulativeAmount: '2000000', - signature: await signTestVoucher(channelId, 2000000n, delegatedSigner), + const voucherReceipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'voucher-delegated', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '2000000', + signature: await signTestVoucher(channelId, 2000000n, delegatedSigner), + }, }, - }, - request: makeRequest(), - })) as SessionReceipt + request: makeRequest(), + }), + ) expect(voucherReceipt.acceptedCumulative).toBe('2000000') - const closeReceipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'close-delegated', channelId }), - payload: { - action: 'close' as const, - channelId, - cumulativeAmount: '2000000', - signature: await signTestVoucher(channelId, 2000000n, delegatedSigner), + const closeReceipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-delegated', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '2000000', + signature: await signTestVoucher(channelId, 2000000n, delegatedSigner), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(closeReceipt.status).toBe('success') }) @@ -1678,20 +1736,22 @@ describe.runIf(isLocalnet)('session', () => { const { channelId, serializedTransaction } = await createSignedOpenTransaction(4000000n) const server = createServer() - const openReceipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'open-multi-topup', channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '1000000', - signature: await signTestVoucher(channelId, 1000000n), + const openReceipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'open-multi-topup', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(openReceipt.status).toBe('success') await charge(store, channelId, 1000000n) @@ -1706,34 +1766,38 @@ describe.runIf(isLocalnet)('session', () => { amount: topUp1Amount, }) - const topUp1Receipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'topup-1', channelId }), - payload: { - action: 'topUp' as const, - type: 'transaction' as const, - channelId, - transaction: topUp1, - additionalDeposit: topUp1Amount.toString(), + const topUp1Receipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'topup-1', channelId }), + payload: { + action: 'topUp' as const, + type: 'transaction' as const, + channelId, + transaction: topUp1, + additionalDeposit: topUp1Amount.toString(), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(topUp1Receipt.status).toBe('success') expect((await store.getChannel(channelId))?.deposit).toBe(6000000n) - const voucher1 = (await server.verify({ - credential: { - challenge: makeChallenge({ id: 'voucher-after-topup-1', channelId }), - payload: { - action: 'voucher' as const, - channelId, - cumulativeAmount: '3000000', - signature: await signTestVoucher(channelId, 3000000n), + const voucher1 = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'voucher-after-topup-1', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '3000000', + signature: await signTestVoucher(channelId, 3000000n), + }, }, - }, - request: makeRequest(), - })) as SessionReceipt + request: makeRequest(), + }), + ) expect(voucher1.acceptedCumulative).toBe('3000000') await charge(store, channelId, 2000000n) @@ -1748,50 +1812,56 @@ describe.runIf(isLocalnet)('session', () => { amount: topUp2Amount, }) - const topUp2Receipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'topup-2', channelId }), - payload: { - action: 'topUp' as const, - type: 'transaction' as const, - channelId, - transaction: topUp2, - additionalDeposit: topUp2Amount.toString(), + const topUp2Receipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'topup-2', channelId }), + payload: { + action: 'topUp' as const, + type: 'transaction' as const, + channelId, + transaction: topUp2, + additionalDeposit: topUp2Amount.toString(), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(topUp2Receipt.status).toBe('success') expect((await store.getChannel(channelId))?.deposit).toBe(8000000n) - const voucher2 = (await server.verify({ - credential: { - challenge: makeChallenge({ id: 'voucher-after-topup-2', channelId }), - payload: { - action: 'voucher' as const, - channelId, - cumulativeAmount: '5000000', - signature: await signTestVoucher(channelId, 5000000n), + const voucher2 = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'voucher-after-topup-2', channelId }), + payload: { + action: 'voucher' as const, + channelId, + cumulativeAmount: '5000000', + signature: await signTestVoucher(channelId, 5000000n), + }, }, - }, - request: makeRequest(), - })) as SessionReceipt + request: makeRequest(), + }), + ) expect(voucher2.acceptedCumulative).toBe('5000000') await charge(store, channelId, 2000000n) - const closeReceipt = await server.verify({ - credential: { - challenge: makeChallenge({ id: 'close-multi-topup', channelId }), - payload: { - action: 'close' as const, - channelId, - cumulativeAmount: '5000000', - signature: await signTestVoucher(channelId, 5000000n), + const closeReceipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-multi-topup', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '5000000', + signature: await signTestVoucher(channelId, 5000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(closeReceipt.status).toBe('success') const finalized = await store.getChannel(channelId) @@ -1999,6 +2069,7 @@ describe.runIf(isLocalnet)('session', () => { request: makeRequest(), }) + if ('response' in closeReceipt) throw new Error('expected close receipt') expect(closeReceipt.status).toBe('success') expect((await store.getChannel(channelId))?.finalized).toBe(true) }) @@ -2044,6 +2115,7 @@ describe.runIf(isLocalnet)('session', () => { request: makeRequest(), }) + if ('response' in closeReceipt) throw new Error('expected close receipt') expect(closeReceipt.status).toBe('success') expect((await store.getChannel(channelId))?.finalized).toBe(true) }) @@ -2191,20 +2263,22 @@ describe.runIf(isLocalnet)('session', () => { // Re-open with a new (fresh) server instance using the same store. const server2 = createServer() - const receipt = (await server2.verify({ - credential: { - challenge: makeChallenge({ id: 'c2', channelId }), - payload: { - action: 'open' as const, - type: 'transaction' as const, - channelId, - transaction: serializedTransaction, - cumulativeAmount: '7000000', - signature: await signTestVoucher(channelId, 7000000n), + const receipt = expectSettledReceipt( + await server2.verify({ + credential: { + challenge: makeChallenge({ id: 'c2', channelId }), + payload: { + action: 'open' as const, + type: 'transaction' as const, + channelId, + transaction: serializedTransaction, + cumulativeAmount: '7000000', + signature: await signTestVoucher(channelId, 7000000n), + }, }, - }, - request: makeRequest(), - })) as SessionReceipt + request: makeRequest(), + }), + ) expect(receipt.status).toBe('success') @@ -2478,18 +2552,20 @@ describe.runIf(isLocalnet)('session', () => { // Close must succeed with voucher >= max(spent=5M, settled=4M) = 5M. // Use 8M (the full authorization). - const receipt = await server2.verify({ - credential: { - challenge: makeChallenge({ id: 'close', channelId }), - payload: { - action: 'close' as const, - channelId, - cumulativeAmount: '8000000', - signature: await signTestVoucher(channelId, 8000000n), + const receipt = expectSettledReceipt( + await server2.verify({ + credential: { + challenge: makeChallenge({ id: 'close', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '8000000', + signature: await signTestVoucher(channelId, 8000000n), + }, }, - }, - request: makeRequest(), - }) + request: makeRequest(), + }), + ) expect(receipt.status).toBe('success') const ch = await store.getChannel(channelId) @@ -2678,13 +2754,15 @@ describe.runIf(isLocalnet)('session', () => { expect(await afterCrashStore.getChannel(channelId)).toBeNull() const healthyServer = createServerWithStore(baseStore) - const recovered = await healthyServer.verify({ - credential: { - challenge: makeChallenge({ id: 'open-crash-retry', channelId }), - payload: openPayload, - }, - request: makeRequest(), - }) + const recovered = expectSettledReceipt( + await healthyServer.verify({ + credential: { + challenge: makeChallenge({ id: 'open-crash-retry', channelId }), + payload: openPayload, + }, + request: makeRequest(), + }), + ) expect(recovered.status).toBe('success') const channel = await afterCrashStore.getChannel(channelId) @@ -2831,18 +2909,20 @@ describe.runIf(isLocalnet)('session', () => { }) hooks.dropOnRead(channelId, 1) - const closeReceipt = (await server.verify({ - credential: { - challenge: makeChallenge({ id: 'close-racy-missing', channelId }), - payload: { - action: 'close' as const, - channelId, - cumulativeAmount: '1000000', - signature: await signTestVoucher(channelId, 1000000n), + const closeReceipt = expectSettledReceipt( + await server.verify({ + credential: { + challenge: makeChallenge({ id: 'close-racy-missing', channelId }), + payload: { + action: 'close' as const, + channelId, + cumulativeAmount: '1000000', + signature: await signTestVoucher(channelId, 1000000n), + }, }, - }, - request: makeRequest(), - })) as SessionReceipt + request: makeRequest(), + }), + ) expect(closeReceipt.status).toBe('success') expect(closeReceipt.spent).toBe('0') @@ -3033,15 +3113,17 @@ describe.runIf(isLocalnet)('session', () => { signature: await signTestVoucher(channelId, 2000000n), }, } - const openReceipt = (await server.verify({ - credential: openCredential, - envelope: { - capturedRequest: mcpCapturedRequest, - challenge: openChallenge, + const openReceipt = expectSettledReceipt( + await server.verify({ credential: openCredential, - }, - request: makeRequest({ amount: '1' }), - } as never)) as SessionReceipt + envelope: { + capturedRequest: mcpCapturedRequest, + challenge: openChallenge, + credential: openCredential, + }, + request: makeRequest({ amount: '1' }), + } as never), + ) expect(openReceipt.spent).toBe('1000000') expect(openReceipt.units).toBe(1) @@ -3055,15 +3137,17 @@ describe.runIf(isLocalnet)('session', () => { signature: await signTestVoucher(channelId, 2000000n), }, } - const replayReceipt = (await server.verify({ - credential: replayCredential, - envelope: { - capturedRequest: mcpCapturedRequest, - challenge: replayChallenge, + const replayReceipt = expectSettledReceipt( + await server.verify({ credential: replayCredential, - }, - request: makeRequest({ amount: '1' }), - } as never)) as SessionReceipt + envelope: { + capturedRequest: mcpCapturedRequest, + challenge: replayChallenge, + credential: replayCredential, + }, + request: makeRequest({ amount: '1' }), + } as never), + ) expect(replayReceipt.spent).toBe('2000000') expect(replayReceipt.units).toBe(2) @@ -3189,6 +3273,7 @@ describe.runIf(isLocalnet)('session', () => { const request = new Request(input, init) const result = await route(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response if (request.method === 'GET') contentRequests++ return result.withReceipt(new Response('ok')) } @@ -3269,6 +3354,7 @@ describe.runIf(isLocalnet)('session', () => { const serve = async (request: Request) => { const result = await route(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response return result.withReceipt(new Response('paid-content')) } @@ -3338,6 +3424,7 @@ describe.runIf(isLocalnet)('session', () => { const serve = async (request: Request) => { const result = await route(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response return result.withReceipt(new Response('paid-content')) } @@ -3412,6 +3499,7 @@ describe.runIf(isLocalnet)('session', () => { const serve = async (request: Request) => { const result = await route(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response return result.withReceipt(new Response('paid-content')) } @@ -3851,6 +3939,7 @@ describe.runIf(isLocalnet)('session', () => { const request = new Request(input, init) const result = await routeHandler(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response return result.withReceipt(new Response('upstream failed', { status: 500 })) } @@ -3893,6 +3982,7 @@ describe.runIf(isLocalnet)('session', () => { const request = new Request(input, init) const result = await routeHandler(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response return result.withReceipt(new Response(null, { status: 204 })) } @@ -3939,6 +4029,7 @@ describe.runIf(isLocalnet)('session', () => { : undefined const result = await routeHandler(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response if (action === 'close') { return new Response('close failed', { status: 500, @@ -3987,6 +4078,7 @@ describe.runIf(isLocalnet)('session', () => { const request = new Request(input, init) const result = await routeHandler(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response return result.withReceipt(new Response('ok')) } @@ -4115,6 +4207,7 @@ describe.runIf(isLocalnet)('session', () => { const result = await routeHandler(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response if (action === 'voucher') { return new Response(null, { status: 200 }) @@ -4193,6 +4286,7 @@ describe.runIf(isLocalnet)('session', () => { const result = await routeHandler(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response if (action === 'voucher') { return new Response(null, { status: 200 }) @@ -4278,6 +4372,7 @@ describe.runIf(isLocalnet)('session', () => { const result = await routeHandler(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response if (action === 'voucher') { return new Response(null, { status: 200 }) @@ -4341,6 +4436,7 @@ describe.runIf(isLocalnet)('session', () => { const request = new Request(input, init) const result = await routeHandler(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response if (request.headers.get('Accept')?.includes('text/event-stream')) { return result.withReceipt(async function* (stream) { @@ -4422,6 +4518,7 @@ describe.runIf(isLocalnet)('session', () => { const httpHandler = NodeRequest.toNodeListener(async (request) => { const result = await route(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response return result.withReceipt(new Response('ok')) }) @@ -4535,6 +4632,7 @@ describe.runIf(isLocalnet)('session', () => { const httpHandler = NodeRequest.toNodeListener(async (request) => { const result = await route(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response return result.withReceipt(new Response('ok')) }) @@ -4639,6 +4737,7 @@ describe.runIf(isLocalnet)('session', () => { const httpHandler = NodeRequest.toNodeListener(async (request) => { const result = await route(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response return result.withReceipt(new Response('ok')) }) @@ -4747,6 +4846,7 @@ describe.runIf(isLocalnet)('session', () => { const httpHandler = NodeRequest.toNodeListener(async (request) => { const result = await routeHandler(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response return result.withReceipt(new Response('ok')) }) @@ -4836,6 +4936,7 @@ describe.runIf(isLocalnet)('session', () => { const httpHandler = NodeRequest.toNodeListener(async (request) => { const result = await route(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response return result.withReceipt(new Response('ok')) }) @@ -4927,6 +5028,7 @@ describe.runIf(isLocalnet)('session', () => { const httpHandler = NodeRequest.toNodeListener(async (request) => { const result = await routeHandler(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response return result.withReceipt(new Response('ok')) }) @@ -5038,6 +5140,7 @@ describe.runIf(isLocalnet)('session', () => { const httpHandler = NodeRequest.toNodeListener(async (request) => { const result = await route(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response return result.withReceipt(new Response('ok')) }) @@ -5138,6 +5241,7 @@ describe.runIf(isLocalnet)('session', () => { const httpHandler = NodeRequest.toNodeListener(async (request) => { const result = await routeHandler(request) if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response return result.withReceipt(new Response('ok')) }) diff --git a/src/tempo/server/Subscription.test.ts b/src/tempo/server/Subscription.test.ts new file mode 100644 index 00000000..04d71530 --- /dev/null +++ b/src/tempo/server/Subscription.test.ts @@ -0,0 +1,379 @@ +import { Receipt } from 'mppx' +import { Mppx } from 'mppx/server' +import { describe, expect, test } from 'vp/test' + +import * as Store from '../../Store.js' +import * as SubscriptionStore from '../subscription/Store.js' +import type { SubscriptionRecord } from '../subscription/Types.js' +import { + cancel, + captureActive, + completeCapture, + failCapture, + revoke, + subscription, +} from './Subscription.js' + +const realm = 'api.example.com' +const secretKey = 'test-secret-key' +const activeBillingAnchor = new Date(Date.now() - 1_000).toISOString() +const activeSubscriptionExpires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1_000).toISOString() +const identity = { id: 'user-1' } as const +const requestOptions = { + amount: '10', + chainId: 4217, + currency: '0x20c0000000000000000000000000000000000001', + periodSeconds: '3600', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + subscriptionExpires: activeSubscriptionExpires, +} as const +const resource = { id: 'resource:alpha' } as const + +function createReceipt(subscriptionId: string, reference = '0xreceipt') { + return { + method: 'tempo', + reference, + status: 'success', + subscriptionId, + timestamp: '2025-01-01T00:00:00.000Z', + } as const +} + +function createRecord(overrides: Partial = {}): SubscriptionRecord { + return { + amount: '10000000', + billingAnchor: activeBillingAnchor, + chainId: 4217, + currency: '0x20c0000000000000000000000000000000000001', + identityId: 'user-1', + lastChargedPeriod: 0, + periodSeconds: '3600', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + reference: '0xsubscription', + resourceId: 'resource:alpha', + subscriptionExpires: activeSubscriptionExpires, + subscriptionId: 'sub_123', + timestamp: '2025-01-01T00:00:00.000Z', + ...overrides, + } +} + +function createDeferred() { + let resolve!: () => void + const promise = new Promise((value) => { + resolve = value + }) + return { promise, resolve } +} + +function createHandler(parameters: { + capture?: Parameters[0]['capture'] + resolve?: Parameters[0]['resolve'] + store: Store.AtomicStore> +}) { + const method = subscription({ + activate: async ({ request, resolution, source }) => ({ + receipt: createReceipt('sub_123', '0xactivate'), + subscription: createRecord({ + amount: request.amount, + chainId: request.methodDetails?.chainId, + currency: request.currency, + identityId: resolution.identity.id, + periodSeconds: request.periodSeconds, + recipient: request.recipient, + resourceId: resolution.resource.id, + subscriptionExpires: request.subscriptionExpires, + timestamp: new Date().toISOString(), + ...(source ? { externalId: source.address } : {}), + }), + }), + ...requestOptions, + ...(parameters.capture ? { capture: parameters.capture } : {}), + resolve: parameters.resolve ?? (async () => ({ identity, resource })), + store: parameters.store, + }) + + return Mppx.create({ methods: [method], realm, secretKey })['tempo/subscription'](requestOptions) +} + +function createRequest() { + return new Request('https://example.com/resource') +} + +describe('tempo.subscription', () => { + test('concurrent request-time renewals only capture once for the same period', async () => { + const store = Store.memory() + const subscriptionStore = SubscriptionStore.fromStore(store) + await subscriptionStore.activate( + createRecord({ + billingAnchor: new Date(Date.now() - 3 * 3_600_000).toISOString(), + lastChargedPeriod: 0, + reference: '0xstale', + subscriptionId: 'sub_due', + }), + ) + + const release = createDeferred() + const started = createDeferred() + let captureCalls = 0 + const handler = createHandler({ + capture: async ({ periodIndex, subscription }) => { + captureCalls++ + started.resolve() + await release.promise + return { + receipt: createReceipt(subscription.subscriptionId, '0xrenewed'), + subscription: { + ...subscription, + lastChargedPeriod: periodIndex, + reference: '0xrenewed', + }, + } + }, + store, + }) + + const first = handler(createRequest()) + const second = handler(createRequest()) + await started.promise + release.resolve() + + const [firstResult, secondResult] = await Promise.all([first, second]) + expect(captureCalls).toBe(1) + expect( + [firstResult.status, secondResult.status].filter((status) => status === 200), + ).toHaveLength(1) + expect( + [firstResult.status, secondResult.status].filter((status) => status === 402), + ).toHaveLength(1) + + const followUp = await handler(createRequest()) + expect(followUp.status).toBe(200) + if (followUp.status !== 200) throw new Error('expected renewed access') + + const receipt = Receipt.fromResponse(followUp.withReceipt(new Response('OK'))) + expect(receipt.reference).toBe('0xrenewed') + + const saved = await subscriptionStore.get('sub_due') + expect(saved?.lastChargedPeriod).toBeGreaterThan(0) + expect(saved?.pendingPeriod).toBeUndefined() + }) + + test('background capture races request-time renewal without double charging', async () => { + const store = Store.memory() + const subscriptionStore = SubscriptionStore.fromStore(store) + await subscriptionStore.activate( + createRecord({ + billingAnchor: new Date(Date.now() - 3 * 3_600_000).toISOString(), + lastChargedPeriod: 0, + reference: '0xstale', + subscriptionId: 'sub_due', + }), + ) + + const release = createDeferred() + const started = createDeferred() + const reasons: string[] = [] + const capture = async ({ + periodIndex, + reason, + subscription, + }: { + periodIndex: number + reason: 'background' | 'request' + subscription: SubscriptionRecord + }) => { + reasons.push(reason) + started.resolve() + await release.promise + return { + receipt: createReceipt(subscription.subscriptionId, '0xbackground'), + subscription: { + ...subscription, + lastChargedPeriod: periodIndex, + reference: '0xbackground', + }, + } + } + const handler = createHandler({ capture, store }) + + const background = captureActive({ capture, identity, resource, store }) + await started.promise + const requestResult = await handler(createRequest()) + release.resolve() + + const backgroundResult = await background + expect(reasons).toEqual(['background']) + expect(backgroundResult?.receipt.reference).toBe('0xbackground') + expect(requestResult.status).toBe(402) + + const followUp = await handler(createRequest()) + expect(followUp.status).toBe(200) + if (followUp.status !== 200) throw new Error('expected post-background access') + + const receipt = Receipt.fromResponse(followUp.withReceipt(new Response('OK'))) + expect(receipt.reference).toBe('0xbackground') + }) + + test('cancel stays active until its effective time, while revoke blocks immediately', async () => { + const store = Store.memory() + const subscriptionStore = SubscriptionStore.fromStore(store) + await subscriptionStore.activate(createRecord()) + + const cancelEffectiveAt = new Date(Date.now() + 60_000).toISOString() + const canceled = await cancel({ cancelEffectiveAt, store, subscriptionId: 'sub_123' }) + expect(canceled?.cancelEffectiveAt).toBe(cancelEffectiveAt) + + const handler = createHandler({ store }) + const beforeRevocation = await handler(createRequest()) + expect(beforeRevocation.status).toBe(200) + + const revokedAt = new Date().toISOString() + const revoked = await revoke({ revokedAt, store, subscriptionId: 'sub_123' }) + expect(revoked?.revokedAt).toBe(revokedAt) + + const afterRevocation = await handler(createRequest()) + expect(afterRevocation.status).toBe(402) + }) + + test('activation atomically replaces the previous active subscription', async () => { + const store = Store.memory() + const subscriptionStore = SubscriptionStore.fromStore(store) + await subscriptionStore.activate( + createRecord({ + pendingPeriod: 2, + pendingPeriodStartedAt: new Date().toISOString(), + reference: '0xold', + subscriptionId: 'sub_old', + }), + ) + + const replacementTimestamp = new Date().toISOString() + await subscriptionStore.activate( + createRecord({ + pendingPeriod: 9, + pendingPeriodStartedAt: new Date().toISOString(), + reference: '0xnew', + subscriptionId: 'sub_new', + timestamp: replacementTimestamp, + }), + ) + + const active = await subscriptionStore.getActive(identity.id, resource.id) + expect(active?.subscriptionId).toBe('sub_new') + expect(active?.pendingPeriod).toBeUndefined() + + const replaced = await subscriptionStore.get('sub_old') + expect(replaced?.cancelEffectiveAt).toBe(replacementTimestamp) + expect(replaced?.pendingPeriod).toBeUndefined() + expect(replaced?.pendingPeriodStartedAt).toBeUndefined() + }) + + test('completeCapture finalizes a pending request-time renewal', async () => { + const store = Store.memory() + const subscriptionStore = SubscriptionStore.fromStore(store) + await subscriptionStore.activate( + createRecord({ + billingAnchor: new Date(Date.now() - 3 * 3_600_000).toISOString(), + lastChargedPeriod: 0, + reference: '0xstale', + subscriptionId: 'sub_due', + }), + ) + + const handler = createHandler({ + capture: async () => ({ + response: new Response('capture pending', { + headers: { Location: '/subscriptions/sub_due/capture' }, + status: 202, + }), + }), + store, + }) + + const pending = await handler(createRequest()) + expect(pending.status).toBe('pending') + if (pending.status !== 'pending') throw new Error('expected pending capture') + expect(pending.response.status).toBe(202) + expect(pending.response.headers.get('location')).toBe('/subscriptions/sub_due/capture') + + const claimed = await subscriptionStore.get('sub_due') + expect(claimed?.pendingPeriod).toBeGreaterThan(0) + expect(claimed?.lastChargedPeriod).toBe(0) + if (!claimed?.pendingPeriod) throw new Error('expected claimed capture period') + + await completeCapture({ + periodIndex: claimed.pendingPeriod, + store, + subscription: { + ...claimed, + lastChargedPeriod: claimed.pendingPeriod, + reference: '0xcompleted', + }, + }) + + const completed = await subscriptionStore.get('sub_due') + expect(completed?.pendingPeriod).toBeUndefined() + expect(completed?.pendingPeriodStartedAt).toBeUndefined() + expect(completed?.reference).toBe('0xcompleted') + + const followUp = await handler(createRequest()) + expect(followUp.status).toBe(200) + if (followUp.status !== 200) throw new Error('expected completed capture access') + + const receipt = Receipt.fromResponse(followUp.withReceipt(new Response('OK'))) + expect(receipt.reference).toBe('0xcompleted') + }) + + test('failCapture clears the claim so a later request can retry the period', async () => { + const store = Store.memory() + const subscriptionStore = SubscriptionStore.fromStore(store) + await subscriptionStore.activate( + createRecord({ + billingAnchor: new Date(Date.now() - 3 * 3_600_000).toISOString(), + lastChargedPeriod: 0, + reference: '0xstale', + subscriptionId: 'sub_due', + }), + ) + + let captureCalls = 0 + const handler = createHandler({ + capture: async ({ periodIndex, subscription }) => { + captureCalls++ + if (captureCalls === 1) { + return { response: new Response('capture pending', { status: 202 }) } + } + return { + receipt: createReceipt(subscription.subscriptionId, '0xretried'), + subscription: { + ...subscription, + lastChargedPeriod: periodIndex, + reference: '0xretried', + }, + } + }, + store, + }) + + const first = await handler(createRequest()) + expect(first.status).toBe('pending') + + const claimed = await subscriptionStore.get('sub_due') + if (!claimed?.pendingPeriod) throw new Error('expected pending claim') + + await failCapture({ periodIndex: claimed.pendingPeriod, store, subscriptionId: 'sub_due' }) + + const cleared = await subscriptionStore.get('sub_due') + expect(cleared?.pendingPeriod).toBeUndefined() + expect(cleared?.pendingPeriodStartedAt).toBeUndefined() + + const retried = await handler(createRequest()) + expect(captureCalls).toBe(2) + expect(retried.status).toBe(200) + if (retried.status !== 200) throw new Error('expected retry success') + + const receipt = Receipt.fromResponse(retried.withReceipt(new Response('OK'))) + expect(receipt.reference).toBe('0xretried') + }) +}) diff --git a/src/tempo/server/Subscription.ts b/src/tempo/server/Subscription.ts new file mode 100644 index 00000000..7e541b4d --- /dev/null +++ b/src/tempo/server/Subscription.ts @@ -0,0 +1,588 @@ +import { KeyAuthorization, SignatureEnvelope } from 'ox/tempo' +import { formatUnits, type Address } from 'viem' +import { Actions } from 'viem/tempo' + +import * as Errors from '../../Errors.js' +import type { LooseOmit, MaybePromise, NoExtraKeys } from '../../internal/types.js' +import * as Method from '../../Method.js' +import type * as Html from '../../server/internal/html/config.ts' +import * as Store from '../../Store.js' +import * as Client from '../../viem/Client.js' +import type * as z from '../../zod.js' +import * as Account from '../internal/account.js' +import * as defaults from '../internal/defaults.js' +import * as Proof from '../internal/proof.js' +import type * as types from '../internal/types.js' +import * as Methods from '../Methods.js' +import * as SubscriptionReceipt from '../subscription/Receipt.js' +import * as SubscriptionStore from '../subscription/Store.js' +import type { + SubscriptionAccessKey, + SubscriptionCredentialPayload, + SubscriptionIdentity, + SubscriptionRecord, + SubscriptionReceipt as SubscriptionReceiptValue, + SubscriptionResolution, + SubscriptionResource, +} from '../subscription/Types.js' +import { html as htmlContent } from './internal/html.gen.js' + +/** + * Creates a Tempo subscription method backed by a single active subscription per identity/resource. + */ +export function subscription( + p: NoExtraKeys, +) { + const parameters = p as parameters + const { + amount, + currency = defaults.resolveCurrency(parameters), + decimals = defaults.decimals, + description, + externalId, + html, + periodSeconds, + store: rawStore = Store.memory(), + subscriptionExpires, + } = parameters + + const store = SubscriptionStore.fromStore(rawStore) + const getClient = Client.getResolver({ + getClient: parameters.getClient, + rpcUrl: defaults.rpcUrl, + }) + const { recipient } = Account.resolve(parameters) + + type Defaults = subscription.DeriveDefaults + return Method.toServer(Methods.subscription, { + defaults: { + amount, + currency, + decimals, + description, + externalId, + periodSeconds, + recipient, + subscriptionExpires, + } as unknown as Defaults, + + html: html + ? { + config: { + accessKey: html.accessKey, + }, + content: htmlContent, + formatAmount: async (request: z.output) => { + const amount = await formatHtmlAmount({ getClient, request }) + return `${amount} / ${formatBillingInterval(request.periodSeconds)}` + }, + text: html.text, + theme: html.theme, + } + : undefined, + + async authorize({ input, request }) { + const resolution = await parameters.resolve({ input, request }) + if (!resolution) return undefined + + return authorizeActiveSubscription({ + capture: parameters.capture, + resolution, + store, + }) + }, + + async request({ request }) { + const chainId = await (async () => { + if (request.chainId) return request.chainId + if (parameters.chainId) return parameters.chainId + if (parameters.testnet) return defaults.chainId.testnet + return (await getClient({})).chain?.id ?? defaults.chainId.mainnet + })() + + return { + ...request, + chainId, + } + }, + + stableBinding(request) { + return subscriptionBinding(request) + }, + + async verify({ credential, envelope, request }) { + const parsedRequest = Methods.subscription.schema.request.parse(request) + const source = credential.source ? Proof.parseProofSource(credential.source) : null + const authorization = parseAndVerifyAuthorization({ + payload: credential.payload as SubscriptionCredentialPayload, + request: parsedRequest, + source, + }) + + const input = envelope + ? new Request(envelope.capturedRequest.url, { + headers: envelope.capturedRequest.headers, + method: envelope.capturedRequest.method, + }) + : new Request('https://subscription.invalid') + const resolution = await parameters.resolve({ input, request: parsedRequest }) + if (!resolution) { + throw new Errors.VerificationFailedError({ + reason: 'subscription target could not be resolved for activation', + }) + } + + const activation = await parameters.activate({ + authorization, + credential: credential as typeof credential & { + payload: SubscriptionCredentialPayload + }, + input, + request: parsedRequest, + resolution, + source, + }) + + await store.activate(normalizeSubscriptionRecord(activation.subscription, resolution)) + return activation.receipt + }, + }) +} + +async function authorizeActiveSubscription(parameters: { + capture: subscription.Parameters['capture'] + resolution: SubscriptionResolution + store: SubscriptionStore.SubscriptionStore +}): Promise { + const { capture, resolution, store } = parameters + const subscription = await store.getActive(resolution.identity.id, resolution.resource.id) + if (!subscription || !isActive(subscription)) return undefined + + const periodIndex = getPeriodIndex(subscription) + if (periodIndex <= subscription.lastChargedPeriod) { + return { + receipt: SubscriptionReceipt.fromRecord(subscription), + } + } + if (!capture) return undefined + + return ( + (await captureDueSubscription({ + capture, + reason: 'request', + store, + subscription, + })) ?? undefined + ) +} + +async function captureDueSubscription(parameters: { + capture: NonNullable + reason: subscription.CaptureReason + store: SubscriptionStore.SubscriptionStore + subscription: SubscriptionRecord +}): Promise { + const { capture, reason, store, subscription } = parameters + const periodIndex = getPeriodIndex(subscription) + if (periodIndex <= subscription.lastChargedPeriod) return null + + const claimed = await store.claimPendingCapture( + subscription.subscriptionId, + periodIndex, + Date.now(), + ) + if (!claimed) return null + + try { + const result = await capture({ + periodIndex, + reason, + subscription: claimed, + }) + if (isPendingResult(result)) return result + + await store.completePendingCapture(result.subscription, periodIndex) + return result + } catch (error) { + await store.clearPendingCapture(subscription.subscriptionId, periodIndex) + throw error + } +} + +function parseAndVerifyAuthorization(parameters: { + payload: SubscriptionCredentialPayload + request: ReturnType + source: { address: Address; chainId: number } | null +}): KeyAuthorization.KeyAuthorization { + const { payload, request, source } = parameters + + let authorization: KeyAuthorization.KeyAuthorization + try { + authorization = KeyAuthorization.deserialize(payload.signature) + } catch { + throw new Errors.InvalidPayloadError({ reason: 'subscription key authorization is malformed' }) + } + + if (!source) { + throw new Errors.VerificationFailedError({ + reason: 'subscription credentials must include a proof source', + }) + } + + const expectedChainId = request.methodDetails?.chainId + if (expectedChainId === undefined) { + throw new Errors.VerificationFailedError({ + reason: 'subscription request is missing chainId', + }) + } + if (authorization.chainId !== BigInt(expectedChainId)) { + throw new Errors.VerificationFailedError({ + reason: 'authorization chainId does not match request', + }) + } + if (source.chainId !== expectedChainId) { + throw new Errors.VerificationFailedError({ + reason: 'proof source chainId does not match request', + }) + } + + const expectedExpiry = Math.floor(new Date(request.subscriptionExpires).getTime() / 1_000) + if ((authorization.expiry ?? 0) !== expectedExpiry) { + throw new Errors.VerificationFailedError({ + reason: 'authorization expiry does not match subscriptionExpires', + }) + } + + if (!authorization.limits || authorization.limits.length !== 1) { + throw new Errors.VerificationFailedError({ + reason: 'authorization must contain exactly one token spending limit', + }) + } + + const limit = authorization.limits[0] + if (!limit) { + throw new Errors.VerificationFailedError({ + reason: 'authorization must contain exactly one token spending limit', + }) + } + if (String(limit.token).toLowerCase() !== request.currency.toLowerCase()) { + throw new Errors.VerificationFailedError({ + reason: 'authorization token does not match request', + }) + } + if (limit.limit.toString() !== request.amount) { + throw new Errors.VerificationFailedError({ + reason: 'authorization amount does not match request', + }) + } + + if (!authorization.signature) { + throw new Errors.VerificationFailedError({ + reason: 'authorization signature is missing', + }) + } + + const valid = SignatureEnvelope.verify(authorization.signature, { + address: source.address, + payload: KeyAuthorization.getSignPayload(authorization), + }) + if (!valid) { + throw new Errors.VerificationFailedError({ + reason: 'authorization signature does not match proof source', + }) + } + + return authorization as KeyAuthorization.KeyAuthorization +} + +function normalizeSubscriptionRecord( + record: SubscriptionRecord, + resolution: SubscriptionResolution, +): SubscriptionRecord { + return { + ...record, + identityId: resolution.identity.id, + pendingPeriod: undefined, + pendingPeriodStartedAt: undefined, + resourceId: resolution.resource.id, + } +} + +function getPeriodIndex(subscription: SubscriptionRecord, now = Date.now()): number { + const anchor = new Date(subscription.billingAnchor).getTime() + const expires = new Date(subscription.subscriptionExpires).getTime() + if (!Number.isFinite(anchor) || !Number.isFinite(expires) || now >= expires) { + return Number.POSITIVE_INFINITY + } + + const periodSeconds = Number(subscription.periodSeconds) + if (!Number.isSafeInteger(periodSeconds) || periodSeconds <= 0) { + return Number.POSITIVE_INFINITY + } + + return Math.max(0, Math.floor((now - anchor) / (periodSeconds * 1_000))) +} + +function isActive(subscription: SubscriptionRecord, now = Date.now()): boolean { + if (subscription.revokedAt) return false + + const cancelEffectiveAt = subscription.cancelEffectiveAt + ? new Date(subscription.cancelEffectiveAt).getTime() + : Number.POSITIVE_INFINITY + if (Number.isFinite(cancelEffectiveAt) && now >= cancelEffectiveAt) return false + + return new Date(subscription.subscriptionExpires).getTime() > now +} + +function isPendingResult(value: unknown): value is Method.PendingResult { + return !!value && typeof value === 'object' && 'response' in value && !('receipt' in value) +} + +function subscriptionBinding( + request: ReturnType, +) { + return { + amount: request.amount, + chainId: request.methodDetails?.chainId, + currency: request.currency, + periodSeconds: request.periodSeconds, + recipient: request.recipient, + subscriptionExpires: request.subscriptionExpires, + } +} + +async function formatHtmlAmount(parameters: { + getClient: ReturnType + request: z.output +}) { + const { getClient, request } = parameters + + try { + const chainId = request.methodDetails?.chainId + if (chainId === undefined) throw new Error('no chainId') + + const client = await getClient({ chainId }) + const metadata = await Actions.token.getMetadata(client, { + token: request.currency as `0x${string}`, + }) + const symbol = + new Intl.NumberFormat('en', { + style: 'currency', + currency: metadata.currency, + currencyDisplay: 'narrowSymbol', + }) + .formatToParts(0) + .find((part) => part.type === 'currency')?.value ?? metadata.currency + + return `${symbol}${formatUnits(BigInt(request.amount), metadata.decimals)}` + } catch { + return `$${request.amount}` + } +} + +const SECONDS_PER_MINUTE = 60 +const SECONDS_PER_HOUR = 3_600 +const SECONDS_PER_DAY = 86_400 +const SECONDS_PER_WEEK = 604_800 +const SECONDS_PER_MONTH = 2_592_000 +const SECONDS_PER_YEAR = 31_536_000 + +function formatBillingInterval(periodSeconds: string) { + switch (Number(periodSeconds)) { + case SECONDS_PER_MINUTE: + return 'minute' + case SECONDS_PER_HOUR: + return 'hour' + case SECONDS_PER_DAY: + return 'day' + case SECONDS_PER_WEEK: + return 'week' + case SECONDS_PER_MONTH: + return 'month' + case SECONDS_PER_YEAR: + return 'year' + default: + return `every ${periodSeconds}s` + } +} + +/** + * Captures the current billing period for the active subscription at an identity/resource pair. + */ +export async function captureActive( + parameters: captureActive.Parameters, +): Promise { + const { capture, identity, resource, store: rawStore = Store.memory() } = parameters + const store = SubscriptionStore.fromStore(rawStore) + const subscription = await store.getActive(identity.id, resource.id) + if (!subscription || !isActive(subscription)) return null + + const result = await captureDueSubscription({ + capture, + reason: 'background', + store, + subscription, + }) + if (!result || isPendingResult(result)) { + if (isPendingResult(result)) { + throw new Error('captureActive() does not support pending capture results.') + } + return null + } + + return result +} + +/** + * Finalizes a previously pending capture and clears the in-flight claim. + */ +export async function completeCapture(parameters: completeCapture.Parameters): Promise { + const { periodIndex, store: rawStore = Store.memory(), subscription } = parameters + const store = SubscriptionStore.fromStore(rawStore) + await store.completePendingCapture(subscription, periodIndex) +} + +/** + * Clears a previously claimed pending capture without advancing billing state. + */ +export async function failCapture(parameters: failCapture.Parameters): Promise { + const { periodIndex, store: rawStore = Store.memory(), subscriptionId } = parameters + const store = SubscriptionStore.fromStore(rawStore) + await store.clearPendingCapture(subscriptionId, periodIndex) +} + +/** + * Cancels a subscription effective at the provided timestamp. + */ +export async function cancel(parameters: cancel.Parameters): Promise { + const { cancelEffectiveAt, store: rawStore = Store.memory(), subscriptionId } = parameters + const store = SubscriptionStore.fromStore(rawStore) + return store.markCanceled(subscriptionId, cancelEffectiveAt) +} + +/** + * Revokes a subscription immediately. + */ +export async function revoke(parameters: revoke.Parameters): Promise { + const { revokedAt, store: rawStore = Store.memory(), subscriptionId } = parameters + const store = SubscriptionStore.fromStore(rawStore) + return store.markRevoked(subscriptionId, revokedAt) +} + +export declare namespace captureActive { + type Parameters = { + /** Billing callback used to capture the next due period. */ + capture: NonNullable + /** Subscription identity to bill. */ + identity: SubscriptionIdentity + /** Subscription resource to bill. */ + resource: SubscriptionResource + /** Store containing subscription records. */ + store?: Store.AtomicStore> | undefined + } + + type Result = subscription.RenewalResult +} + +export declare namespace completeCapture { + type Parameters = { + /** Period index that was captured. */ + periodIndex: number + /** Updated subscription record to persist. */ + subscription: SubscriptionRecord + /** Store containing subscription records. */ + store?: Store.AtomicStore> | undefined + } +} + +export declare namespace failCapture { + type Parameters = { + /** Period index whose pending claim should be cleared. */ + periodIndex: number + /** Store containing subscription records. */ + store?: Store.AtomicStore> | undefined + /** Subscription whose pending claim should be cleared. */ + subscriptionId: string + } +} + +export declare namespace cancel { + type Parameters = { + /** Timestamp when the subscription stops authorizing renewals. */ + cancelEffectiveAt: string + /** Store containing subscription records. */ + store?: Store.AtomicStore> | undefined + /** Subscription to cancel. */ + subscriptionId: string + } +} + +export declare namespace revoke { + type Parameters = { + /** Timestamp when the subscription was revoked. */ + revokedAt: string + /** Store containing subscription records. */ + store?: Store.AtomicStore> | undefined + /** Subscription to revoke. */ + subscriptionId: string + } +} + +export declare namespace subscription { + type ActivationResult = { + receipt: SubscriptionReceiptValue + response?: Response | undefined + subscription: SubscriptionRecord + } + + type RenewalResult = { + receipt: SubscriptionReceiptValue + response?: Response | undefined + subscription: SubscriptionRecord + } + + type CaptureReason = 'background' | 'request' + + type Defaults = LooseOmit, 'recipient'> + + type Parameters = Account.resolve.Parameters & + Client.getResolver.Parameters & { + activate: (parameters: { + authorization: KeyAuthorization.KeyAuthorization + credential: { + payload: SubscriptionCredentialPayload + source?: string | undefined + } + input: Request + request: ReturnType + resolution: SubscriptionResolution + source: { address: Address; chainId: number } | null + }) => Promise + capture?: (parameters: { + periodIndex: number + reason: CaptureReason + subscription: SubscriptionRecord + }) => Promise + html?: + | { + accessKey: SubscriptionAccessKey + text?: Html.Text | undefined + theme?: Html.Theme | undefined + } + | undefined + periodSeconds?: string | undefined + resolve: (parameters: { + input: Request + request: ReturnType + }) => MaybePromise + store?: Store.AtomicStore> | undefined + testnet?: boolean | undefined + } & Defaults + + type DeriveDefaults = types.DeriveDefaults< + parameters, + Defaults + > & { + decimals: number + } +} diff --git a/src/tempo/server/index.ts b/src/tempo/server/index.ts index 05d96e40..48898b15 100644 --- a/src/tempo/server/index.ts +++ b/src/tempo/server/index.ts @@ -4,3 +4,11 @@ export * as Ws from '../session/Ws.js' export { charge } from './Charge.js' export { tempo } from './Methods.js' export { session, settle } from './Session.js' +export { + cancel, + captureActive, + completeCapture, + failCapture, + revoke, + subscription, +} from './Subscription.js' diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts index 148cb623..5970c3d2 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -5,8 +5,14 @@ import { tempoModerato, tempoLocalnet } from 'viem/chains' import { tempo } from '../../../../client/index.js' import * as Html from '../../../../Html.js' import type * as Methods from '../../../Methods.js' +import type { SubscriptionAccessKey } from '../../../subscription/Types.js' -const c = Html.init('tempo') +const c = Html.init< + typeof Methods.charge | typeof Methods.subscription, + { + accessKey?: SubscriptionAccessKey | undefined + } +>('tempo') const css = String.raw const style = document.createElement('style') @@ -73,7 +79,12 @@ const provider = Provider.create({ }) const button = document.createElement('button') -const buttonLabel = c.text.pay === 'Pay' ? 'Continue with' : c.text.pay +const buttonLabel = + c.challenge.intent === 'subscription' + ? 'Authorize with' + : c.text.pay === 'Pay' + ? 'Continue with' + : c.text.pay button.innerHTML = `${buttonLabel} ` button.onclick = async () => { try { @@ -94,9 +105,30 @@ button.onclick = async () => { ) return createClient({ chain, transport: custom(provider) }) as never } - const method = tempo({ account, getClient })[0] - const credential = await method.createCredential({ challenge: c.challenge, context: {} }) + const credential = await (async () => { + if (c.challenge.intent === 'charge') { + const challenge = c.challenge as Extract + const method = tempo.charge({ account, getClient }) + return method.createCredential({ challenge, context: {} }) + } + + if (c.challenge.intent === 'subscription') { + if (!c.config.accessKey) throw new Error('Missing subscription access key configuration.') + + const challenge = c.challenge as Extract + const method = tempo.subscription({ + accessKey: c.config.accessKey, + account, + getClient, + }) + + return method.createCredential({ challenge, context: {} }) + } + + throw new Error(`Unsupported Tempo HTML intent: ${c.challenge.intent}`) + })() + await c.submit(credential) } catch (e) { const message = e instanceof Error && 'shortMessage' in e ? (e as any).shortMessage : undefined diff --git a/src/tempo/session/Ws.ts b/src/tempo/session/Ws.ts index 03cc23dd..fa527879 100644 --- a/src/tempo/session/Ws.ts +++ b/src/tempo/session/Ws.ts @@ -9,6 +9,7 @@ export type { SessionController } from './Sse.js' export type SessionRouteResult = | { status: 402; challenge: Response } + | { status: 'pending'; response: Response } | { status: 200; withReceipt(response?: Response): Response } export type SessionRoute = (request: Request) => Promise @@ -266,6 +267,22 @@ export async function serve(options: serve.Options): Promise { return } + if (result.status === 'pending') { + const message = + (await result.response.text().catch(() => '')) || + result.response.statusText || + 'payment is pending' + await send( + socket, + formatErrorMessage({ + message, + status: result.response.status || 202, + }), + ) + await close(1008, message) + return + } + const response = result.withReceipt(new Response(null, { status: 204 })) const receiptHeader = response.headers.get('Payment-Receipt') if (!receiptHeader) { diff --git a/src/tempo/subscription/Identity.ts b/src/tempo/subscription/Identity.ts new file mode 100644 index 00000000..8ec4385d --- /dev/null +++ b/src/tempo/subscription/Identity.ts @@ -0,0 +1,71 @@ +import type { MaybePromise } from '../../internal/types.js' +import type { SubscriptionIdentity } from './Types.js' + +type IdentityExtractor = (parameters: { + input: Request + request: Record +}) => MaybePromise + +/** + * Extracts identity from a request header value. + * + * @example + * ```ts + * subscription({ getIdentity: Identity.fromHeader('X-User-Id'), ... }) + * ``` + */ +export function fromHeader(name: string): IdentityExtractor { + return ({ input }) => { + const value = input.headers.get(name)?.trim() + return value ? { id: value } : null + } +} + +/** + * Extracts identity from a Bearer token in the Authorization header using + * a decoder function (e.g. JWT verification). + * + * @example + * ```ts + * subscription({ + * getIdentity: Identity.fromBearer(async (token) => { + * const claims = await verifyJwt(token) + * return claims.sub + * }), + * ... + * }) + * ``` + */ +export function fromBearer( + decode: (token: string) => MaybePromise, +): IdentityExtractor { + return async ({ input }) => { + const auth = input.headers.get('Authorization') + if (!auth) return null + const match = /^Bearer\s+(.+)$/i.exec(auth) + if (!match?.[1]) return null + const id = await decode(match[1]) + return id ? { id } : null + } +} + +/** + * Extracts identity from a wallet address in a request header. + * Accepts raw `0x…` addresses or `did:pkh:eip155:…` DIDs. + * The identity ID is the lowercased wallet address. + * + * @example + * ```ts + * subscription({ getIdentity: Identity.fromWallet('X-Wallet-Address'), ... }) + * ``` + */ +export function fromWallet(headerName: string): IdentityExtractor { + return ({ input }) => { + const value = input.headers.get(headerName)?.trim() + if (!value) return null + const address = /^0x[0-9a-f]{40}$/i.test(value) + ? value + : /^did:pkh:eip155:\d+:(0x[0-9a-f]{40})$/i.exec(value)?.[1] + return address ? { id: address.toLowerCase() } : null + } +} diff --git a/src/tempo/subscription/Receipt.ts b/src/tempo/subscription/Receipt.ts new file mode 100644 index 00000000..4a312631 --- /dev/null +++ b/src/tempo/subscription/Receipt.ts @@ -0,0 +1,25 @@ +import type { SubscriptionRecord, SubscriptionReceipt } from './Types.js' + +export function createSubscriptionReceipt( + parameters: createSubscriptionReceipt.Parameters, +): SubscriptionReceipt { + return { + method: 'tempo', + reference: parameters.reference, + status: 'success', + subscriptionId: parameters.subscriptionId, + timestamp: parameters.timestamp, + ...(parameters.externalId ? { externalId: parameters.externalId } : {}), + } +} + +export declare namespace createSubscriptionReceipt { + type Parameters = Pick< + SubscriptionRecord, + 'externalId' | 'reference' | 'subscriptionId' | 'timestamp' + > +} + +export function fromRecord(record: SubscriptionRecord): SubscriptionReceipt { + return createSubscriptionReceipt(record) +} diff --git a/src/tempo/subscription/Store.ts b/src/tempo/subscription/Store.ts new file mode 100644 index 00000000..37adef1f --- /dev/null +++ b/src/tempo/subscription/Store.ts @@ -0,0 +1,192 @@ +import * as Store from '../../Store.js' +import type { SubscriptionRecord } from './Types.js' + +const defaultRecordPrefix = 'tempo:subscription:record:' +const defaultResourcePrefix = 'tempo:subscription:resource:' +const defaultPendingTimeoutMs = 5 * 60 * 1_000 + +/** Subscription-aware wrapper around a generic key-value store. */ +export type SubscriptionStore = { + activate: (record: SubscriptionRecord) => Promise + claimPendingCapture: ( + subscriptionId: string, + periodIndex: number, + now: number, + ) => Promise + clearPendingCapture: (subscriptionId: string, periodIndex: number) => Promise + completePendingCapture: (record: SubscriptionRecord, periodIndex: number) => Promise + get: (subscriptionId: string) => Promise + getActive: (identityId: string, resourceId: string) => Promise + markCanceled: ( + subscriptionId: string, + cancelEffectiveAt: string, + ) => Promise + markRevoked: (subscriptionId: string, revokedAt: string) => Promise + save: (record: SubscriptionRecord) => Promise +} + +/** Wraps a generic atomic {@link Store.Store} with subscription-specific accessors. */ +export function fromStore( + store: Store.AtomicStore>, + options?: fromStore.Options, +): SubscriptionStore { + const recordPrefix = options?.recordPrefix ?? defaultRecordPrefix + const pendingTimeoutMs = options?.pendingTimeoutMs ?? defaultPendingTimeoutMs + const resourcePrefix = options?.resourcePrefix ?? defaultResourcePrefix + + function recordKey(subscriptionId: string): string { + return `${recordPrefix}${subscriptionId}` + } + + function resourceKey(identityId: string, resourceId: string): string { + return `${resourcePrefix}${identityId}:${resourceId}` + } + + return { + async activate(record) { + const key = resourceKey(record.identityId, record.resourceId) + const previousId = (await store.get(key)) as string | null + if (previousId && previousId !== record.subscriptionId) { + await store.update(recordKey(previousId), (current) => { + const currentRecord = current as SubscriptionRecord | null + if (!currentRecord) return { op: 'noop', result: undefined } + return { + op: 'set', + result: undefined, + value: { + ...currentRecord, + cancelEffectiveAt: currentRecord.cancelEffectiveAt ?? record.timestamp, + pendingPeriod: undefined, + pendingPeriodStartedAt: undefined, + } satisfies SubscriptionRecord, + } + }) + } + + await store.put(recordKey(record.subscriptionId), { + ...record, + pendingPeriod: undefined, + pendingPeriodStartedAt: undefined, + }) + await store.put(key, record.subscriptionId) + }, + + async claimPendingCapture(subscriptionId, periodIndex, now) { + return store.update(recordKey(subscriptionId), (current) => { + const currentRecord = current as SubscriptionRecord | null + if (!currentRecord) return { op: 'noop', result: null } + if (currentRecord.lastChargedPeriod >= periodIndex) return { op: 'noop', result: null } + + const pendingStartedAt = currentRecord.pendingPeriodStartedAt + ? new Date(currentRecord.pendingPeriodStartedAt).getTime() + : Number.NaN + const pendingExpired = + !Number.isFinite(pendingStartedAt) || now - pendingStartedAt > pendingTimeoutMs + if ( + currentRecord.pendingPeriod !== undefined && + currentRecord.pendingPeriod >= periodIndex && + !pendingExpired + ) { + return { op: 'noop', result: null } + } + + const next = { + ...currentRecord, + pendingPeriod: periodIndex, + pendingPeriodStartedAt: new Date(now).toISOString(), + } satisfies SubscriptionRecord + return { op: 'set', result: next, value: next } + }) + }, + + async clearPendingCapture(subscriptionId, periodIndex) { + await store.update(recordKey(subscriptionId), (current) => { + const currentRecord = current as SubscriptionRecord | null + if (!currentRecord || currentRecord.pendingPeriod !== periodIndex) { + return { op: 'noop', result: undefined } + } + + return { + op: 'set', + result: undefined, + value: { + ...currentRecord, + pendingPeriod: undefined, + pendingPeriodStartedAt: undefined, + } satisfies SubscriptionRecord, + } + }) + }, + + async completePendingCapture(record, periodIndex) { + await store.update(recordKey(record.subscriptionId), (current) => { + const currentRecord = current as SubscriptionRecord | null + const merged = { + ...(currentRecord ?? record), + ...record, + pendingPeriod: undefined, + pendingPeriodStartedAt: undefined, + } satisfies SubscriptionRecord + + if ( + currentRecord && + currentRecord.pendingPeriod !== undefined && + currentRecord.pendingPeriod !== periodIndex + ) { + return { op: 'noop', result: undefined } + } + + return { op: 'set', result: undefined, value: merged } + }) + }, + + async get(subscriptionId) { + return (await store.get(recordKey(subscriptionId))) as SubscriptionRecord | null + }, + + /** Looks up the single subscription for an identity+resource pair. */ + async getActive(identityId, resourceId) { + const id = (await store.get(resourceKey(identityId, resourceId))) as string | null + if (!id) return null + return (await store.get(recordKey(id))) as SubscriptionRecord | null + }, + + async markCanceled(subscriptionId, cancelEffectiveAt) { + return store.update(recordKey(subscriptionId), (current) => { + const currentRecord = current as SubscriptionRecord | null + if (!currentRecord) return { op: 'noop', result: null } + const next = { ...currentRecord, cancelEffectiveAt } satisfies SubscriptionRecord + return { op: 'set', result: next, value: next } + }) + }, + + async markRevoked(subscriptionId, revokedAt) { + return store.update(recordKey(subscriptionId), (current) => { + const currentRecord = current as SubscriptionRecord | null + if (!currentRecord) return { op: 'noop', result: null } + const next = { + ...currentRecord, + pendingPeriod: undefined, + pendingPeriodStartedAt: undefined, + revokedAt, + } satisfies SubscriptionRecord + return { op: 'set', result: next, value: next } + }) + }, + + async save(record) { + await store.put(recordKey(record.subscriptionId), record) + }, + } +} + +export declare namespace fromStore { + type Options = { + /** Key prefix for subscription records. @default `'tempo:subscription:record:'` */ + recordPrefix?: string | undefined + /** Timeout after which an in-flight capture claim can be stolen. @default `300000` */ + pendingTimeoutMs?: number | undefined + /** Key prefix for identity→resource indexes. @default `'tempo:subscription:resource:'` */ + resourcePrefix?: string | undefined + } +} diff --git a/src/tempo/subscription/Types.ts b/src/tempo/subscription/Types.ts new file mode 100644 index 00000000..4ef14195 --- /dev/null +++ b/src/tempo/subscription/Types.ts @@ -0,0 +1,54 @@ +import type { Address } from 'viem' + +export type SubscriptionIdentity = { + id: string +} + +export type SubscriptionResource = { + id: string +} + +export type SubscriptionResolution = { + identity: SubscriptionIdentity + resource: SubscriptionResource +} + +export type SubscriptionAccessKey = { + accessKeyAddress: Address + keyType: 'p256' | 'secp256k1' | 'webAuthn' +} + +export type SubscriptionRecord = { + amount: string + billingAnchor: string + chainId?: number | undefined + currency: Address | string + externalId?: string | undefined + identityId: string + lastChargedPeriod: number + periodSeconds: string + recipient: Address | string + reference: string + resourceId: string + subscriptionExpires: string + subscriptionId: string + timestamp: string + cancelEffectiveAt?: string | undefined + pendingPeriod?: number | undefined + pendingPeriodStartedAt?: string | undefined + revokedAt?: string | undefined +} + +export type SubscriptionCredentialPayload = { + signature: `0x${string}` + type: 'keyAuthorization' +} + +export type SubscriptionReceipt = { + method: 'tempo' + reference: string + status: 'success' + subscriptionId: string + timestamp: string + externalId?: string | undefined +} diff --git a/src/tempo/subscription/index.ts b/src/tempo/subscription/index.ts new file mode 100644 index 00000000..e5d635a4 --- /dev/null +++ b/src/tempo/subscription/index.ts @@ -0,0 +1,11 @@ +export * as Identity from './Identity.js' +export { createSubscriptionReceipt, fromRecord } from './Receipt.js' +export { fromStore } from './Store.js' +export type { + SubscriptionCredentialPayload, + SubscriptionIdentity, + SubscriptionRecord, + SubscriptionReceipt, + SubscriptionResolution, + SubscriptionResource, +} from './Types.js' diff --git a/test/html/server.ts b/test/html/server.ts index 0500fb9b..d495104f 100644 --- a/test/html/server.ts +++ b/test/html/server.ts @@ -4,10 +4,22 @@ import { Mppx, Request as ServerRequest, stripe, tempo } from 'mppx/server' import { createClient, http as createHttpTransport } from 'viem' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { tempoModerato } from 'viem/chains' -import { Actions } from 'viem/tempo' +import { Account, Actions } from 'viem/tempo' import { stripePreviewVersion } from '../../src/stripe/internal/constants.js' +function unwrapHttpResult( + result: + | { challenge: Response; status: 402 } + | { response: Response; status: 'pending' } + | { status: 200; withReceipt: (response?: Response) => Response }, + response?: Response, +) { + if (result.status === 402) return result.challenge + if (result.status === 'pending') return result.response + return result.withReceipt(response) +} + export async function startServer(port: number): Promise { const stripePublishableKey = process.env.VITE_STRIPE_PUBLIC_KEY const stripeSecretKey = process.env.VITE_STRIPE_SECRET_KEY @@ -28,6 +40,8 @@ export async function startServer(port: number): Promise { } const createTokenUrl = '/stripe/create-spt' + const subscriptionAccessKey = Account.fromP256(generatePrivateKey()) + const subscriptionExpires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1_000).toISOString() const tempoMppx = Mppx.create({ methods: [ tempo.charge({ @@ -38,6 +52,46 @@ export async function startServer(port: number): Promise { recipient: account.address, testnet: true, }), + tempo.subscription({ + activate: async ({ request }) => ({ + receipt: { + method: 'tempo', + reference: '0xsubscription', + status: 'success', + subscriptionId: 'sub_pro', + timestamp: new Date().toISOString(), + }, + subscription: { + amount: request.amount, + billingAnchor: new Date().toISOString(), + chainId: request.methodDetails?.chainId, + currency: request.currency, + identityId: 'user-1', + lastChargedPeriod: 0, + periodSeconds: request.periodSeconds, + recipient: request.recipient, + reference: '0xsubscription', + resourceId: 'plan:pro', + subscriptionExpires: request.subscriptionExpires, + subscriptionId: 'sub_pro', + timestamp: new Date().toISOString(), + }, + }), + amount: '1', + chainId: tempoModerato.id, + currency: '0x20c0000000000000000000000000000000000000', + html: { + accessKey: { + accessKeyAddress: subscriptionAccessKey.address, + keyType: subscriptionAccessKey.keyType, + }, + }, + periodSeconds: '2592000', + recipient: account.address, + resolve: async () => ({ identity: { id: 'user-1' }, resource: { id: 'plan:pro' } }), + subscriptionExpires, + testnet: true, + }), ], secretKey: 'test-html-server-secret-key', }) @@ -88,10 +142,7 @@ export async function startServer(port: number): Promise { amount: '0.01', description: 'Random stock photo', })(request) - - if (result.status === 402) return result.challenge - - return result.withReceipt(Response.json({ url: 'https://example.com/photo.jpg' })) + return unwrapHttpResult(result, Response.json({ url: 'https://example.com/photo.jpg' })) } if (url.pathname === '/tempo/charge-custom-text') { @@ -99,10 +150,16 @@ export async function startServer(port: number): Promise { amount: '0.01', description: 'Random stock photo', })(request) + return unwrapHttpResult(result, Response.json({ url: 'https://example.com/photo.jpg' })) + } - if (result.status === 402) return result.challenge - - return result.withReceipt(Response.json({ url: 'https://example.com/photo.jpg' })) + if (url.pathname === '/tempo/subscription') { + const result = await tempoMppx.tempo.subscription({ + description: 'Tempo Pro', + externalId: 'plan_pro', + recipient: account.address, + })(request) + return unwrapHttpResult(result, Response.json({ plan: 'pro' })) } if (url.pathname === '/stripe/charge') { @@ -114,8 +171,6 @@ export async function startServer(port: number): Promise { decimals: 2, })(request) - if (result.status === 402) return result.challenge - const fortunes = [ 'A beautiful, smart, and loving person will come into your life.', 'A dubious friend may be an enemy in camouflage.', @@ -130,7 +185,7 @@ export async function startServer(port: number): Promise { ] as const const fortune = fortunes[Math.floor(Math.random() * fortunes.length)] - return result.withReceipt(Response.json({ fortune })) + return unwrapHttpResult(result, Response.json({ fortune })) } if (url.pathname === createTokenUrl) { @@ -145,10 +200,7 @@ export async function startServer(port: number): Promise { ['tempo/charge', { amount: '0.01', description: 'Composed payment' }], ['stripe/charge', { amount: '1', currency: 'usd', decimals: 2 }], )(request) - - if (result.status === 402) return result.challenge - - return result.withReceipt(Response.json({ ok: true })) + return unwrapHttpResult(result, Response.json({ ok: true })) } if (url.pathname === '/compose-duplicates') { @@ -159,10 +211,7 @@ export async function startServer(port: number): Promise { ['stripe/charge', { amount: '1', currency: 'usd', decimals: 2 }], ['stripe/charge', { amount: '2', currency: 'usd', decimals: 2 }], )(request) - - if (result.status === 402) return result.challenge - - return result.withReceipt(Response.json({ ok: true })) + return unwrapHttpResult(result, Response.json({ ok: true })) } return new Response('Not Found', { status: 404 }) diff --git a/test/html/tempo.test.ts b/test/html/tempo.test.ts index d840f8d1..cec505cd 100644 --- a/test/html/tempo.test.ts +++ b/test/html/tempo.test.ts @@ -25,6 +25,25 @@ test('charge via tempo html payment page respects custom pay text', async ({ pag await expect(page.getByRole('button', { name: /buy now tempo/i })).toBeVisible() }) +test('subscription via tempo html payment page', async ({ page }) => { + await page.goto('/tempo/subscription', { + waitUntil: 'domcontentloaded', + }) + + await expect(page.getByText('Payment Required')).toBeVisible() + await expect(page.getByRole('button', { name: /authorize with tempo/i })).toBeVisible() + + const paidResponsePromise = page.waitForResponse( + (response) => response.url().includes('/tempo/subscription') && response.status() === 200, + ) + + await page.getByRole('button', { name: /authorize with tempo/i }).click() + + const paidResponse = await paidResponsePromise + expect(paidResponse.headers()['payment-receipt']).toBeTruthy() + await expect(page.locator('body')).toContainText('"plan":"pro"', { timeout: 30_000 }) +}) + test('service worker endpoint returns javascript', async ({ page }) => { const response = await page.goto('/tempo/charge?__mppx_worker') expect(response?.headers()['content-type']).toContain('application/javascript')