From e776f4ebe2710c4809648545a7c484589d35eebc Mon Sep 17 00:00:00 2001 From: Brendan Ryan Date: Mon, 20 Apr 2026 15:39:18 -0700 Subject: [PATCH 1/3] feat: add authorize hook and tempo subscriptions --- AGENTS.md | 4 + src/Method.ts | 43 +++- src/Receipt.ts | 2 + src/client/Methods.ts | 1 + src/server/Mppx.authorize.test.ts | 160 ++++++++++++ src/server/Mppx.ts | 247 ++++++++++-------- src/tempo/Methods.test.ts | 44 ++++ src/tempo/Methods.ts | 43 ++++ src/tempo/client/Methods.ts | 3 + src/tempo/client/Subscription.ts | 127 +++++++++ src/tempo/client/index.ts | 1 + src/tempo/index.ts | 1 + src/tempo/server/Methods.ts | 3 + src/tempo/server/Subscription.test.ts | 232 +++++++++++++++++ src/tempo/server/Subscription.ts | 339 +++++++++++++++++++++++++ src/tempo/server/index.ts | 1 + src/tempo/server/internal/html/main.ts | 42 ++- src/tempo/subscription/Identity.ts | 71 ++++++ src/tempo/subscription/Receipt.ts | 25 ++ src/tempo/subscription/Store.ts | 63 +++++ src/tempo/subscription/Types.ts | 47 ++++ src/tempo/subscription/index.ts | 10 + test/html/server.ts | 56 +++- test/html/tempo.test.ts | 19 ++ 24 files changed, 1470 insertions(+), 114 deletions(-) create mode 100644 src/server/Mppx.authorize.test.ts create mode 100644 src/tempo/client/Subscription.ts create mode 100644 src/tempo/server/Subscription.test.ts create mode 100644 src/tempo/server/Subscription.ts create mode 100644 src/tempo/subscription/Identity.ts create mode 100644 src/tempo/subscription/Receipt.ts create mode 100644 src/tempo/subscription/Store.ts create mode 100644 src/tempo/subscription/Types.ts create mode 100644 src/tempo/subscription/index.ts diff --git a/AGENTS.md b/AGENTS.md index d2d14018..3cb09222 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -164,6 +164,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/src/Method.ts b/src/Method.ts index f6bd056c..c60f61c9 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -128,10 +128,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 } @@ -153,6 +155,41 @@ 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 +} + +/** + * 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, @@ -249,13 +286,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 @@ -267,10 +306,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/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 693f0bdb..69f90686 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -216,12 +216,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, }) @@ -349,7 +351,17 @@ function createMethodFn< ): createMethodFn.ReturnType // biome-ignore lint/correctness/noUnusedVariables: _ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.ReturnType { - const { defaults, method, realm, respond, secretKey, transport, verify } = parameters + const { + authorize, + defaults, + method, + realm, + respond, + secretKey, + stableBinding, + transport, + verify, + } = parameters return (options) => { const { description, meta, ...rest } = options @@ -407,8 +419,77 @@ 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 + }, + } + } + // 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) { + 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, @@ -465,7 +546,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, @@ -535,30 +620,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: { @@ -568,6 +635,10 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R name: method.name, intent: method.intent, _canonicalRequest: PaymentRequest.fromMethod(method, merged), + _stableBinding: getStableBinding( + PaymentRequest.fromMethod(method, merged), + stableBinding as never, + ), }, }, ) @@ -634,12 +705,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 } @@ -714,89 +787,55 @@ function captureRequestFromInput(input: unknown): Method.CapturedRequest { } } -const coreBindingFields = ['amount', 'currency', 'recipient'] as const -const methodBindingFields = ['chainId', 'memo', 'splits'] 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 StableBinding = Record -/** - * 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( +function getChallengeBindingMismatch( expectedChallenge: Challenge.Challenge, actualChallenge: Challenge.Challenge, -): PinnedChallengeField | undefined { + 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 getPinnedRequestBindingMismatch( - expectedChallenge.request as Record, - actualChallenge.request as Record, + return getRequestBindingMismatch( + getStableBinding(expectedChallenge.request as Record, stableBinding), + getStableBinding(actualChallenge.request as Record, stableBinding), ) } -function getPinnedRequestBindingMismatch( - expectedRequest: Record, - actualRequest: Record, -): PinnedRequestBindingField | undefined { - const expected = getPinnedRequestBinding(expectedRequest) - const actual = getPinnedRequestBinding(actualRequest) - - return ( - getCoreBindingMismatch(expected.coreBinding, actual.coreBinding) ?? - getMethodBindingMismatch(expected.methodBinding, actual.methodBinding) +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])), ) } -function getCoreBindingMismatch( - expected: CoreBinding, - actual: CoreBinding, -): CoreBindingField | undefined { - return coreBindingFields.find((field) => !isDeepStrictEqual(expected[field], actual[field])) -} - -function getMethodBindingMismatch( - expected: MethodBinding, - actual: MethodBinding, -): MethodBindingField | undefined { - return methodBindingFields.find((field) => !isDeepStrictEqual(expected[field], actual[field])) -} +function getStableBinding( + request: Record, + stableBinding?: Method.StableBindingFn | undefined, +): StableBinding { + if (stableBinding) return stableBinding(request as never) -function getPinnedRequestBinding(request: Record): PinnedRequestBinding { const methodDetails = (request.methodDetails ?? {}) as Record - const amount = normalizeScalar(request.amount ?? methodDetails.amount) - const chainId = normalizeScalar(request.chainId ?? methodDetails.chainId) - const currency = normalizeScalar(request.currency ?? methodDetails.currency) - const memo = normalizeHex(methodDetails.memo) - const recipient = normalizeScalar(request.recipient ?? methodDetails.recipient) - const splits = normalizeComparable(methodDetails.splits) return { - coreBinding: { - ...(amount !== undefined ? { amount } : {}), - ...(currency !== undefined ? { currency } : {}), - ...(recipient !== undefined ? { recipient } : {}), - }, - methodBinding: { - ...(chainId !== undefined ? { chainId } : {}), - ...(memo !== undefined ? { memo } : {}), - ...(splits !== undefined ? { splits } : {}), - }, + 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), } } @@ -833,19 +872,6 @@ function opaqueValuesMatch( return isDeepStrictEqual(expected, actual) } -type CoreBinding = { - [field in CoreBindingField]?: string -} - -type MethodBinding = { - [field in MethodBindingField]?: unknown -} - -type PinnedRequestBinding = { - coreBinding: CoreBinding - methodBinding: MethodBinding -} - export type MethodFn< method extends Method.Method, transport extends Transport.AnyTransport, @@ -891,6 +917,7 @@ type ConfiguredHandler = ((input: Request) => Promise | undefined _canonicalRequest: Record + _stableBinding: StableBinding } } @@ -1014,10 +1041,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) ) }) @@ -1036,8 +1061,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: { 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..26650962 100644 --- a/src/tempo/Methods.ts +++ b/src/tempo/Methods.ts @@ -199,3 +199,46 @@ 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(), + subscriptionId: z.optional(z.string()), + }), + 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..9b7c2efa --- /dev/null +++ b/src/tempo/client/Subscription.ts @@ -0,0 +1,127 @@ +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 + allowMemo?: boolean | 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/Subscription.test.ts b/src/tempo/server/Subscription.test.ts new file mode 100644 index 00000000..0557cca3 --- /dev/null +++ b/src/tempo/server/Subscription.test.ts @@ -0,0 +1,232 @@ +import { Challenge, Credential, Receipt } from 'mppx' +import { Mppx } from 'mppx/server' +import { describe, expect, test } from 'vp/test' + +import * as Store from '../../Store.js' +import type { SubscriptionRecord } from '../subscription/Types.js' +import { subscription } from './Subscription.js' + +const realm = 'api.example.com' +const secretKey = 'test-secret-key' +const activeBillingAnchor = new Date().toISOString() +const activeSubscriptionExpires = new Date(Date.now() + 365 * 24 * 60 * 60 * 1_000).toISOString() + +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, + } +} + +describe('tempo.subscription', () => { + test('stores an activated subscription and reuses it on later requests', async () => { + const store = Store.memory() + const method = subscription({ + activate: async ({ request, source }) => ({ + receipt: createReceipt('sub_123', '0xactivate'), + subscription: createRecord({ + amount: request.amount, + chainId: request.methodDetails?.chainId, + currency: request.currency, + identityId: source?.address ?? 'anon', + periodSeconds: request.periodSeconds, + recipient: request.recipient, + reference: '0xactivate', + subscriptionExpires: request.subscriptionExpires, + }), + }), + amount: '10', + chainId: 4217, + currency: '0x20c0000000000000000000000000000000000001', + getIdentity: async ({ input }) => ({ id: input.headers.get('X-User') ?? 'anon' }), + getResource: async () => ({ id: 'resource:alpha' }), + periodSeconds: '3600', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + store, + subscriptionExpires: activeSubscriptionExpires, + }) + + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const challengeResult = await mppx['tempo/subscription']({})( + new Request('https://example.com/resource', { headers: { 'X-User': 'user-1' } }), + ) + + expect(challengeResult.status).toBe(402) + if (challengeResult.status !== 402) throw new Error('expected activation challenge') + + const challenge = Challenge.fromResponse(challengeResult.challenge) + const credential = Credential.from({ + challenge, + payload: { signature: '0x1234', type: 'keyAuthorization' }, + source: 'did:pkh:eip155:4217:0x1234567890abcdef1234567890abcdef12345678', + }) + + const activated = await mppx['tempo/subscription']({})( + new Request('https://example.com/resource', { + headers: { + Authorization: Credential.serialize(credential), + 'X-User': '0x1234567890abcdef1234567890abcdef12345678', + }, + }), + ) + + expect(activated.status).toBe(200) + + const reused = await mppx['tempo/subscription']({})( + new Request('https://example.com/resource', { + headers: { + 'X-User': '0x1234567890abcdef1234567890abcdef12345678', + }, + }), + ) + + expect(reused.status).toBe(200) + if (reused.status !== 200) throw new Error('expected authorize reuse') + + const response = reused.withReceipt(new Response('OK')) + const receipt = response.headers.get('Payment-Receipt') + expect(receipt).toBeTruthy() + }) + + test('new activation replaces previous subscription for same resource', async () => { + const store = Store.memory() + + // Seed an expired subscription so authorize() falls through to a new challenge. + const expiredDate = new Date(Date.now() - 1_000).toISOString() + await store.put('tempo:subscription:record:sub_old', createRecord({ + subscriptionId: 'sub_old', + reference: '0xold', + subscriptionExpires: expiredDate, + })) + await store.put('tempo:subscription:resource:user-1:resource:alpha', 'sub_old') + + const method = subscription({ + activate: async ({ request, source }) => ({ + receipt: createReceipt('sub_new', '0xnew'), + subscription: createRecord({ + amount: request.amount, + chainId: request.methodDetails?.chainId, + currency: request.currency, + identityId: source?.address ?? 'anon', + periodSeconds: request.periodSeconds, + recipient: request.recipient, + reference: '0xnew', + subscriptionExpires: request.subscriptionExpires, + subscriptionId: 'sub_new', + }), + }), + amount: '10', + chainId: 4217, + currency: '0x20c0000000000000000000000000000000000001', + getIdentity: async () => ({ id: 'user-1' }), + getResource: async () => ({ id: 'resource:alpha' }), + periodSeconds: '3600', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + store, + subscriptionExpires: activeSubscriptionExpires, + }) + + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + + const challengeResult = await mppx['tempo/subscription']({})( + new Request('https://example.com/resource'), + ) + expect(challengeResult.status).toBe(402) + if (challengeResult.status !== 402) throw new Error('expected challenge') + + const challenge = Challenge.fromResponse(challengeResult.challenge) + const credential = Credential.from({ + challenge, + payload: { signature: '0x1234', type: 'keyAuthorization' }, + source: 'did:pkh:eip155:4217:0x1234567890abcdef1234567890abcdef12345678', + }) + + const activated = await mppx['tempo/subscription']({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + expect(activated.status).toBe(200) + if (activated.status !== 200) throw new Error('expected activation') + + const receipt = Receipt.fromResponse(activated.withReceipt(new Response('OK'))) + expect(receipt.subscriptionId).toBe('sub_new') + }) + + test('renews an overdue matching subscription before falling back to 402', async () => { + const store = Store.memory() + const renewCalls: number[] = [] + const method = subscription({ + activate: async () => ({ + receipt: createReceipt('unused'), + subscription: createRecord({ subscriptionId: 'unused' }), + }), + amount: '10', + chainId: 4217, + currency: '0x20c0000000000000000000000000000000000001', + getIdentity: async () => ({ id: 'user-1' }), + getResource: async () => ({ id: 'resource:alpha' }), + periodSeconds: '3600', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + renew: async ({ periodIndex, subscription }) => { + renewCalls.push(periodIndex) + return { + receipt: createReceipt(subscription.subscriptionId, '0xrenewed'), + subscription: { + ...subscription, + lastChargedPeriod: periodIndex, + reference: '0xrenewed', + }, + } + }, + store, + subscriptionExpires: activeSubscriptionExpires, + }) + + await store.put( + 'tempo:subscription:record:sub_due', + createRecord({ + billingAnchor: new Date(Date.now() - 3 * 3_600_000).toISOString(), + lastChargedPeriod: 0, + reference: '0xstale', + subscriptionId: 'sub_due', + }), + ) + await store.put('tempo:subscription:resource:user-1:resource:alpha', 'sub_due') + + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const result = await mppx['tempo/subscription']({})(new Request('https://example.com/resource')) + + expect(result.status).toBe(200) + expect(renewCalls.length).toBe(1) + expect(renewCalls[0]).toBeGreaterThan(0) + if (result.status !== 200) throw new Error('expected renewal success') + + const receipt = Receipt.fromResponse(result.withReceipt(new Response('OK'))) + expect(receipt.reference).toBe('0xrenewed') + expect(receipt.subscriptionId).toBe('sub_due') + }) + +}) diff --git a/src/tempo/server/Subscription.ts b/src/tempo/server/Subscription.ts new file mode 100644 index 00000000..2ece32d6 --- /dev/null +++ b/src/tempo/server/Subscription.ts @@ -0,0 +1,339 @@ +import { formatUnits, type Address } from 'viem' +import { Actions } from 'viem/tempo' + +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 { + SubscriptionCredentialPayload, + SubscriptionIdentity, + SubscriptionRecord, + SubscriptionReceipt as SubscriptionReceiptValue, + SubscriptionAccessKey, + SubscriptionResource, +} from '../subscription/Types.js' +import { html as htmlContent } from './internal/html.gen.js' + +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, + ...(html.allowMemo !== undefined ? { allowMemo: html.allowMemo } : {}), + }, + 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 identity = await parameters.getIdentity({ input, request }) + if (!identity) return undefined + + const resource = await parameters.getResource({ identity, input, request }) + const subscription = await store.getByIdentityResource(identity.id, resource.id) + if (!subscription || !isActive(subscription)) return undefined + + const periodIndex = getPeriodIndex(subscription) + if (periodIndex > subscription.lastChargedPeriod) { + if (!parameters.renew) return undefined + const renewed = await parameters.renew({ + identity, + input, + periodIndex, + request, + resource, + subscription, + }) + await store.put(renewed.subscription) + return { + receipt: renewed.receipt, + response: renewed.response, + } + } + + return { + receipt: SubscriptionReceipt.fromRecord(subscription), + } + }, + + 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 source = credential.source ? Proof.parseProofSource(credential.source) : null + const input = envelope + ? new Request(envelope.capturedRequest.url, { + headers: envelope.capturedRequest.headers, + method: envelope.capturedRequest.method, + }) + : new Request('https://subscription.invalid') + const activation = await parameters.activate({ + credential: credential as typeof credential & { + payload: SubscriptionCredentialPayload + }, + input, + request: Methods.subscription.schema.request.parse(request), + source, + }) + await store.put(activation.subscription) + return activation.receipt + }, + }) +} + +function getPeriodIndex(subscription: SubscriptionRecord): number { + const anchor = new Date(subscription.billingAnchor).getTime() + const expires = new Date(subscription.subscriptionExpires).getTime() + const now = Date.now() + 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): boolean { + if (subscription.canceledAt || subscription.revokedAt) return false + return new Date(subscription.subscriptionExpires).getTime() > Date.now() +} + +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` + } +} + +/** + * Charges an overdue subscription outside of the HTTP request path. + * Intended for cron jobs or background workers that bill subscriptions on a schedule. + * + * Returns the renewal result if the subscription was overdue, or `null` if already current. + */ +export async function charge(parameters: charge.Parameters): Promise { + const { renew, store: rawStore = Store.memory() } = parameters + const store = SubscriptionStore.fromStore(rawStore) + + const record = await store.get(parameters.subscriptionId) + if (!record) return null + if (!isActive(record)) return null + + const periodIndex = getPeriodIndex(record) + if (periodIndex <= record.lastChargedPeriod) return null + + const renewed = await renew({ periodIndex, subscription: record }) + await store.put(renewed.subscription) + return renewed +} + +export declare namespace charge { + type Parameters = { + /** The subscription to charge. */ + subscriptionId: string + /** Billing callback — same signature as the `renew` hook on {@link subscription}. */ + renew: (parameters: { + periodIndex: number + subscription: SubscriptionRecord + }) => Promise + /** Store containing subscription records. */ + store?: Store.Store> | undefined + } + + type Result = subscription.RenewalResult +} + +export declare namespace subscription { + type ActivationResult = { + receipt: SubscriptionReceiptValue + response?: Response | undefined + subscription: SubscriptionRecord + } + + type RenewalResult = { + receipt: SubscriptionReceiptValue + response?: Response | undefined + subscription: SubscriptionRecord + } + + type Defaults = LooseOmit, 'recipient'> + + type Parameters = Account.resolve.Parameters & + Client.getResolver.Parameters & { + getIdentity: (parameters: { + input: Request + request: ReturnType + }) => MaybePromise + getResource: (parameters: { + identity: SubscriptionIdentity + input: Request + request: ReturnType + }) => MaybePromise + activate: (parameters: { + credential: { + payload: SubscriptionCredentialPayload + source?: string | undefined + } + input: Request + request: ReturnType + source: { address: Address; chainId: number } | null + }) => Promise + html?: + | { + accessKey: SubscriptionAccessKey + allowMemo?: boolean | undefined + text?: Html.Text | undefined + theme?: Html.Theme | undefined + } + | undefined + periodSeconds?: string | undefined + renew?: (parameters: { + identity: SubscriptionIdentity + input: Request + periodIndex: number + request: ReturnType + resource: SubscriptionResource + subscription: SubscriptionRecord + }) => Promise + store?: Store.Store> | 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..7d1537e7 100644 --- a/src/tempo/server/index.ts +++ b/src/tempo/server/index.ts @@ -4,3 +4,4 @@ export * as Ws from '../session/Ws.js' export { charge } from './Charge.js' export { tempo } from './Methods.js' export { session, settle } from './Session.js' +export { charge as chargeSubscription, subscription } from './Subscription.js' diff --git a/src/tempo/server/internal/html/main.ts b/src/tempo/server/internal/html/main.ts index 148cb623..afd4bf16 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -5,8 +5,15 @@ 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 + allowMemo?: boolean | undefined + } +>('tempo') const css = String.raw const style = document.createElement('style') @@ -73,7 +80,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 +106,31 @@ 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, + ...(c.config.allowMemo !== undefined ? { allowMemo: c.config.allowMemo } : {}), + 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/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..2a87e717 --- /dev/null +++ b/src/tempo/subscription/Store.ts @@ -0,0 +1,63 @@ +import * as Store from '../../Store.js' +import type { SubscriptionRecord } from './Types.js' + +const defaultRecordPrefix = 'tempo:subscription:record:' +const defaultResourcePrefix = 'tempo:subscription:resource:' + +/** Subscription-aware wrapper around a generic key-value store. */ +export type SubscriptionStore = { + get: (subscriptionId: string) => Promise + getByIdentityResource: (identityId: string, resourceId: string) => Promise + put: (record: SubscriptionRecord) => Promise +} + +/** Wraps a generic key-value {@link Store.Store} with subscription-specific accessors. */ +export function fromStore( + store: Store.Store>, + options?: fromStore.Options, +): SubscriptionStore { + const recordPrefix = options?.recordPrefix ?? defaultRecordPrefix + 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 get(subscriptionId) { + return (await store.get(recordKey(subscriptionId))) as SubscriptionRecord | null + }, + + /** Looks up the single subscription for an identity+resource pair. */ + async getByIdentityResource(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 + }, + + /** + * Upserts a subscription record and sets it as the active subscription + * for the identity+resource pair, replacing any previous subscription. + */ + async put(record) { + await store.put(recordKey(record.subscriptionId), record) + await store.put( + resourceKey(record.identityId, record.resourceId), + record.subscriptionId, + ) + }, + } +} + +export declare namespace fromStore { + type Options = { + /** Key prefix for subscription records. @default `'tempo:subscription:record:'` */ + recordPrefix?: string | 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..9c71a4c3 --- /dev/null +++ b/src/tempo/subscription/Types.ts @@ -0,0 +1,47 @@ +import type { Address } from 'viem' + +export type SubscriptionIdentity = { + id: string +} + +export type SubscriptionResource = { + id: string +} + +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 + canceledAt?: 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..bc05d8c4 --- /dev/null +++ b/src/tempo/subscription/index.ts @@ -0,0 +1,10 @@ +export * as Identity from './Identity.js' +export { createSubscriptionReceipt, fromRecord } from './Receipt.js' +export { fromStore } from './Store.js' +export type { + SubscriptionCredentialPayload, + SubscriptionIdentity, + SubscriptionRecord, + SubscriptionReceipt, + SubscriptionResource, +} from './Types.js' diff --git a/test/html/server.ts b/test/html/server.ts index 0500fb9b..6844afa3 100644 --- a/test/html/server.ts +++ b/test/html/server.ts @@ -4,7 +4,7 @@ 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' @@ -28,6 +28,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 +40,47 @@ 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', + getIdentity: async () => ({ id: 'user-1' }), + getResource: async () => ({ id: 'plan:pro' }), + html: { + accessKey: { + accessKeyAddress: subscriptionAccessKey.address, + keyType: subscriptionAccessKey.keyType, + }, + }, + periodSeconds: '2592000', + recipient: account.address, + subscriptionExpires, + testnet: true, + }), ], secretKey: 'test-html-server-secret-key', }) @@ -105,6 +148,17 @@ export async function startServer(port: number): Promise { 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', + })(request) + + if (result.status === 402) return result.challenge + + return result.withReceipt(Response.json({ plan: 'pro' })) + } + if (url.pathname === '/stripe/charge') { if (!stripeMppx) 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') From 0313536a01565cb2519db3781b42cc3eeb66882d Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:43:16 -0700 Subject: [PATCH 2/3] feat: refactor tempo subscription lifecycle --- examples/charge-wagmi/server.ts | 1 + examples/charge/src/server.ts | 1 + examples/session/multi-fetch/src/server.ts | 1 + examples/session/sse/src/server.ts | 1 + examples/session/ws/src/server.ts | 1 + examples/stripe/src/server.ts | 1 + src/Method.ts | 14 +- src/mcp-sdk/client/McpClient.test.ts | 1 + src/middlewares/express.ts | 8 + src/middlewares/hono.ts | 1 + src/middlewares/nextjs.ts | 1 + src/proxy/Proxy.ts | 1 + src/proxy/Service.ts | 1 + src/server/Mppx.ts | 35 +- src/tempo/Methods.ts | 1 - src/tempo/client/Subscription.ts | 1 - src/tempo/server/Session.test.ts | 896 ++++++++++++--------- src/tempo/server/Subscription.test.ts | 435 ++++++---- src/tempo/server/Subscription.ts | 401 +++++++-- src/tempo/server/index.ts | 9 +- src/tempo/server/internal/html/main.ts | 2 - src/tempo/session/Ws.ts | 17 + src/tempo/subscription/Store.ts | 157 +++- src/tempo/subscription/Types.ts | 9 +- src/tempo/subscription/index.ts | 1 + test/html/server.ts | 45 +- 26 files changed, 1373 insertions(+), 669 deletions(-) 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 c60f61c9..193b2477 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -173,13 +173,23 @@ export type AuthorizeFn = (parameters: { > input: globalThis.Request request: z.output -}) => MaybePromise +}) => 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. * @@ -193,7 +203,7 @@ export type StableBindingFn = ( /** Verification function for a single method. */ export type VerifyFn = ( parameters: VerifyContext, -) => Promise +) => Promise /** * Optional respond function for a server-side method. 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 de3deb5b..22323845 100644 --- a/src/middlewares/hono.ts +++ b/src/middlewares/hono.ts @@ -58,6 +58,7 @@ export function payment( return async (c, next) => { const result = await intent(options)(c.req.raw) 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 60f0164b..698726b1 100644 --- a/src/proxy/Proxy.ts +++ b/src/proxy/Proxy.ts @@ -131,6 +131,7 @@ export function create(config: create.Config): Proxy { const handler = typeof endpoint === 'function' ? endpoint : endpoint.pay const result = await handler(request) 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.ts b/src/server/Mppx.ts index 69f90686..e69b9a06 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -289,7 +289,13 @@ export function create< // so we use them directly — no need for the caller to re-supply. const request = credential.challenge.request as z.input - return mi.verify({ credential, request } as never) + const result = await mi.verify({ credential, 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( @@ -350,7 +356,7 @@ function createMethodFn< parameters: createMethodFn.Parameters, ): createMethodFn.ReturnType // biome-ignore lint/correctness/noUnusedVariables: _ -function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.ReturnType { +function createMethodFn(parameters: createMethodFn.Parameters): any { const { authorize, defaults, @@ -363,12 +369,12 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R verify, } = parameters - return (options) => { + return (options: any) => { const { description, meta, ...rest } = options 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) @@ -461,6 +467,12 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R } } + const pending = (response: Transport.ChallengeOutputOf) => + ({ + response, + status: 'pending', + }) as MethodFn.Response + // No credential provided—issue challenge if (!credential) { if (authorize && input instanceof globalThis.Request) { @@ -471,6 +483,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R request: challenge.request, } as never) if (authorized) { + if (isPendingResult(authorized)) return pending(authorized.response as never) return success(authorized.receipt, { managementResponse: authorized.response, }) @@ -598,7 +611,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) @@ -872,6 +887,10 @@ function opaqueValuesMatch( return isDeepStrictEqual(expected, actual) } +function isPendingResult(value: unknown): value is Method.PendingResult { + return !!value && typeof value === 'object' && 'response' in value && !('receipt' in value) +} + export type MethodFn< method extends Method.Method, transport extends Transport.AnyTransport, @@ -903,6 +922,10 @@ declare namespace MethodFn { challenge: Transport.ChallengeOutputOf status: 402 } + | { + response: Transport.ChallengeOutputOf + status: 'pending' + } | { status: 200 withReceipt: Transport.WithReceipt @@ -1218,6 +1241,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.ts b/src/tempo/Methods.ts index 26650962..a8ed892b 100644 --- a/src/tempo/Methods.ts +++ b/src/tempo/Methods.ts @@ -226,7 +226,6 @@ export const subscription = Method.from({ periodSeconds: z.string().check(z.regex(/^[1-9]\d*$/, 'Invalid periodSeconds')), recipient: z.string(), subscriptionExpires: z.datetime(), - subscriptionId: z.optional(z.string()), }), z.transform(({ amount, chainId, decimals, ...rest }) => ({ ...rest, diff --git a/src/tempo/client/Subscription.ts b/src/tempo/client/Subscription.ts index 9b7c2efa..ba93650b 100644 --- a/src/tempo/client/Subscription.ts +++ b/src/tempo/client/Subscription.ts @@ -121,7 +121,6 @@ export declare namespace subscription { type Parameters = Account.getResolver.Parameters & Client.getResolver.Parameters & { accessKey?: SubscriptionAccessKey | undefined - allowMemo?: boolean | undefined expectedRecipients?: readonly Address[] | undefined } } diff --git a/src/tempo/server/Session.test.ts b/src/tempo/server/Session.test.ts index 7e05d405..b567c3fa 100644 --- a/src/tempo/server/Session.test.ts +++ b/src/tempo/server/Session.test.ts @@ -64,6 +64,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() @@ -130,20 +142,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') @@ -279,20 +293,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) @@ -318,20 +334,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) @@ -377,20 +395,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( @@ -445,20 +465,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') }) @@ -496,20 +518,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') @@ -551,20 +575,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') @@ -599,18 +625,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') @@ -797,13 +825,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') @@ -1115,19 +1145,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') @@ -1189,19 +1221,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') @@ -1262,20 +1296,22 @@ 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(), - }) - - expect(receipt.status).toBe('success') + request: makeRequest(), + }), + ) + + expect(receipt.status).toBe('success') const ch = await store.getChannel(channelId) expect(ch).not.toBeNull() @@ -1287,18 +1323,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') @@ -1326,18 +1364,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') @@ -1407,18 +1447,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') @@ -1461,18 +1503,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') }) @@ -1502,18 +1546,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/) @@ -1568,49 +1614,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) @@ -1625,51 +1677,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') }) @@ -1677,20 +1735,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) @@ -1705,34 +1765,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) @@ -1747,50 +1811,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) @@ -1929,20 +1999,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') @@ -2216,18 +2288,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) @@ -2416,13 +2490,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) @@ -2569,18 +2645,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') @@ -2771,15 +2849,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) @@ -2793,15 +2873,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) @@ -2927,6 +3009,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')) } @@ -3007,6 +3090,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')) } @@ -3076,6 +3160,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')) } @@ -3150,6 +3235,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')) } @@ -3589,6 +3675,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 })) } @@ -3631,6 +3718,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 })) } @@ -3677,6 +3765,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, @@ -3725,6 +3814,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')) } @@ -3853,6 +3943,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 }) @@ -3931,6 +4022,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 }) @@ -4016,6 +4108,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 }) @@ -4079,6 +4172,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) { @@ -4160,6 +4254,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')) }) @@ -4273,6 +4368,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')) }) @@ -4377,6 +4473,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')) }) @@ -4485,6 +4582,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')) }) @@ -4574,6 +4672,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')) }) @@ -4665,6 +4764,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')) }) @@ -4776,6 +4876,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')) }) @@ -4876,6 +4977,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 index 0557cca3..04d71530 100644 --- a/src/tempo/server/Subscription.test.ts +++ b/src/tempo/server/Subscription.test.ts @@ -1,15 +1,33 @@ -import { Challenge, Credential, Receipt } from 'mppx' +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 { subscription } from './Subscription.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().toISOString() +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 { @@ -40,193 +58,322 @@ function createRecord(overrides: Partial = {}): Subscription } } +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('stores an activated subscription and reuses it on later requests', async () => { + test('concurrent request-time renewals only capture once for the same period', async () => { const store = Store.memory() - const method = subscription({ - activate: async ({ request, source }) => ({ - receipt: createReceipt('sub_123', '0xactivate'), - subscription: createRecord({ - amount: request.amount, - chainId: request.methodDetails?.chainId, - currency: request.currency, - identityId: source?.address ?? 'anon', - periodSeconds: request.periodSeconds, - recipient: request.recipient, - reference: '0xactivate', - subscriptionExpires: request.subscriptionExpires, - }), + 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', }), - amount: '10', - chainId: 4217, - currency: '0x20c0000000000000000000000000000000000001', - getIdentity: async ({ input }) => ({ id: input.headers.get('X-User') ?? 'anon' }), - getResource: async () => ({ id: 'resource:alpha' }), - periodSeconds: '3600', - recipient: '0x1234567890abcdef1234567890abcdef12345678', + ) + + 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, - subscriptionExpires: activeSubscriptionExpires, }) - const mppx = Mppx.create({ methods: [method], realm, secretKey }) - const challengeResult = await mppx['tempo/subscription']({})( - new Request('https://example.com/resource', { headers: { 'X-User': 'user-1' } }), - ) + const first = handler(createRequest()) + const second = handler(createRequest()) + await started.promise + release.resolve() - expect(challengeResult.status).toBe(402) - if (challengeResult.status !== 402) throw new Error('expected activation challenge') + 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 challenge = Challenge.fromResponse(challengeResult.challenge) - const credential = Credential.from({ - challenge, - payload: { signature: '0x1234', type: 'keyAuthorization' }, - source: 'did:pkh:eip155:4217:0x1234567890abcdef1234567890abcdef12345678', - }) + const followUp = await handler(createRequest()) + expect(followUp.status).toBe(200) + if (followUp.status !== 200) throw new Error('expected renewed access') - const activated = await mppx['tempo/subscription']({})( - new Request('https://example.com/resource', { - headers: { - Authorization: Credential.serialize(credential), - 'X-User': '0x1234567890abcdef1234567890abcdef12345678', - }, + 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', }), ) - expect(activated.status).toBe(200) - - const reused = await mppx['tempo/subscription']({})( - new Request('https://example.com/resource', { - headers: { - 'X-User': '0x1234567890abcdef1234567890abcdef12345678', + 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, }), ) - expect(reused.status).toBe(200) - if (reused.status !== 200) throw new Error('expected authorize reuse') + const active = await subscriptionStore.getActive(identity.id, resource.id) + expect(active?.subscriptionId).toBe('sub_new') + expect(active?.pendingPeriod).toBeUndefined() - const response = reused.withReceipt(new Response('OK')) - const receipt = response.headers.get('Payment-Receipt') - expect(receipt).toBeTruthy() + const replaced = await subscriptionStore.get('sub_old') + expect(replaced?.cancelEffectiveAt).toBe(replacementTimestamp) + expect(replaced?.pendingPeriod).toBeUndefined() + expect(replaced?.pendingPeriodStartedAt).toBeUndefined() }) - test('new activation replaces previous subscription for same resource', async () => { + 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', + }), + ) - // Seed an expired subscription so authorize() falls through to a new challenge. - const expiredDate = new Date(Date.now() - 1_000).toISOString() - await store.put('tempo:subscription:record:sub_old', createRecord({ - subscriptionId: 'sub_old', - reference: '0xold', - subscriptionExpires: expiredDate, - })) - await store.put('tempo:subscription:resource:user-1:resource:alpha', 'sub_old') - - const method = subscription({ - activate: async ({ request, source }) => ({ - receipt: createReceipt('sub_new', '0xnew'), - subscription: createRecord({ - amount: request.amount, - chainId: request.methodDetails?.chainId, - currency: request.currency, - identityId: source?.address ?? 'anon', - periodSeconds: request.periodSeconds, - recipient: request.recipient, - reference: '0xnew', - subscriptionExpires: request.subscriptionExpires, - subscriptionId: 'sub_new', + const handler = createHandler({ + capture: async () => ({ + response: new Response('capture pending', { + headers: { Location: '/subscriptions/sub_due/capture' }, + status: 202, }), }), - amount: '10', - chainId: 4217, - currency: '0x20c0000000000000000000000000000000000001', - getIdentity: async () => ({ id: 'user-1' }), - getResource: async () => ({ id: 'resource:alpha' }), - periodSeconds: '3600', - recipient: '0x1234567890abcdef1234567890abcdef12345678', store, - subscriptionExpires: activeSubscriptionExpires, }) - const mppx = Mppx.create({ methods: [method], realm, secretKey }) + 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 challengeResult = await mppx['tempo/subscription']({})( - new Request('https://example.com/resource'), - ) - expect(challengeResult.status).toBe(402) - if (challengeResult.status !== 402) throw new Error('expected challenge') - - const challenge = Challenge.fromResponse(challengeResult.challenge) - const credential = Credential.from({ - challenge, - payload: { signature: '0x1234', type: 'keyAuthorization' }, - source: 'did:pkh:eip155:4217:0x1234567890abcdef1234567890abcdef12345678', + 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 activated = await mppx['tempo/subscription']({})( - new Request('https://example.com/resource', { - headers: { Authorization: Credential.serialize(credential) }, - }), - ) - expect(activated.status).toBe(200) - if (activated.status !== 200) throw new Error('expected activation') + 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(activated.withReceipt(new Response('OK'))) - expect(receipt.subscriptionId).toBe('sub_new') + const receipt = Receipt.fromResponse(followUp.withReceipt(new Response('OK'))) + expect(receipt.reference).toBe('0xcompleted') }) - test('renews an overdue matching subscription before falling back to 402', async () => { + test('failCapture clears the claim so a later request can retry the period', async () => { const store = Store.memory() - const renewCalls: number[] = [] - const method = subscription({ - activate: async () => ({ - receipt: createReceipt('unused'), - subscription: createRecord({ subscriptionId: 'unused' }), + 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', }), - amount: '10', - chainId: 4217, - currency: '0x20c0000000000000000000000000000000000001', - getIdentity: async () => ({ id: 'user-1' }), - getResource: async () => ({ id: 'resource:alpha' }), - periodSeconds: '3600', - recipient: '0x1234567890abcdef1234567890abcdef12345678', - renew: async ({ periodIndex, subscription }) => { - renewCalls.push(periodIndex) + ) + + 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, '0xrenewed'), + receipt: createReceipt(subscription.subscriptionId, '0xretried'), subscription: { ...subscription, lastChargedPeriod: periodIndex, - reference: '0xrenewed', + reference: '0xretried', }, } }, store, - subscriptionExpires: activeSubscriptionExpires, }) - await store.put( - 'tempo:subscription:record:sub_due', - createRecord({ - billingAnchor: new Date(Date.now() - 3 * 3_600_000).toISOString(), - lastChargedPeriod: 0, - reference: '0xstale', - subscriptionId: 'sub_due', - }), - ) - await store.put('tempo:subscription:resource:user-1:resource:alpha', 'sub_due') + const first = await handler(createRequest()) + expect(first.status).toBe('pending') - const mppx = Mppx.create({ methods: [method], realm, secretKey }) - const result = await mppx['tempo/subscription']({})(new Request('https://example.com/resource')) + const claimed = await subscriptionStore.get('sub_due') + if (!claimed?.pendingPeriod) throw new Error('expected pending claim') - expect(result.status).toBe(200) - expect(renewCalls.length).toBe(1) - expect(renewCalls[0]).toBeGreaterThan(0) - if (result.status !== 200) throw new Error('expected renewal success') + await failCapture({ periodIndex: claimed.pendingPeriod, store, subscriptionId: 'sub_due' }) - const receipt = Receipt.fromResponse(result.withReceipt(new Response('OK'))) - expect(receipt.reference).toBe('0xrenewed') - expect(receipt.subscriptionId).toBe('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 index 2ece32d6..7e541b4d 100644 --- a/src/tempo/server/Subscription.ts +++ b/src/tempo/server/Subscription.ts @@ -1,6 +1,8 @@ +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' @@ -15,15 +17,19 @@ 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, - SubscriptionAccessKey, + 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, ) { @@ -64,7 +70,6 @@ export function subscription( ? { config: { accessKey: html.accessKey, - ...(html.allowMemo !== undefined ? { allowMemo: html.allowMemo } : {}), }, content: htmlContent, formatAmount: async (request: z.output) => { @@ -77,34 +82,14 @@ export function subscription( : undefined, async authorize({ input, request }) { - const identity = await parameters.getIdentity({ input, request }) - if (!identity) return undefined - - const resource = await parameters.getResource({ identity, input, request }) - const subscription = await store.getByIdentityResource(identity.id, resource.id) - if (!subscription || !isActive(subscription)) return undefined - - const periodIndex = getPeriodIndex(subscription) - if (periodIndex > subscription.lastChargedPeriod) { - if (!parameters.renew) return undefined - const renewed = await parameters.renew({ - identity, - input, - periodIndex, - request, - resource, - subscription, - }) - await store.put(renewed.subscription) - return { - receipt: renewed.receipt, - response: renewed.response, - } - } + const resolution = await parameters.resolve({ input, request }) + if (!resolution) return undefined - return { - receipt: SubscriptionReceipt.fromRecord(subscription), - } + return authorizeActiveSubscription({ + capture: parameters.capture, + resolution, + store, + }) }, async request({ request }) { @@ -126,31 +111,206 @@ export function subscription( }, 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: Methods.subscription.schema.request.parse(request), + request: parsedRequest, + resolution, source, }) - await store.put(activation.subscription) + + await store.activate(normalizeSubscriptionRecord(activation.subscription, resolution)) return activation.receipt }, }) } -function getPeriodIndex(subscription: SubscriptionRecord): number { +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() - const now = Date.now() if (!Number.isFinite(anchor) || !Number.isFinite(expires) || now >= expires) { return Number.POSITIVE_INFINITY } @@ -163,9 +323,19 @@ function getPeriodIndex(subscription: SubscriptionRecord): number { return Math.max(0, Math.floor((now - anchor) / (periodSeconds * 1_000))) } -function isActive(subscription: SubscriptionRecord): boolean { - if (subscription.canceledAt || subscription.revokedAt) return false - return new Date(subscription.subscriptionExpires).getTime() > Date.now() +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( @@ -237,43 +407,127 @@ function formatBillingInterval(periodSeconds: string) { } /** - * Charges an overdue subscription outside of the HTTP request path. - * Intended for cron jobs or background workers that bill subscriptions on a schedule. - * - * Returns the renewal result if the subscription was overdue, or `null` if already current. + * 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 charge(parameters: charge.Parameters): Promise { - const { renew, store: rawStore = Store.memory() } = parameters +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) +} - const record = await store.get(parameters.subscriptionId) - if (!record) return null - if (!isActive(record)) return null +/** + * 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) +} - const periodIndex = getPeriodIndex(record) - if (periodIndex <= record.lastChargedPeriod) return null +/** + * 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) +} - const renewed = await renew({ periodIndex, subscription: record }) - await store.put(renewed.subscription) - return renewed +/** + * 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 charge { +export declare namespace captureActive { type Parameters = { - /** The subscription to charge. */ - subscriptionId: string - /** Billing callback — same signature as the `renew` hook on {@link subscription}. */ - renew: (parameters: { - periodIndex: number - subscription: SubscriptionRecord - }) => Promise + /** 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.Store> | undefined + 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 @@ -287,46 +541,41 @@ export declare namespace subscription { subscription: SubscriptionRecord } + type CaptureReason = 'background' | 'request' + type Defaults = LooseOmit, 'recipient'> type Parameters = Account.resolve.Parameters & Client.getResolver.Parameters & { - getIdentity: (parameters: { - input: Request - request: ReturnType - }) => MaybePromise - getResource: (parameters: { - identity: SubscriptionIdentity - input: Request - request: ReturnType - }) => MaybePromise 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 - allowMemo?: boolean | undefined text?: Html.Text | undefined theme?: Html.Theme | undefined } | undefined periodSeconds?: string | undefined - renew?: (parameters: { - identity: SubscriptionIdentity + resolve: (parameters: { input: Request - periodIndex: number request: ReturnType - resource: SubscriptionResource - subscription: SubscriptionRecord - }) => Promise - store?: Store.Store> | undefined + }) => MaybePromise + store?: Store.AtomicStore> | undefined testnet?: boolean | undefined } & Defaults diff --git a/src/tempo/server/index.ts b/src/tempo/server/index.ts index 7d1537e7..48898b15 100644 --- a/src/tempo/server/index.ts +++ b/src/tempo/server/index.ts @@ -4,4 +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 { charge as chargeSubscription, subscription } from './Subscription.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 afd4bf16..5970c3d2 100644 --- a/src/tempo/server/internal/html/main.ts +++ b/src/tempo/server/internal/html/main.ts @@ -11,7 +11,6 @@ const c = Html.init< typeof Methods.charge | typeof Methods.subscription, { accessKey?: SubscriptionAccessKey | undefined - allowMemo?: boolean | undefined } >('tempo') @@ -121,7 +120,6 @@ button.onclick = async () => { const method = tempo.subscription({ accessKey: c.config.accessKey, account, - ...(c.config.allowMemo !== undefined ? { allowMemo: c.config.allowMemo } : {}), getClient, }) 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/Store.ts b/src/tempo/subscription/Store.ts index 2a87e717..37adef1f 100644 --- a/src/tempo/subscription/Store.ts +++ b/src/tempo/subscription/Store.ts @@ -3,20 +3,35 @@ 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 - getByIdentityResource: (identityId: string, resourceId: string) => Promise - put: (record: SubscriptionRecord) => 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 key-value {@link Store.Store} with subscription-specific accessors. */ +/** Wraps a generic atomic {@link Store.Store} with subscription-specific accessors. */ export function fromStore( - store: Store.Store>, + 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 { @@ -28,27 +43,139 @@ export function fromStore( } 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 getByIdentityResource(identityId, resourceId) { + 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 }, - /** - * Upserts a subscription record and sets it as the active subscription - * for the identity+resource pair, replacing any previous subscription. - */ - async put(record) { + 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) - await store.put( - resourceKey(record.identityId, record.resourceId), - record.subscriptionId, - ) }, } } @@ -57,6 +184,8 @@ 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 index 9c71a4c3..4ef14195 100644 --- a/src/tempo/subscription/Types.ts +++ b/src/tempo/subscription/Types.ts @@ -8,6 +8,11 @@ export type SubscriptionResource = { id: string } +export type SubscriptionResolution = { + identity: SubscriptionIdentity + resource: SubscriptionResource +} + export type SubscriptionAccessKey = { accessKeyAddress: Address keyType: 'p256' | 'secp256k1' | 'webAuthn' @@ -28,7 +33,9 @@ export type SubscriptionRecord = { subscriptionExpires: string subscriptionId: string timestamp: string - canceledAt?: string | undefined + cancelEffectiveAt?: string | undefined + pendingPeriod?: number | undefined + pendingPeriodStartedAt?: string | undefined revokedAt?: string | undefined } diff --git a/src/tempo/subscription/index.ts b/src/tempo/subscription/index.ts index bc05d8c4..e5d635a4 100644 --- a/src/tempo/subscription/index.ts +++ b/src/tempo/subscription/index.ts @@ -6,5 +6,6 @@ export type { SubscriptionIdentity, SubscriptionRecord, SubscriptionReceipt, + SubscriptionResolution, SubscriptionResource, } from './Types.js' diff --git a/test/html/server.ts b/test/html/server.ts index 6844afa3..d495104f 100644 --- a/test/html/server.ts +++ b/test/html/server.ts @@ -8,6 +8,18 @@ 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 @@ -68,8 +80,6 @@ export async function startServer(port: number): Promise { amount: '1', chainId: tempoModerato.id, currency: '0x20c0000000000000000000000000000000000000', - getIdentity: async () => ({ id: 'user-1' }), - getResource: async () => ({ id: 'plan:pro' }), html: { accessKey: { accessKeyAddress: subscriptionAccessKey.address, @@ -78,6 +88,7 @@ export async function startServer(port: number): Promise { }, periodSeconds: '2592000', recipient: account.address, + resolve: async () => ({ identity: { id: 'user-1' }, resource: { id: 'plan:pro' } }), subscriptionExpires, testnet: true, }), @@ -131,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') { @@ -142,21 +150,16 @@ 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/subscription') { const result = await tempoMppx.tempo.subscription({ description: 'Tempo Pro', externalId: 'plan_pro', + recipient: account.address, })(request) - - if (result.status === 402) return result.challenge - - return result.withReceipt(Response.json({ plan: 'pro' })) + return unwrapHttpResult(result, Response.json({ plan: 'pro' })) } if (url.pathname === '/stripe/charge') { @@ -168,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.', @@ -184,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) { @@ -199,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') { @@ -213,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 }) From 05962d0ebf70e9e3c8d998c04d3c3beaa3e2f87d Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Thu, 23 Apr 2026 21:50:35 -0700 Subject: [PATCH 3/3] chore: retrigger pr checks