From 47a0c46b42ffe871286f562f1f2f18cbe4eff908 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Wed, 6 May 2026 16:31:37 -0700 Subject: [PATCH 1/8] feat: add tempo subscriptions --- .changeset/harden-tempo-subscriptions.md | 5 + examples/README.md | 1 + examples/subscription/README.md | 40 + examples/subscription/package.json | 20 + examples/subscription/src/client.ts | 55 + examples/subscription/src/server.ts | 81 ++ examples/subscription/tsconfig.json | 13 + examples/subscription/vite.config.ts | 45 + pnpm-lock.yaml | 282 ++++- src/Method.ts | 44 +- src/Receipt.ts | 2 + src/client/Methods.ts | 1 + src/server/Mppx.authorize.test.ts | 164 +++ src/server/Mppx.ts | 224 +++- src/tempo/Methods.test.ts | 85 ++ src/tempo/Methods.ts | 129 ++ src/tempo/Subscription.integration.test.ts | 591 +++++++++ src/tempo/client/Methods.ts | 3 + src/tempo/client/Subscription.test.ts | 131 ++ src/tempo/client/Subscription.ts | 155 +++ src/tempo/client/index.ts | 1 + src/tempo/index.ts | 1 + src/tempo/server/Methods.ts | 5 + src/tempo/server/Subscription.test.ts | 1116 +++++++++++++++++ src/tempo/server/Subscription.ts | 969 ++++++++++++++ src/tempo/server/index.ts | 1 + .../subscription/KeyAuthorization.test.ts | 185 +++ src/tempo/subscription/KeyAuthorization.ts | 391 ++++++ src/tempo/subscription/Receipt.ts | 28 + src/tempo/subscription/Store.test.ts | 121 ++ src/tempo/subscription/Store.ts | 290 +++++ src/tempo/subscription/Types.ts | 66 + src/tempo/subscription/index.ts | 23 + 33 files changed, 5209 insertions(+), 59 deletions(-) create mode 100644 .changeset/harden-tempo-subscriptions.md create mode 100644 examples/subscription/README.md create mode 100644 examples/subscription/package.json create mode 100644 examples/subscription/src/client.ts create mode 100644 examples/subscription/src/server.ts create mode 100644 examples/subscription/tsconfig.json create mode 100644 examples/subscription/vite.config.ts create mode 100644 src/server/Mppx.authorize.test.ts create mode 100644 src/tempo/Subscription.integration.test.ts create mode 100644 src/tempo/client/Subscription.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/KeyAuthorization.test.ts create mode 100644 src/tempo/subscription/KeyAuthorization.ts create mode 100644 src/tempo/subscription/Receipt.ts create mode 100644 src/tempo/subscription/Store.test.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/.changeset/harden-tempo-subscriptions.md b/.changeset/harden-tempo-subscriptions.md new file mode 100644 index 00000000..fc2e6899 --- /dev/null +++ b/.changeset/harden-tempo-subscriptions.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added Tempo subscription key authorization, activation replay protection, renewal idempotency, and dynamic access key handling. diff --git a/examples/README.md b/examples/README.md index 3c7fc0dc..67b0c846 100644 --- a/examples/README.md +++ b/examples/README.md @@ -11,6 +11,7 @@ Standalone, runnable examples demonstrating the mppx HTTP 402 payment flow. | [session/sse](./session/sse/) | Pay-per-token LLM streaming with SSE | | [session/ws](./session/ws/) | Pay-per-token LLM streaming with WebSocket | | [stripe](./stripe/) | Stripe SPT charge with automatic client | +| [subscription](./subscription/) | Daily news subscription using Tempo access keys | ## Running Examples diff --git a/examples/subscription/README.md b/examples/subscription/README.md new file mode 100644 index 00000000..8d6774fe --- /dev/null +++ b/examples/subscription/README.md @@ -0,0 +1,40 @@ +# Tempo Subscription + +Recurring access-key subscription for a news app. The server charges `0.10` pathUSD per day by resolving the user to `{ key, accessKey }`, returning that dynamic Tempo access key in the MPP challenge, then requiring a `keyAuthorization` scoped to that key. + +The example keeps billing deterministic for local development: `activate` and `renew` simulate the transfer that a production app would submit with the resolved access key, then persist the subscription record and receipt. + +## Setup + +```bash +npx gitpick wevm/mppx/examples/subscription +pnpm i +``` + +## Usage + +Start the server: + +```bash +pnpm dev +``` + +In a separate terminal, run the client: + +```bash +pnpm client +``` + +The client: + +1. Requests `/api/article` and receives a `402` challenge that includes the dynamic access key for `user-1` and the `monthly` plan. +2. Signs a `keyAuthorization` for that access key and activates the subscription. +3. Requests `/api/article` again, reusing the active subscription with the same access key. + +## Test with mppx CLI + +With the server running, use the `mppx` CLI to inspect the challenge: + +```bash +pnpm mppx localhost:5173/api/article -H 'X-User-Id: user-1' +``` diff --git a/examples/subscription/package.json b/examples/subscription/package.json new file mode 100644 index 00000000..c1e3c21c --- /dev/null +++ b/examples/subscription/package.json @@ -0,0 +1,20 @@ +{ + "name": "subscription", + "private": true, + "type": "module", + "scripts": { + "check:types": "tsgo -b", + "dev": "vite", + "client": "tsx src/client.ts" + }, + "dependencies": { + "@remix-run/node-fetch-server": "^0.13.0", + "@types/node": "^25.6.0", + "@typescript/native-preview": "7.0.0-dev.20260323.1", + "mppx": "workspace:*", + "tsx": "^4.21.0", + "typescript": "~5.9.3", + "viem": "^2.47.6", + "vite": "latest" + } +} diff --git a/examples/subscription/src/client.ts b/examples/subscription/src/client.ts new file mode 100644 index 00000000..fef305b6 --- /dev/null +++ b/examples/subscription/src/client.ts @@ -0,0 +1,55 @@ +import { Receipt } from 'mppx' +import { Mppx, tempo } from 'mppx/client' +import type { Subscription } from 'mppx/tempo' +import { createClient, type Hex, http } from 'viem' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' +import { tempoModerato } from 'viem/chains' + +const baseUrl = process.env.BASE_URL ?? 'http://localhost:5173' +const userId = process.env.USER_ID ?? 'user-1' +const account = privateKeyToAccount((process.env.PRIVATE_KEY as Hex) ?? generatePrivateKey()) + +const client = createClient({ + account, + chain: tempoModerato, + transport: http(process.env.MPPX_RPC_URL), +}) + +const mppx = Mppx.create({ + methods: [ + tempo.subscription({ + account, + getClient: async () => client, + validateRequest: (request) => { + if (BigInt(request.amount) > 1n) throw new Error('subscription amount too high') + }, + }), + ], + polyfill: false, +}) + +async function readArticle(label: string) { + const response = await mppx.fetch(`${baseUrl}/api/article`, { + headers: { 'X-User-Id': userId }, + }) + if (!response.ok) throw new Error(`article request failed: ${response.status}`) + + const receipt = Receipt.fromResponse(response) + const body = (await response.json()) as { article: string; plan: string } + console.log(label) + console.log(body.article) + console.log(`subscriptionId=${receipt.subscriptionId}`) + console.log(`reference=${receipt.reference}`) +} + +console.log(`Payer: ${account.address}`) + +await readArticle('Initial activation') + +console.log('Run the server with an overdue stored subscription to exercise renewal.') + +await readArticle('Reused access') + +const subscriptionResponse = await fetch(`${baseUrl}/api/subscription?userId=${userId}`) +const subscription = (await subscriptionResponse.json()) as Subscription.SubscriptionRecord +console.log(`lastChargedPeriod=${subscription.lastChargedPeriod}`) diff --git a/examples/subscription/src/server.ts b/examples/subscription/src/server.ts new file mode 100644 index 00000000..37a3dcc2 --- /dev/null +++ b/examples/subscription/src/server.ts @@ -0,0 +1,81 @@ +import { Mppx, Store, tempo } from 'mppx/server' +import { Subscription } from 'mppx/tempo' +import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' + +const currency = '0x20c0000000000000000000000000000000000000' as const +const planId = 'monthly' +const pricePerPeriod = '0.10' +const periodCount = '1' +const periodUnit = 'day' +const subscriptionDurationMs = 30 * 24 * 60 * 60 * 1_000 +const subscriptionExpiresAtMs = Math.ceil((Date.now() + subscriptionDurationMs) / 1_000) * 1_000 +const subscriptionExpires = new Date(subscriptionExpiresAtMs).toISOString() + +const account = privateKeyToAccount(generatePrivateKey()) +const store = Store.memory() +const subscriptions = Subscription.fromStore(store) + +function subscriptionKey(userId: string) { + return `news:${userId}:${planId}` +} + +function getUserId(request: Request) { + return request.headers.get('X-User-Id') ?? new URL(request.url).searchParams.get('userId') +} + +const mppx = Mppx.create({ + methods: [ + tempo.subscription({ + amount: pricePerPeriod, + chainId: 4217, + currency, + periodCount, + periodUnit, + recipient: account.address, + resolve: async ({ input }) => { + const userId = getUserId(input) + if (!userId) return null + return { key: subscriptionKey(userId) } + }, + store, + subscriptionExpires, + hooks: { + activated: async ({ subscription }) => { + console.log(`[subscription] activated ${subscription.subscriptionId}`) + }, + renewed: async ({ periodIndex, subscription }) => { + console.log(`[subscription] renewed ${subscription.subscriptionId} period=${periodIndex}`) + }, + }, + }), + ], +}) + +export async function handler(request: Request): Promise { + const url = new URL(request.url) + + if (url.pathname === '/api/health') return Response.json({ status: 'ok' }) + + if (url.pathname === '/api/subscription') { + const userId = getUserId(request) + if (!userId) return Response.json({ error: 'missing userId' }, { status: 400 }) + return Response.json(await subscriptions.getByKey(subscriptionKey(userId))) + } + + if (url.pathname === '/api/article') { + const result = await mppx.tempo.subscription({ + description: 'News app daily subscription', + })(request) + + if (result.status === 402) return result.challenge + + return result.withReceipt( + Response.json({ + article: 'Tempo subscriptions let a news app sell recurring access.', + plan: planId, + }), + ) + } + + return null +} diff --git a/examples/subscription/tsconfig.json b/examples/subscription/tsconfig.json new file mode 100644 index 00000000..808b9ab4 --- /dev/null +++ b/examples/subscription/tsconfig.json @@ -0,0 +1,13 @@ +{ + "compilerOptions": { + "target": "ESNext", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "skipLibCheck": true, + "lib": ["ESNext", "DOM"], + "types": ["node"], + "noEmit": true + }, + "include": ["src/**/*"] +} diff --git a/examples/subscription/vite.config.ts b/examples/subscription/vite.config.ts new file mode 100644 index 00000000..98e42f9e --- /dev/null +++ b/examples/subscription/vite.config.ts @@ -0,0 +1,45 @@ +import { createRequest, sendResponse } from '@remix-run/node-fetch-server' +import { defineConfig, type Plugin, type ViteDevServer } from 'vite' + +import { handler } from './src/server.ts' + +const startupLogDelayMs = 100 + +export default defineConfig({ + plugins: [apiPlugin()], +}) + +function apiPlugin(): Plugin { + return { + name: 'api', + configureServer(server) { + // oxlint-disable-next-line no-async-endpoint-handlers + server.middlewares.use(async (req, res, next) => { + const request = createRequest(req, res) + const response = await handler(request) + if (response) await sendResponse(res, response) + else next() + }) + server.httpServer?.once('listening', () => logStartup(server)) + }, + } +} + +function logStartup(server: ViteDevServer) { + const host = getServerHost(server) + const packageRunner = getPackageRunner() + setTimeout(() => { + console.log(` ${packageRunner} mppx http://${host}/api/article`) + console.log(' pnpm client') + }, startupLogDelayMs) +} + +function getServerHost(server: ViteDevServer) { + const address = server.httpServer?.address() + return typeof address === 'object' && address ? `localhost:${address.port}` : 'localhost:5173' +} + +function getPackageRunner() { + const packageManager = process.env.npm_config_user_agent?.split('/')[0] + return packageManager === 'npm' || !packageManager ? 'npx' : packageManager +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index a7500752..72417e7d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -325,6 +325,33 @@ importers: specifier: ^8.0.10 version: 8.0.10(@types/node@25.6.0)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.4) + examples/subscription: + dependencies: + '@remix-run/node-fetch-server': + specifier: ^0.13.0 + version: 0.13.1 + '@types/node': + specifier: ^25.6.0 + version: 25.6.2 + '@typescript/native-preview': + specifier: 7.0.0-dev.20260323.1 + version: 7.0.0-dev.20260323.1 + mppx: + specifier: workspace:* + version: link:../.. + tsx: + specifier: ^4.21.0 + version: 4.21.0 + typescript: + specifier: ~5.9.3 + version: 5.9.3 + viem: + specifier: ^2.47.5 + version: 2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3) + vite: + specifier: latest + version: 8.0.12(@types/node@25.6.2)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.4) + src/stripe/server/internal/html: dependencies: '@stripe/stripe-js': @@ -917,6 +944,9 @@ packages: '@oxc-project/types@0.128.0': resolution: {integrity: sha512-huv1Y/LzBJkBVHt3OlC7u0zHBW9qXf1FdD7sGmc1rXc2P1mTwHssYv7jyGx5KAACSCH+9B3Bhn6Z9luHRvf7pQ==} + '@oxc-project/types@0.129.0': + resolution: {integrity: sha512-3oz8m3FGdr2nDXVqmFUw7jolKliC4MoyXYIG2c7gpjBnzUWQpUGIYcXYKxTdTi+N2jusvt610ckTMkxdwHkYEg==} + '@oxfmt/binding-android-arm-eabi@0.45.0': resolution: {integrity: sha512-A/UMxFob1fefCuMeGxQBulGfFE38g2Gm23ynr3u6b+b7fY7/ajGbNsa3ikMIkGMLJW/TRoQaMoP1kME7S+815w==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1252,6 +1282,12 @@ packages: '@remix-run/session@0.4.1': resolution: {integrity: sha512-Bm6aKYgutb/raHZ3laloz8g/Qu7f3CeK3o4gUVDMxtEiAdWCzJamwHoTpGOc5+g1Kuy7z85v4M6nGrF06MFDSg==} + '@rolldown/binding-android-arm64@1.0.0': + resolution: {integrity: sha512-TWMZnRLMe63C2Lhyicviu7ZHaU4kxa6PS3rofvc9GmcvptzNN11BcfQ4Sl7MwTOsisQoa2keB/EBdNCAnUo8vA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [android] + '@rolldown/binding-android-arm64@1.0.0-rc.17': resolution: {integrity: sha512-s70pVGhw4zqGeFnXWvAzJDlvxhlRollagdCCKRgOsgUOH3N1l0LIxf83AtGzmb5SiVM4Hjl5HyarMRfdfj3DaQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1264,6 +1300,12 @@ packages: cpu: [arm64] os: [android] + '@rolldown/binding-darwin-arm64@1.0.0': + resolution: {integrity: sha512-6XcD+8k0gPVItNagEw78/qqcBDwKcwDYS8V2hRmVsfUSIrd8cWe/CBvRDI5toqFyPfj+FJr6t8U6Xj2P2prEew==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [darwin] + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': resolution: {integrity: sha512-4ksWc9n0mhlZpZ9PMZgTGjeOPRu8MB1Z3Tz0Mo02eWfWCHMW1zN82Qz/pL/rC+yQa+8ZnutMF0JjJe7PjwasYw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1276,6 +1318,12 @@ packages: cpu: [arm64] os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0': + resolution: {integrity: sha512-iN/tWVXRQDWvmZlKdceP1Dwug9GDpEymhb9p4xnEe6zvCg5lFmzVljl+1qR1NVx3yfGpr2Na+CuLmv5IU8uzfQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [darwin] + '@rolldown/binding-darwin-x64@1.0.0-rc.17': resolution: {integrity: sha512-SUSDOI6WwUVNcWxd02QEBjLdY1VPHvlEkw6T/8nYG322iYWCTxRb1vzk4E+mWWYehTp7ERibq54LSJGjmouOsw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1288,6 +1336,12 @@ packages: cpu: [x64] os: [darwin] + '@rolldown/binding-freebsd-x64@1.0.0': + resolution: {integrity: sha512-jjQMDvvwSOuhOwMszD/klSOjyWMM3zI64hWTj9KT5x4MxRbZAf+7vLQ6qouRhtsLVFHr3f0ILaJAfgENPiQdAQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [freebsd] + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': resolution: {integrity: sha512-hwnz3nw9dbJ05EDO/PvcjaaewqqDy7Y1rn1UO81l8iIK1GjenME75dl16ajbvSSMfv66WXSRCYKIqfgq2KCfxw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1300,6 +1354,12 @@ packages: cpu: [x64] os: [freebsd] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0': + resolution: {integrity: sha512-d//Dtg2x6/m3mbV64yUGNnDGNZaDGRpDLLNGerHQUVObuNaIQaaDp25yUiqGXtHEXX+NP2d0wAlmKgpYgIAJ2A==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm] + os: [linux] + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': resolution: {integrity: sha512-IS+W7epTcwANmFSQFrS1SivEXHtl1JtuQA9wlxrZTcNi6mx+FDOYrakGevvvTwgj2JvWiK8B29/qD9BELZPyXQ==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1312,6 +1372,13 @@ packages: cpu: [arm] os: [linux] + '@rolldown/binding-linux-arm64-gnu@1.0.0': + resolution: {integrity: sha512-n7Ofp0mx+aB2cC+Sdy5YtMnXtY9lchnHbY+3Yt0uq9JsWQExf4f5Whu0tK0R8Jdc9S6RchTHjIFY7uc92puOVQ==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-e6usGaHKW5BMNZOymS1UcEYGowQMWcgZ71Z17Sl/h2+ZziNJ1a9n3Zvcz6LdRyIW5572wBCTH/Z+bKuZouGk9Q==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1326,6 +1393,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-arm64-musl@1.0.0': + resolution: {integrity: sha512-EIVjy2cgd7uuMMo94FVkBp7F6DhcZAUwNURkSG3RwUmvAXR6s0ISxM81U+IydcZByPG0pZIHsf1b6kTxoFDgJA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': resolution: {integrity: sha512-b/CgbwAJpmrRLp02RPfhbudf5tZnN9nsPWK82znefso832etkem8H7FSZwxrOI9djcdTP7U6YfNhbRnh7djErg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1340,6 +1414,13 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-linux-ppc64-gnu@1.0.0': + resolution: {integrity: sha512-JEwwOPcwTLAcpDQlqSmjEmfs63xJnSiUNIGvLcDLUHCWK4XowpS/7c7tUsUH6uT/ct6bMUTdXKfI8967FYj6mg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [ppc64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-4EII1iNGRUN5WwGbF/kOh/EIkoDN9HsupgLQoXfY+D1oyJm7/F4t5PYU5n8SWZgG0FEwakyM8pGgwcBYruGTlA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1354,6 +1435,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.0': + resolution: {integrity: sha512-0wjCFhLrihtAubnT9iA0N++0pSV0z5Hg7tNGdNJ4RFaINceHadoF+kiFGyY1qSSNVIAZtLotG8Ju1bgDPkjnFA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [s390x] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': resolution: {integrity: sha512-AH8oq3XqQo4IibpVXvPeLDI5pzkpYn0WiZAfT05kFzoJ6tQNzwRdDYQ45M8I/gslbodRZwW8uxLhbSBbkv96rA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1368,6 +1456,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.0': + resolution: {integrity: sha512-Dfn7iak9BcMMePxcoJfpSbWqnEyrp/dRF63/8qW/eHBdOZov6x5aShLLEYGYdIeSJ6vMLK/XCVB+lGIxm41bQA==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [glibc] + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': resolution: {integrity: sha512-cLnjV3xfo7KslbU41Z7z8BH/E1y5mzUYzAqih1d1MDaIGZRCMqTijqLv76/P7fyHuvUcfGsIpqCdddbxLLK9rA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1382,6 +1477,13 @@ packages: os: [linux] libc: [glibc] + '@rolldown/binding-linux-x64-musl@1.0.0': + resolution: {integrity: sha512-5/utzzDmD/pD/bmuaUcbTf/sZYy0aztwIVlfpoW1fTjCZ0BaPOMVWGZL1zvgxyi7ZIVYWlxKONHmSbHuiOh8Jw==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [linux] + libc: [musl] + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': resolution: {integrity: sha512-0phclDw1spsL7dUB37sIARuis2tAgomCJXAHZlpt8PXZ4Ba0dRP1e+66lsRqrfhISeN9bEGNjQs+T/Fbd7oYGw==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1396,6 +1498,12 @@ packages: os: [linux] libc: [musl] + '@rolldown/binding-openharmony-arm64@1.0.0': + resolution: {integrity: sha512-ouJs8VcUomfLfpbUECqFMRqdV4x6aeAK3MA4m6vTrJJjKyWTV5KnxZx7Jd9G+GlDaQQxubcba00x16OyJ1meig==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [openharmony] + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': resolution: {integrity: sha512-0ag/hEgXOwgw4t8QyQvUCxvEg+V0KBcA6YuOx9g0r02MprutRF5dyljgm3EmR02O292UX7UeS6HzWHAl6KgyhA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1408,6 +1516,11 @@ packages: cpu: [arm64] os: [openharmony] + '@rolldown/binding-wasm32-wasi@1.0.0': + resolution: {integrity: sha512-E+oHKGiDA+lsKMmFtffDDw91EryDT7uJocrIuCHqhm6bCTM6xFK+3gaCkYOHfPwQr0cCNarSM2xaELoQDz9jJg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [wasm32] + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': resolution: {integrity: sha512-LEXei6vo0E5wTGwpkJ4KoT3OZJRnglwldt5ziLzOlc6qqb55z4tWNq2A+PFqCJuvWWdP53CVhG1Z9NtToDPJrA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1418,6 +1531,12 @@ packages: engines: {node: ^20.19.0 || >=22.12.0} cpu: [wasm32] + '@rolldown/binding-win32-arm64-msvc@1.0.0': + resolution: {integrity: sha512-yYK02n8Rngo+gbm1y6G0+7jk1sJ/2Wt7K0me0Y7k/ErBpyf+LJ2gFpqWVTcRV1rUepBlQRmpgWkTQCiiwrK0Ow==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [arm64] + os: [win32] + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': resolution: {integrity: sha512-gUmyzBl3SPMa6hrqFUth9sVfcLBlYsbMzBx5PlexMroZStgzGqlZ26pYG89rBb45Mnia+oil6YAIFeEWGWhoZA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1430,6 +1549,12 @@ packages: cpu: [arm64] os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0': + resolution: {integrity: sha512-14bpChMahXRRXiTwahSl+zzHPW6qQTXtkMuJBFlbo+pqSAews2d4BdCSHfrJ/MBsCZtpmTafsY+1QhBzitcmdg==} + engines: {node: ^20.19.0 || >=22.12.0} + cpu: [x64] + os: [win32] + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': resolution: {integrity: sha512-3hkiolcUAvPB9FLb3UZdfjVVNWherN1f/skkGWJP/fgSQhYUZpSIRr0/I8ZK9TkF3F7kxvJAk0+IcKvPHk9qQg==} engines: {node: ^20.19.0 || >=22.12.0} @@ -1442,6 +1567,9 @@ packages: cpu: [x64] os: [win32] + '@rolldown/pluginutils@1.0.0': + resolution: {integrity: sha512-aKs/3GSWyV0mrhNmt/96/Z3yczC3yvrzYATCiCXQebBsGyYzjNdUphRVLeJQ67ySKVXRfMxt2lm12pmXvbPFQQ==} + '@rolldown/pluginutils@1.0.0-rc.17': resolution: {integrity: sha512-n8iosDOt6Ig1UhJ2AYqoIhHWh/isz0xpicHTzpKBeotdVsTEcxsSA/i3EVM7gQAj0rU27OLAxCjzlj15IWY7bg==} @@ -3474,6 +3602,11 @@ packages: resolution: {integrity: sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==} engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + rolldown@1.0.0: + resolution: {integrity: sha512-yD986aXDESFGS95spT1LAv0jssywP4npMEjmMHyN2/5+eE8qQJUype2AaKkRiLgBgyD0LFlubwAht7VmY8rGoA==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + rolldown@1.0.0-rc.17: resolution: {integrity: sha512-ZrT53oAKrtA4+YtBWPQbtPOxIbVDbxT0orcYERKd63VJTF13zPcgXTvD4843L8pcsI7M6MErt8QtON6lrB9tyA==} engines: {node: ^20.19.0 || >=22.12.0} @@ -3920,6 +4053,49 @@ packages: yaml: optional: true + vite@8.0.12: + resolution: {integrity: sha512-w2dDofOWv2QB09ZITZBsvKTVAlYvPR4IAmrY/v0ir9KvLs0xybR7i48wxhM1/oyBWO34wPns+bPGw5ZrZqDpZg==} + engines: {node: ^20.19.0 || >=22.12.0} + hasBin: true + peerDependencies: + '@types/node': ^20.19.0 || >=22.12.0 + '@vitejs/devtools': ^0.1.18 + esbuild: ^0.27.0 || ^0.28.0 + jiti: '>=1.21.0' + less: ^4.0.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: '>=0.54.8' + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: '>=2.8.3' + peerDependenciesMeta: + '@types/node': + optional: true + '@vitejs/devtools': + optional: true + esbuild: + optional: true + jiti: + optional: true + less: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + wagmi@3.6.9: resolution: {integrity: sha512-9Lrkf7bXyhG/aSK/65V2t+44Kti2m9tqaTS2vQTCeUgfaYlmFfx1RDUm4f8me5zcYclAo1XbJjm5x99dw7xAiA==} peerDependencies: @@ -4735,6 +4911,8 @@ snapshots: '@oxc-project/types@0.128.0': {} + '@oxc-project/types@0.129.0': {} + '@oxfmt/binding-android-arm-eabi@0.45.0': optional: true @@ -4914,78 +5092,121 @@ snapshots: '@remix-run/session@0.4.1': {} + '@rolldown/binding-android-arm64@1.0.0': + optional: true + '@rolldown/binding-android-arm64@1.0.0-rc.17': optional: true '@rolldown/binding-android-arm64@1.0.0-rc.18': optional: true + '@rolldown/binding-darwin-arm64@1.0.0': + optional: true + '@rolldown/binding-darwin-arm64@1.0.0-rc.17': optional: true '@rolldown/binding-darwin-arm64@1.0.0-rc.18': optional: true + '@rolldown/binding-darwin-x64@1.0.0': + optional: true + '@rolldown/binding-darwin-x64@1.0.0-rc.17': optional: true '@rolldown/binding-darwin-x64@1.0.0-rc.18': optional: true + '@rolldown/binding-freebsd-x64@1.0.0': + optional: true + '@rolldown/binding-freebsd-x64@1.0.0-rc.17': optional: true '@rolldown/binding-freebsd-x64@1.0.0-rc.18': optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0': + optional: true + '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.17': optional: true '@rolldown/binding-linux-arm-gnueabihf@1.0.0-rc.18': optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0': + optional: true + '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.17': optional: true '@rolldown/binding-linux-arm64-gnu@1.0.0-rc.18': optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0': + optional: true + '@rolldown/binding-linux-arm64-musl@1.0.0-rc.17': optional: true '@rolldown/binding-linux-arm64-musl@1.0.0-rc.18': optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.0': + optional: true + '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.17': optional: true '@rolldown/binding-linux-ppc64-gnu@1.0.0-rc.18': optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.0': + optional: true + '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.17': optional: true '@rolldown/binding-linux-s390x-gnu@1.0.0-rc.18': optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0': + optional: true + '@rolldown/binding-linux-x64-gnu@1.0.0-rc.17': optional: true '@rolldown/binding-linux-x64-gnu@1.0.0-rc.18': optional: true + '@rolldown/binding-linux-x64-musl@1.0.0': + optional: true + '@rolldown/binding-linux-x64-musl@1.0.0-rc.17': optional: true '@rolldown/binding-linux-x64-musl@1.0.0-rc.18': optional: true + '@rolldown/binding-openharmony-arm64@1.0.0': + optional: true + '@rolldown/binding-openharmony-arm64@1.0.0-rc.17': optional: true '@rolldown/binding-openharmony-arm64@1.0.0-rc.18': optional: true + '@rolldown/binding-wasm32-wasi@1.0.0': + dependencies: + '@emnapi/core': 1.10.0 + '@emnapi/runtime': 1.10.0 + '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) + optional: true + '@rolldown/binding-wasm32-wasi@1.0.0-rc.17': dependencies: '@emnapi/core': 1.10.0 @@ -5000,18 +5221,26 @@ snapshots: '@napi-rs/wasm-runtime': 1.1.4(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0) optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0': + optional: true + '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.17': optional: true '@rolldown/binding-win32-arm64-msvc@1.0.0-rc.18': optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0': + optional: true + '@rolldown/binding-win32-x64-msvc@1.0.0-rc.17': optional: true '@rolldown/binding-win32-x64-msvc@1.0.0-rc.18': optional: true + '@rolldown/pluginutils@1.0.0': {} + '@rolldown/pluginutils@1.0.0-rc.17': {} '@rolldown/pluginutils@1.0.0-rc.18': {} @@ -5082,7 +5311,7 @@ snapshots: '@types/body-parser@1.19.6': dependencies: '@types/connect': 3.4.38 - '@types/node': 25.6.0 + '@types/node': 25.6.2 '@types/chai@5.2.3': dependencies: @@ -5091,7 +5320,7 @@ snapshots: '@types/connect@3.4.38': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.6.2 '@types/debug@4.1.13': dependencies: @@ -5101,13 +5330,13 @@ snapshots: '@types/docker-modem@3.0.6': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.6.2 '@types/ssh2': 1.15.5 '@types/dockerode@4.0.1': dependencies: '@types/docker-modem': 3.0.6 - '@types/node': 25.6.0 + '@types/node': 25.6.2 '@types/ssh2': 1.15.5 '@types/esrecurse@4.3.1': {} @@ -5116,7 +5345,7 @@ snapshots: '@types/express-serve-static-core@5.1.1': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.6.2 '@types/qs': 6.14.0 '@types/range-parser': 1.2.7 '@types/send': 1.2.1 @@ -5161,20 +5390,20 @@ snapshots: '@types/send@1.2.1': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.6.2 '@types/serve-static@2.2.0': dependencies: '@types/http-errors': 2.0.5 - '@types/node': 25.6.0 + '@types/node': 25.6.2 '@types/ssh2-streams@0.1.13': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.6.2 '@types/ssh2@0.5.52': dependencies: - '@types/node': 25.6.0 + '@types/node': 25.6.2 '@types/ssh2-streams': 0.1.13 '@types/ssh2@1.15.5': @@ -6926,6 +7155,27 @@ snapshots: reusify@1.1.0: {} + rolldown@1.0.0: + dependencies: + '@oxc-project/types': 0.129.0 + '@rolldown/pluginutils': 1.0.0 + optionalDependencies: + '@rolldown/binding-android-arm64': 1.0.0 + '@rolldown/binding-darwin-arm64': 1.0.0 + '@rolldown/binding-darwin-x64': 1.0.0 + '@rolldown/binding-freebsd-x64': 1.0.0 + '@rolldown/binding-linux-arm-gnueabihf': 1.0.0 + '@rolldown/binding-linux-arm64-gnu': 1.0.0 + '@rolldown/binding-linux-arm64-musl': 1.0.0 + '@rolldown/binding-linux-ppc64-gnu': 1.0.0 + '@rolldown/binding-linux-s390x-gnu': 1.0.0 + '@rolldown/binding-linux-x64-gnu': 1.0.0 + '@rolldown/binding-linux-x64-musl': 1.0.0 + '@rolldown/binding-openharmony-arm64': 1.0.0 + '@rolldown/binding-wasm32-wasi': 1.0.0 + '@rolldown/binding-win32-arm64-msvc': 1.0.0 + '@rolldown/binding-win32-x64-msvc': 1.0.0 + rolldown@1.0.0-rc.17: dependencies: '@oxc-project/types': 0.127.0 @@ -7484,6 +7734,20 @@ snapshots: tsx: 4.21.0 yaml: 2.8.4 + vite@8.0.12(@types/node@25.6.2)(esbuild@0.27.2)(tsx@4.21.0)(yaml@2.8.4): + dependencies: + lightningcss: 1.32.0 + picomatch: 4.0.4 + postcss: 8.5.14 + rolldown: 1.0.0 + tinyglobby: 0.2.16 + optionalDependencies: + '@types/node': 25.6.2 + esbuild: 0.27.2 + fsevents: 2.3.3 + tsx: 4.21.0 + yaml: 2.8.4 + wagmi@3.6.9(@tanstack/query-core@5.100.9)(@tanstack/react-query@5.100.9(react@19.2.5))(@types/react@19.2.14)(accounts@0.8.10)(react@19.2.5)(typescript@5.9.3)(viem@2.47.6(bufferutil@4.1.0)(typescript@5.9.3)(utf-8-validate@5.0.10)(zod@4.4.3)): dependencies: '@tanstack/react-query': 5.100.9(react@19.2.5) diff --git a/src/Method.ts b/src/Method.ts index 068742d6..e24519f4 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -130,10 +130,12 @@ export type Server< defaults extends ExactPartial> = {}, transportOverride = undefined, > = method & { + authorize?: AuthorizeFn | undefined defaults?: defaults | undefined html?: Html.Options | undefined request?: RequestFn | undefined respond?: RespondFn | undefined + stableBinding?: StableBindingFn | undefined transport?: transportOverride | undefined verify: VerifyFn } @@ -155,6 +157,42 @@ 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 + +/** Successful result returned from an {@link AuthorizeFn}. */ +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, @@ -251,13 +289,15 @@ export function toServer< method: method, options: toServer.Options, ): Server { - const { defaults, html, request, respond, transport, verify } = options + const { authorize, defaults, html, request, respond, stableBinding, transport, verify } = options return { ...method, + authorize, defaults, html, request, respond, + stableBinding, transport, verify, } as Server @@ -269,10 +309,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..78a6b812 --- /dev/null +++ b/src/server/Mppx.authorize.test.ts @@ -0,0 +1,164 @@ +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(), + periodCount: z.string(), + periodUnit: z.enum(['day', 'week']), + recipient: z.string(), + subscriptionExpires: z.string(), + }), + }, + }), + { + stableBinding(request) { + return { + amount: request.amount, + chainId: request.chainId, + currency: request.currency, + periodCount: request.periodCount, + periodUnit: request.periodUnit, + 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', + periodCount: '30', + periodUnit: 'day', + 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', + periodCount: '60', + periodUnit: 'day', + 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('periodCount') + }) +}) diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 87496810..dd99fa95 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -237,12 +237,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, }) @@ -339,7 +341,11 @@ export function create< routeRequest: options?.request ?? {}, secretKey: secretKey!, }).then((resolved) => { - const mismatch = getPinnedChallengeMismatch(resolved.challenge, credential.challenge) + const mismatch = getChallengeBindingMismatch( + resolved.challenge, + credential.challenge, + mi.stableBinding as never, + ) if (mismatch) throw new Errors.InvalidChallengeError({ id: credential.challenge.id, @@ -421,7 +427,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, scope, ...rest } = options @@ -472,8 +488,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, @@ -530,7 +615,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, @@ -601,30 +690,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: { @@ -635,6 +706,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R name: method.name, intent: method.intent, _canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }), + _stableBinding: stableBinding as never, }, }, ) @@ -694,12 +766,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 } @@ -831,6 +905,26 @@ 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 + +function getChallengeBindingMismatch( + expectedChallenge: Challenge.Challenge, + actualChallenge: Challenge.Challenge, + stableBinding?: Method.StableBindingFn | undefined, +): string | undefined { + if (!stableBinding) return getPinnedChallengeMismatch(expectedChallenge, actualChallenge) + + for (const field of ['method', 'intent', 'realm'] as const) { + if (actualChallenge[field] !== expectedChallenge[field]) return field + } + + if (!opaqueValuesMatch(expectedChallenge.meta, actualChallenge.meta)) return 'opaque' + + return getRequestBindingMismatch( + getStableBinding(expectedChallenge.request as Record, stableBinding), + getStableBinding(actualChallenge.request as Record, stableBinding), + ) +} /** * Compares only the fields that MUST be stable across request-hook transforms. @@ -911,6 +1005,44 @@ function getPinnedRequestBinding(request: Record): PinnedReques } } +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 getStableBinding( + request: Record, + stableBinding: Method.StableBindingFn, +): StableBinding { + return stableBinding(request as never) +} + +/** Top-level economic fields that should never drift after challenge issuance. */ +type CoreBinding = { + [field in CoreBindingField]?: string +} + +/** Method-specific fields that are pinned by the fallback binding check. */ +type MethodBinding = { + [field in MethodBindingField]?: unknown +} + +/** Normalized request subset used when a method does not provide a custom stable binding. */ +type PinnedRequestBinding = { + coreBinding: CoreBinding + methodBinding: MethodBinding +} + function normalizeScalar(value: unknown): string | undefined { return value === undefined ? undefined : String(value) } @@ -957,20 +1089,6 @@ function hydrateCredentialMeta( }, } } - -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, @@ -1019,6 +1137,7 @@ type ConfiguredHandler = ((input: Request) => Promise | undefined scope?: string | undefined _canonicalRequest: Record + _stableBinding?: Method.StableBindingFn | undefined } } @@ -1142,12 +1261,13 @@ 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) && - opaqueValuesMatch(internal.meta, credential.challenge.meta) - ) + const mismatch = internal._stableBinding + ? getRequestBindingMismatch( + getStableBinding(internal._canonicalRequest, internal._stableBinding), + getStableBinding(credReq, internal._stableBinding), + ) + : getPinnedRequestBindingMismatch(internal._canonicalRequest, credReq) + return !mismatch && opaqueValuesMatch(internal.meta, credential.challenge.meta) }) const match = @@ -1164,8 +1284,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..fb5da08c 100644 --- a/src/tempo/Methods.test.ts +++ b/src/tempo/Methods.test.ts @@ -1,6 +1,8 @@ import { Methods } from 'mppx/tempo' import { describe, expect, expectTypeOf, test } from 'vp/test' +const secondsPerDay = 86_400n + describe('charge', () => { test('has correct name and intent', () => { expect(Methods.charge.intent).toBe('charge') @@ -247,3 +249,86 @@ 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({ + accessKey: { + accessKeyAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + keyType: 'secp256k1', + }, + amount: '10', + chainId: 4217, + currency: '0x20c0000000000000000000000000000000000001', + decimals: 6, + periodCount: '1', + periodUnit: 'day', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + subscriptionExpires: '2026-01-01T00:00:00Z', + }) + + expect(request.amount).toBe('10000000') + expect(request.methodDetails?.chainId).toBe(4217) + expect(request.methodDetails?.accessKey).toEqual({ + accessKeyAddress: '0xabcdefabcdefabcdefabcdefabcdefabcdefabcd', + keyType: 'secp256k1', + }) + expect('accessKey' in request).toBe(false) + }) + + test('schema: rejects non-numeric periodCount', () => { + const result = Methods.subscription.schema.request.safeParse({ + amount: '10', + currency: '0x20c0000000000000000000000000000000000001', + decimals: 6, + periodCount: 'month', + periodUnit: 'day', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + subscriptionExpires: '2026-01-01T00:00:00Z', + }) + + expect(result.success).toBe(false) + }) + + test('schema: rejects calendar-month periods that Tempo cannot represent exactly', () => { + const result = Methods.subscription.schema.request.safeParse({ + amount: '10', + currency: '0x20c0000000000000000000000000000000000001', + decimals: 6, + periodCount: '1', + periodUnit: 'month', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + subscriptionExpires: '2026-01-01T00:00:00Z', + }) + + expect(result.success).toBe(false) + }) + + test('schema: rejects periods whose mapped seconds exceed uint64', () => { + const result = Methods.subscription.schema.request.safeParse({ + amount: '10', + currency: '0x20c0000000000000000000000000000000000001', + decimals: 6, + periodCount: String((1n << 64n) / secondsPerDay + 1n), + periodUnit: 'day', + 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..8d64671e 100644 --- a/src/tempo/Methods.ts +++ b/src/tempo/Methods.ts @@ -3,6 +3,7 @@ import { parseUnits } from 'viem' import * as Method from '../Method.js' import * as z from '../zod.js' +import type { SubscriptionPeriodUnit } from './subscription/Types.js' export const chargeModes = ['push', 'pull'] as const export type ChargeMode = (typeof chargeModes)[number] @@ -16,6 +17,71 @@ const split = z.object({ ), }) +const uint64Max = (1n << 64n) - 1n +const secondsPerDay = 86_400n +const secondsPerWeek = 604_800n + +const normalizedAddress = z.pipe( + z.address(), + z.transform((value) => value.toLowerCase() as Address), +) + +const subscriptionAccessKey = z.object({ + accessKeyAddress: normalizedAddress, + keyType: z.enum(['p256', 'secp256k1', 'webAuthn']), +}) + +const subscriptionMethodDetails = z.object({ + accessKey: z.optional(subscriptionAccessKey), + chainId: z.optional(z.number()), +}) + +const subscriptionExpires = z + .pipe( + z.datetime(), + z.transform((value) => new Date(value)), + ) + .check( + z.refine( + (value) => value.getTime() % 1_000 === 0, + 'subscriptionExpires must be representable as whole seconds', + ), + ) + +const subscriptionPeriodUnits = ['day', 'week'] as const satisfies readonly SubscriptionPeriodUnit[] +const subscriptionPeriodUnit = z.enum(subscriptionPeriodUnits) + +const uint64String = z.string().check( + z.regex(/^[1-9]\d*$/, 'Invalid periodCount'), + z.refine((value) => { + try { + return BigInt(value) <= uint64Max + } catch { + return false + } + }, 'periodCount exceeds uint64'), +) + +function positiveParsedAmount(message: string) { + return z.refine((value) => { + const { amount, decimals } = value as { amount: string; decimals: number } + return parseUnits(amount, decimals) > 0n + }, message) +} + +function subscriptionPeriodFitsUint64(value: unknown) { + const { periodCount, periodUnit } = value as { + periodCount: string + periodUnit: SubscriptionPeriodUnit + } + try { + const unitSeconds = periodUnit === 'day' ? secondsPerDay : secondsPerWeek + return BigInt(periodCount) * unitSeconds <= uint64Max + } catch { + return false + } +} + /** * Tempo charge intent for one-time TIP-20 token transfers. * @@ -199,3 +265,66 @@ 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(), + accessKey: z.optional(subscriptionAccessKey), + chainId: z.optional(z.number()), + currency: normalizedAddress, + decimals: z.number(), + description: z.optional(z.string()), + externalId: z.optional(z.string()), + methodDetails: z.optional(subscriptionMethodDetails), + periodCount: uint64String, + periodUnit: subscriptionPeriodUnit, + recipient: normalizedAddress, + subscriptionExpires, + }) + .check( + positiveParsedAmount('Subscription amount must be greater than 0'), + z.refine(subscriptionPeriodFitsUint64, 'Subscription period exceeds uint64'), + ), + z.transform( + ({ accessKey, amount, chainId, decimals, methodDetails, subscriptionExpires, ...rest }) => { + // Accept top-level convenience input, but serialize Tempo-specific fields under methodDetails. + const nextMethodDetails: { + accessKey?: z.infer | undefined + chainId?: number | undefined + } = { + ...methodDetails, + ...(accessKey !== undefined && { accessKey }), + ...(chainId !== undefined && { chainId }), + } + + return { + ...rest, + amount: parseUnits(amount, decimals).toString(), + subscriptionExpires: subscriptionExpires.toISOString(), + ...(Object.keys(nextMethodDetails).length > 0 + ? { + methodDetails: nextMethodDetails, + } + : {}), + } + }, + ), + ), + }, +}) diff --git a/src/tempo/Subscription.integration.test.ts b/src/tempo/Subscription.integration.test.ts new file mode 100644 index 00000000..94255c76 --- /dev/null +++ b/src/tempo/Subscription.integration.test.ts @@ -0,0 +1,591 @@ +import { Receipt } from 'mppx' +import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client' +import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server' +import { privateKeyToAccount } from 'viem/accounts' +import { describe, expect, test, vi } from 'vp/test' + +import * as Store from '../Store.js' +import { createSubscriptionReceipt } from './subscription/Receipt.js' +import * as SubscriptionStore from './subscription/Store.js' +import type { SubscriptionAccessKey, SubscriptionRecord } from './subscription/Types.js' + +const realm = 'news.example.com' +const secretKey = 'subscription-lifecycle-secret' +const currency = '0x20c0000000000000000000000000000000000001' +const recipient = '0x1234567890abcdef1234567890abcdef12345678' +const periodCount = '30' +const periodUnit = 'day' +const periodSeconds = String(30 * 86_400) +const subscriptionExpires = new Date( + Math.ceil((Date.now() + 365 * 24 * 60 * 60 * 1_000) / 1_000) * 1_000, +).toISOString() +const userId = 'user-1' +const planId = 'monthly' +const subscriptionKey = `news:${userId}:${planId}` +const rootAccount = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000001', +) +const accessAccount = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000002', +) +const accessKey = { + accessKeyAddress: accessAccount.address, + keyType: 'secp256k1', +} as const satisfies SubscriptionAccessKey + +function txHash(index: number) { + return `0x${index.toString(16).padStart(64, '0')}` as const +} + +function timestamp(index: number) { + return new Date(Date.UTC(2025, 0, 1, 0, 0, index)).toISOString() +} + +function receiptFor(record: SubscriptionRecord) { + return createSubscriptionReceipt(record) +} + +describe('tempo subscription lifecycle integration', () => { + test('runs a news app subscription from activation through reuse, renewal, cancellation, and reactivation', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + const events: string[] = [] + const resolvedKeys: string[] = [] + const renewalReferences: string[] = [] + let activationCount = 0 + let renewalCount = 0 + + const server = Mppx_server.create({ + methods: [ + tempo_server.subscription({ + activate: async ({ request, resolved, source }) => { + activationCount += 1 + const record = { + amount: request.amount, + billingAnchor: new Date().toISOString(), + chainId: request.methodDetails?.chainId, + currency: request.currency, + lastChargedPeriod: 0, + lookupKey: resolved.key, + periodCount: request.periodCount, + periodUnit: request.periodUnit, + recipient: request.recipient, + reference: txHash(activationCount), + subscriptionExpires: request.subscriptionExpires, + subscriptionId: `sub_${activationCount}`, + timestamp: timestamp(activationCount), + } satisfies SubscriptionRecord + + events.push(`activated:${record.subscriptionId}:${source?.address.toLowerCase()}`) + return { + receipt: receiptFor(record), + subscription: record, + } + }, + amount: '1', + chainId: 4217, + currency, + periodCount, + periodUnit, + recipient, + resolve: async ({ input }) => { + const requestedUserId = input.headers.get('X-User-Id') + if (!requestedUserId) return null + const key = `news:${requestedUserId}:${planId}` + resolvedKeys.push(key) + if (key !== subscriptionKey) throw new Error('unknown subscription key') + return { accessKey, key } + }, + renew: async ({ inFlightReference, periodIndex, subscription }) => { + renewalCount += 1 + renewalReferences.push(inFlightReference) + const record = { + ...subscription, + lastChargedPeriod: periodIndex, + reference: txHash(100 + renewalCount), + timestamp: timestamp(100 + renewalCount), + } + + events.push(`renewed:${record.subscriptionId}:${periodIndex}`) + return { + receipt: receiptFor(record), + subscription: record, + } + }, + store, + subscriptionExpires, + hooks: { + activated: async ({ subscription }) => { + events.push(`hook:activated:${subscription.subscriptionId}`) + }, + renewed: async ({ periodIndex, subscription }) => { + events.push(`hook:renewed:${subscription.subscriptionId}:${periodIndex}`) + }, + }, + }), + ], + realm, + secretKey, + }) + + const appFetch = async (input: RequestInfo | URL, init?: RequestInit) => { + const request = new Request(input, init) + const result = await server.tempo.subscription({})(request) + if (result.status === 402) return result.challenge + return result.withReceipt( + Response.json({ + article: 'paid article', + userId: request.headers.get('X-User-Id'), + }), + ) + } + + const client = Mppx_client.create({ + fetch: appFetch, + methods: [ + tempo_client.subscription({ + account: rootAccount, + getClient: async () => + ({ + request: async () => { + throw new Error('wallet_authorizeAccessKey should not be called for local account') + }, + }) as never, + validateRequest: (request) => { + expect(request.amount).toBe('1000000') + expect(request.currency).toBe(currency) + expect(request.periodCount).toBe(periodCount) + expect(request.periodUnit).toBe(periodUnit) + expect(request.recipient).toBe(recipient) + }, + }), + ], + polyfill: false, + }) + + const request = { + headers: { 'X-User-Id': userId }, + } as const + + const activated = await client.fetch('https://news.example.com/articles/tempo', request) + expect(activated.status).toBe(200) + expect((await activated.clone().json()).article).toBe('paid article') + expect(Receipt.fromResponse(activated).subscriptionId).toBe('sub_1') + expect(activationCount).toBe(1) + expect(resolvedKeys.at(0)).toBe(subscriptionKey) + expect(await subscriptions.getByKey(subscriptionKey)).toMatchObject({ + accessKey: { + accessKeyAddress: accessKey.accessKeyAddress.toLowerCase(), + keyType: accessKey.keyType, + }, + amount: '1000000', + lastChargedPeriod: 0, + lookupKey: subscriptionKey, + periodCount, + periodUnit, + subscriptionId: 'sub_1', + }) + + const reused = await client.fetch('https://news.example.com/articles/tempo', request) + expect(reused.status).toBe(200) + expect(Receipt.fromResponse(reused).subscriptionId).toBe('sub_1') + expect(activationCount).toBe(1) + expect(renewalCount).toBe(0) + + const active = await subscriptions.get('sub_1') + if (!active) throw new Error('expected active subscription') + await subscriptions.put({ + ...active, + billingAnchor: new Date(Date.now() - 3 * Number(periodSeconds) * 1_000).toISOString(), + lastChargedPeriod: 0, + reference: txHash(99), + timestamp: timestamp(99), + }) + + const renewed = await client.fetch('https://news.example.com/articles/tempo', request) + expect(renewed.status).toBe(200) + expect(Receipt.fromResponse(renewed).subscriptionId).toBe('sub_1') + expect(renewalCount).toBe(1) + const afterRequestRenewal = await subscriptions.get('sub_1') + expect(afterRequestRenewal?.lastChargedPeriod).toBeGreaterThan(0) + expect(afterRequestRenewal?.inFlightPeriod).toBe(undefined) + + if (!afterRequestRenewal) throw new Error('expected renewed subscription') + await subscriptions.put({ + ...afterRequestRenewal, + billingAnchor: new Date(Date.now() - 5 * Number(periodSeconds) * 1_000).toISOString(), + lastChargedPeriod: 1, + reference: txHash(199), + timestamp: timestamp(199), + }) + const backgroundRenewal = await tempo_server.renewSubscription({ + renew: async ({ inFlightReference, periodIndex, subscription }) => { + renewalCount += 1 + renewalReferences.push(inFlightReference) + const record = { + ...subscription, + lastChargedPeriod: periodIndex, + reference: txHash(100 + renewalCount), + timestamp: timestamp(100 + renewalCount), + } + events.push(`background:${record.subscriptionId}:${periodIndex}`) + return { + receipt: receiptFor(record), + subscription: record, + } + }, + store, + subscriptionId: 'sub_1', + }) + expect(backgroundRenewal?.subscription.subscriptionId).toBe('sub_1') + expect( + await tempo_server.renewSubscription({ + renew: async () => { + throw new Error('already renewed period should not be charged again') + }, + store, + subscriptionId: 'sub_1', + }), + ).toBe(null) + + const current = await subscriptions.get('sub_1') + if (!current) throw new Error('expected subscription before cancellation') + await subscriptions.put({ + ...current, + canceledAt: timestamp(240), + }) + + const canceledProbe = await appFetch('https://news.example.com/articles/tempo', { + headers: { 'X-User-Id': userId }, + }) + expect(canceledProbe.status).toBe(402) + + const reactivated = await client.fetch('https://news.example.com/articles/tempo', request) + expect(reactivated.status).toBe(200) + expect(Receipt.fromResponse(reactivated).subscriptionId).toBe('sub_2') + expect(activationCount).toBe(2) + expect((await subscriptions.getByKey(subscriptionKey))?.subscriptionId).toBe('sub_2') + + expect(resolvedKeys.every((key) => key === subscriptionKey)).toBe(true) + expect(renewalReferences).toEqual( + expect.arrayContaining([expect.stringMatching(/^renewal:sub_1:\d+$/)]), + ) + expect(events).toEqual( + expect.arrayContaining([ + `activated:sub_1:${rootAccount.address.toLowerCase()}`, + 'hook:activated:sub_1', + expect.stringMatching(/^renewed:sub_1:\d+$/), + expect.stringMatching(/^hook:renewed:sub_1:\d+$/), + expect.stringMatching(/^background:sub_1:\d+$/), + `activated:sub_2:${rootAccount.address.toLowerCase()}`, + 'hook:activated:sub_2', + ]), + ) + }) + + test('renews 30-day elapsed periods across calendar-month boundaries', async () => { + vi.useFakeTimers() + try { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + const renewals: number[] = [] + await subscriptions.put({ + accessKey, + amount: '1000000', + billingAnchor: '2026-01-31T12:03:10.000Z', + chainId: 4217, + currency, + lastChargedPeriod: 0, + lookupKey: subscriptionKey, + periodCount: '30', + periodUnit: 'day', + recipient, + reference: txHash(300), + subscriptionExpires: '2027-01-31T12:03:10.000Z', + subscriptionId: 'sub_elapsed', + timestamp: timestamp(300), + }) + + const server = Mppx_server.create({ + methods: [ + tempo_server.subscription({ + accessKey: async () => accessKey, + activate: async () => { + throw new Error('existing subscription should be reused') + }, + amount: '1', + chainId: 4217, + currency, + periodCount: '30', + periodUnit: 'day', + recipient, + resolve: async () => ({ accessKey, key: subscriptionKey }), + renew: async ({ periodIndex, subscription }) => { + renewals.push(periodIndex) + return { + receipt: receiptFor({ + ...subscription, + lastChargedPeriod: periodIndex, + reference: txHash(301), + timestamp: timestamp(301), + }), + subscription: { + ...subscription, + lastChargedPeriod: periodIndex, + reference: txHash(301), + timestamp: timestamp(301), + }, + } + }, + store, + subscriptionExpires: '2027-01-31T12:03:10.000Z', + }), + ], + realm, + secretKey, + }) + + vi.setSystemTime(new Date('2026-02-28T12:03:10.000Z')) + const beforeElapsedBoundary = await server.tempo.subscription({})( + new Request('https://news.example.com/articles/tempo'), + ) + expect(beforeElapsedBoundary.status).toBe(200) + expect(renewals).toEqual([]) + + vi.setSystemTime(new Date('2026-03-02T12:03:10.000Z')) + const afterElapsedBoundary = await server.tempo.subscription({})( + new Request('https://news.example.com/articles/tempo'), + ) + expect(afterElapsedBoundary.status).toBe(200) + expect(renewals).toEqual([1]) + + const duplicate = await server.tempo.subscription({})( + new Request('https://news.example.com/articles/tempo'), + ) + expect(duplicate.status).toBe(200) + expect(renewals).toEqual([1]) + expect((await subscriptions.get('sub_elapsed'))?.lastChargedPeriod).toBe(1) + } finally { + vi.useRealTimers() + } + }) + + test('renews only the latest elapsed week period when multiple periods passed', async () => { + vi.useFakeTimers() + try { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + const renewals: number[] = [] + await subscriptions.put({ + amount: '1000000', + billingAnchor: '2026-01-01T00:00:00.000Z', + chainId: 4217, + currency, + lastChargedPeriod: 0, + lookupKey: subscriptionKey, + periodCount: '2', + periodUnit: 'week', + recipient, + reference: txHash(400), + subscriptionExpires: '2027-01-01T00:00:00.000Z', + subscriptionId: 'sub_weekly', + timestamp: timestamp(400), + }) + + const server = Mppx_server.create({ + methods: [ + tempo_server.subscription({ + accessKey: async () => accessKey, + activate: async () => { + throw new Error('existing subscription should be reused') + }, + amount: '1', + chainId: 4217, + currency, + periodCount: '2', + periodUnit: 'week', + recipient, + resolve: async () => ({ key: subscriptionKey }), + renew: async ({ periodIndex, subscription }) => { + renewals.push(periodIndex) + return { + receipt: receiptFor({ + ...subscription, + lastChargedPeriod: periodIndex, + reference: txHash(401), + timestamp: timestamp(401), + }), + subscription: { + ...subscription, + lastChargedPeriod: periodIndex, + reference: txHash(401), + timestamp: timestamp(401), + }, + } + }, + store, + subscriptionExpires: '2027-01-01T00:00:00.000Z', + }), + ], + realm, + secretKey, + }) + + vi.setSystemTime(new Date('2026-01-29T00:00:00.000Z')) + const result = await server.tempo.subscription({})( + new Request('https://news.example.com/articles/tempo'), + ) + expect(result.status).toBe(200) + expect(renewals).toEqual([2]) + + const duplicate = await server.tempo.subscription({})( + new Request('https://news.example.com/articles/tempo'), + ) + expect(duplicate.status).toBe(200) + expect(renewals).toEqual([2]) + expect((await subscriptions.get('sub_weekly'))?.lastChargedPeriod).toBe(2) + } finally { + vi.useRealTimers() + } + }) + + test('rejects calendar-month subscription periods for Tempo', async () => { + const server = Mppx_server.create({ + methods: [ + tempo_server.subscription({ + activate: async () => { + throw new Error('month period should not activate') + }, + amount: '1', + chainId: 4217, + currency, + periodCount: '1', + periodUnit: 'month' as never, + recipient, + resolve: async () => ({ accessKey, key: subscriptionKey }), + store: Store.memory(), + subscriptionExpires, + }), + ], + realm, + secretKey, + }) + + expect(() => server.tempo.subscription({})).toThrow() + }) + + test('falls back to activation when an existing subscription is expired or revoked', async () => { + for (const state of ['expired', 'revoked'] as const) { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + await subscriptions.put({ + accessKey, + amount: '1000000', + billingAnchor: '2026-01-01T00:00:00.000Z', + chainId: 4217, + currency, + lastChargedPeriod: 0, + lookupKey: subscriptionKey, + periodCount, + periodUnit, + recipient, + reference: txHash(500), + subscriptionExpires: + state === 'expired' ? '2020-01-01T00:00:00.000Z' : '2027-01-01T00:00:00.000Z', + subscriptionId: `sub_${state}`, + timestamp: timestamp(500), + ...(state === 'revoked' ? { revokedAt: timestamp(501) } : {}), + }) + + const server = Mppx_server.create({ + methods: [ + tempo_server.subscription({ + activate: async () => { + throw new Error('expired and revoked subscriptions should require a new credential') + }, + amount: '1', + chainId: 4217, + currency, + periodCount, + periodUnit, + recipient, + resolve: async () => ({ accessKey, key: subscriptionKey }), + renew: async () => { + throw new Error('inactive subscriptions should not renew') + }, + store, + subscriptionExpires, + }), + ], + realm, + secretKey, + }) + + const result = await server.tempo.subscription({})( + new Request('https://news.example.com/articles/tempo'), + ) + expect(result.status).toBe(402) + expect((await subscriptions.getByKey(subscriptionKey))?.subscriptionId).toBe(`sub_${state}`) + } + }) + + test('clears in-flight renewal state after a failed renewal hook', async () => { + vi.useFakeTimers() + try { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + await subscriptions.put({ + amount: '1000000', + billingAnchor: '2026-01-01T00:00:00.000Z', + chainId: 4217, + currency, + lastChargedPeriod: 0, + lookupKey: subscriptionKey, + periodCount: '1', + periodUnit: 'week', + recipient, + reference: txHash(600), + subscriptionExpires: '2027-01-01T00:00:00.000Z', + subscriptionId: 'sub_failed_renewal', + timestamp: timestamp(600), + }) + + const server = Mppx_server.create({ + methods: [ + tempo_server.subscription({ + activate: async () => { + throw new Error('existing subscription should be reused') + }, + amount: '1', + chainId: 4217, + currency, + periodCount: '1', + periodUnit: 'week', + recipient, + resolve: async () => ({ accessKey, key: subscriptionKey }), + renew: async () => { + throw new Error('renewal failed') + }, + store, + subscriptionExpires: '2027-01-01T00:00:00.000Z', + }), + ], + realm, + secretKey, + }) + + vi.setSystemTime(new Date('2026-01-15T00:00:00.000Z')) + const result = await server.tempo.subscription({})( + new Request('https://news.example.com/articles/tempo'), + ) + expect(result.status).toBe(402) + + const failed = await subscriptions.get('sub_failed_renewal') + expect(failed?.inFlightPeriod).toBe(undefined) + expect(failed?.inFlightReference).toBe(undefined) + expect(failed?.lastChargedPeriod).toBe(0) + } finally { + vi.useRealTimers() + } + }) +}) 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.test.ts b/src/tempo/client/Subscription.test.ts new file mode 100644 index 00000000..09b975a0 --- /dev/null +++ b/src/tempo/client/Subscription.test.ts @@ -0,0 +1,131 @@ +import { Challenge, Credential } from 'mppx' +import { KeyAuthorization } from 'ox/tempo' +import { privateKeyToAccount } from 'viem/accounts' +import { describe, expect, test } from 'vp/test' + +import * as Methods from '../Methods.js' +import { signSubscriptionKeyAuthorization } from '../subscription/KeyAuthorization.js' +import type { SubscriptionAccessKey } from '../subscription/Types.js' +import { subscription } from './Subscription.js' + +const chainId = 4217 +const currency = '0x20c0000000000000000000000000000000000001' +const recipient = '0x1234567890abcdef1234567890abcdef12345678' +const selectedAccount = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000001', +) +const accessAccount = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000002', +) +const otherRootAccount = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000003', +) +const accessKey = { + accessKeyAddress: accessAccount.address, + keyType: 'secp256k1', +} as const satisfies SubscriptionAccessKey + +type SubscriptionRequest = ReturnType + +function secondsFromNow(milliseconds: number) { + return new Date(Math.ceil((Date.now() + milliseconds) / 1_000) * 1_000).toISOString() +} + +function createChallenge( + overrides: Partial[0]> = {}, +): Challenge.Challenge { + const request = Methods.subscription.schema.request.parse({ + accessKey, + amount: '1', + chainId, + currency, + decimals: 6, + periodCount: '1', + periodUnit: 'day', + recipient, + subscriptionExpires: secondsFromNow(86_400_000), + ...overrides, + }) + return Challenge.from({ + id: 'test-challenge-id', + intent: 'subscription', + method: 'tempo', + realm: 'api.example.com', + request, + }) as Challenge.Challenge +} + +describe('tempo.subscription client', () => { + test('uses Tempo testnet as the default subscription chain', async () => { + const challenge = createChallenge({ chainId: undefined }) + const method = subscription({ + account: selectedAccount, + }) + + const credential = Credential.deserialize( + await method.createCredential({ challenge, context: {} }), + ) + const payload = Methods.subscription.schema.credential.payload.parse(credential.payload) + const authorization = KeyAuthorization.deserialize(payload.signature as `0x${string}`) + + expect(authorization.chainId).toBe(42431n) + }) + + test('can reject subscription expiry from custom request validation', async () => { + const challenge = createChallenge({ + subscriptionExpires: secondsFromNow(2 * 86_400_000), + }) + const method = subscription({ + account: selectedAccount, + validateRequest: (request) => { + const maxExpiry = Date.now() + 86_400_000 + if (new Date(request.subscriptionExpires).getTime() > maxExpiry) { + throw new Error('subscription expiry too late') + } + }, + }) + + await expect(method.createCredential({ challenge, context: {} })).rejects.toThrow( + 'subscription expiry too late', + ) + }) + + test('runs custom request validation before authorizing the access key', async () => { + const challenge = createChallenge() + const method = subscription({ + account: selectedAccount, + validateRequest: () => { + throw new Error('unexpected subscription request') + }, + }) + + await expect(method.createCredential({ challenge, context: {} })).rejects.toThrow( + 'unexpected subscription request', + ) + }) + + test('rejects key authorizations signed by a different account', async () => { + const challenge = createChallenge() + const keyAuthorization = await signSubscriptionKeyAuthorization({ + accessKey, + account: otherRootAccount, + chainId, + request: challenge.request, + }) + if (!keyAuthorization) throw new Error('expected key authorization') + + const method = subscription({ + account: selectedAccount.address, + getClient: async () => + ({ + request: async () => ({ + keyAuthorization: KeyAuthorization.toRpc(keyAuthorization), + }), + }) as never, + }) + + await expect(method.createCredential({ challenge, context: {} })).rejects.toThrow( + 'keyAuthorization signer does not match the selected account', + ) + }) +}) diff --git a/src/tempo/client/Subscription.ts b/src/tempo/client/Subscription.ts new file mode 100644 index 00000000..09a87955 --- /dev/null +++ b/src/tempo/client/Subscription.ts @@ -0,0 +1,155 @@ +import { KeyAuthorization } from 'ox/tempo' +import { isAddressEqual, type Address } from 'viem' +import { tempo as tempo_chain } from 'viem/chains' + +import * as Credential from '../../Credential.js' +import type { MaybePromise } from '../../internal/types.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 { + getSubscriptionRpcAllowedCalls, + signSubscriptionKeyAuthorization, + toSubscriptionExpiryDate, + toSubscriptionExpirySeconds, + toSubscriptionPeriodSeconds, + verifySubscriptionKeyAuthorization, +} from '../subscription/KeyAuthorization.js' +import type { SubscriptionAccessKey } from '../subscription/Types.js' + +type SubscriptionRequest = ReturnType + +/** Context accepted by the Tempo subscription client method. */ +export const subscriptionContextSchema = z.object({ + accessKey: z.optional(z.custom()), + account: z.optional(z.custom()), +}) + +/** Runtime context for creating a Tempo subscription credential. */ +export type SubscriptionContext = z.infer + +/** Creates a Tempo subscription client method. */ +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.testnet + const client = await getClient({ chainId }) + const account = getAccount(client, context) + const accessKey = + context?.accessKey ?? parameters.accessKey ?? challenge.request.methodDetails?.accessKey + if (!accessKey) { + throw new Error( + 'No `accessKey` provided. The subscription challenge must include `accessKey`, or the client must pass one to parameters/context.', + ) + } + + assertSubscriptionRequestRepresentable(challenge.request) + await parameters.validateRequest?.(challenge.request) + + const keyAuthorization = await authorizeAccessKey(client, { + accessKey, + account, + chainId, + request: challenge.request, + } as never) + + const verified = verifySubscriptionKeyAuthorization({ + accessKey, + chainId, + payload: { + signature: KeyAuthorization.serialize(keyAuthorization as never), + type: 'keyAuthorization', + }, + request: challenge.request, + }) + if (!isAddressEqual(verified.source.address, account.address)) { + throw new Error('keyAuthorization signer does not match the selected account') + } + + return Credential.serialize({ + challenge, + payload: { + signature: KeyAuthorization.serialize(keyAuthorization as never), + type: 'keyAuthorization', + }, + source: `did:pkh:eip155:${chainId}:${account.address.toLowerCase()}`, + }) + }, + }) +} + +async function authorizeAccessKey( + client: Awaited>>, + parameters: { + accessKey: SubscriptionAccessKey + account: Account.Account + chainId: number + request: Pick< + SubscriptionRequest, + 'amount' | 'currency' | 'periodCount' | 'periodUnit' | 'recipient' | 'subscriptionExpires' + > + }, +) { + const { accessKey, account, chainId, request } = parameters + + const local = await signSubscriptionKeyAuthorization({ + accessKey, + account, + chainId, + request, + }) + if (local) return local + + const result = (await client.request({ + method: 'wallet_authorizeAccessKey', + params: [ + { + address: accessKey.accessKeyAddress, + allowedCalls: getSubscriptionRpcAllowedCalls(request), + expiry: toSubscriptionExpirySeconds(toSubscriptionExpiryDate(request.subscriptionExpires)), + keyType: accessKey.keyType, + limits: [ + { + token: request.currency as Address, + limit: BigInt(request.amount), + period: toSubscriptionPeriodSeconds(request), + }, + ], + }, + ], + } as never)) as { + keyAuthorization: Parameters[0] + } + + return KeyAuthorization.fromRpc(result.keyAuthorization) +} + +function assertSubscriptionRequestRepresentable(request: SubscriptionRequest) { + toSubscriptionPeriodSeconds(request) + toSubscriptionExpirySeconds(toSubscriptionExpiryDate(request.subscriptionExpires)) +} + +export declare namespace subscription { + /** Parameters for creating a Tempo subscription credential. */ + type Parameters = Account.getResolver.Parameters & + Client.getResolver.Parameters & { + accessKey?: SubscriptionAccessKey | undefined + validateRequest?: + | (( + request: ReturnType, + ) => MaybePromise) + | 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..d8ab70a4 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 { renew as renewSubscription_, subscription as subscription_ } from './Subscription.js' /** * Creates both Tempo `charge` and `session` methods from shared parameters. @@ -28,6 +29,10 @@ 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_ + /** Renews an overdue Tempo subscription outside of the HTTP request path. */ + export const renewSubscription = renewSubscription_ /** 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..d38dbe18 --- /dev/null +++ b/src/tempo/server/Subscription.test.ts @@ -0,0 +1,1116 @@ +import { Challenge, Credential, Receipt } from 'mppx' +import { Mppx } from 'mppx/server' +import { KeyAuthorization } from 'ox/tempo' +import { createClient, custom } from 'viem' +import { privateKeyToAccount } from 'viem/accounts' +import { tempo as tempo_chain } from 'viem/chains' +import { describe, expect, test } from 'vp/test' + +import * as Store from '../../Store.js' +import * as Methods from '../Methods.js' +import { signSubscriptionKeyAuthorization } from '../subscription/KeyAuthorization.js' +import * as SubscriptionStore from '../subscription/Store.js' +import type { SubscriptionAccessKey } from '../subscription/Types.js' +import type { SubscriptionRecord } from '../subscription/Types.js' +import { renew, subscription } from './Subscription.js' + +const realm = 'api.example.com' +const secretKey = 'test-secret-key' +const activeBillingAnchor = new Date(Math.floor(Date.now() / 1_000) * 1_000).toISOString() +const activeSubscriptionExpires = new Date( + Math.ceil((Date.now() + 365 * 24 * 60 * 60 * 1_000) / 1_000) * 1_000, +).toISOString() +const chainId = 4217 +const subscriptionDefaultChainId = 42431 +const subscriptionAmount = '10' +const subscriptionCurrency = '0x20c0000000000000000000000000000000000001' +const subscriptionKey = 'user-1:plan:pro' +const subscriptionPeriodCount = '1' +const subscriptionPeriodUnit = 'day' +const subscriptionPeriodMilliseconds = 86_400_000 +const subscriptionRecipient = '0x1234567890abcdef1234567890abcdef12345678' +const rootAccount = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000001', +) +const accessAccount = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000002', +) +const otherAccessAccount = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000003', +) +const accessKey = { + accessKeyAddress: accessAccount.address, + keyType: 'secp256k1', +} as const satisfies SubscriptionAccessKey +const hashActivate = `0x${'a'.repeat(64)}` +const hashRenewed = `0x${'b'.repeat(64)}` +const hashStale = `0x${'c'.repeat(64)}` +const hashBackground = `0x${'d'.repeat(64)}` +const hashOld = `0x${'e'.repeat(64)}` + +function createReceipt(subscriptionId: string, reference = hashActivate) { + 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, + currency: subscriptionCurrency, + lastChargedPeriod: 0, + lookupKey: subscriptionKey, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + reference: hashActivate, + subscriptionExpires: activeSubscriptionExpires, + subscriptionId: 'sub_123', + timestamp: '2025-01-01T00:00:00.000Z', + ...overrides, + } +} + +async function createCredential( + challenge: Challenge.Challenge, + source = rootAccount.address, + key: SubscriptionAccessKey = accessKey, +) { + const keyAuthorization = await signSubscriptionKeyAuthorization({ + accessKey: key, + account: rootAccount, + chainId, + request: challenge.request as ReturnType, + }) + if (!keyAuthorization) throw new Error('expected key authorization') + return Credential.from({ + challenge, + payload: { + signature: KeyAuthorization.serialize(keyAuthorization), + type: 'keyAuthorization', + }, + source: `did:pkh:eip155:${chainId}:${source.toLowerCase()}`, + }) +} + +function createBillingClient(hashes: readonly string[]) { + const rpcMethods: string[] = [] + let nextHash = 0 + const client = createClient({ + chain: { ...tempo_chain, id: chainId }, + transport: custom({ + async request({ method }) { + rpcMethods.push(method) + if (method === 'eth_chainId') return `0x${chainId.toString(16)}` + if (method === 'eth_call') return '0x' + if (method === 'eth_sendRawTransaction') return hashes[nextHash++] ?? hashActivate + throw new Error(`unexpected rpc method: ${method}`) + }, + }), + }) + return { client, rpcMethods } +} + +describe('tempo.subscription', () => { + test('stores an activated subscription and reuses it on later requests', async () => { + const store = Store.memory() + let activationCount = 0 + const method = subscription({ + activate: async ({ request, resolved }) => { + activationCount += 1 + return { + receipt: createReceipt('sub_123', hashActivate), + subscription: createRecord({ + amount: request.amount, + chainId: request.methodDetails?.chainId, + currency: request.currency, + lookupKey: resolved.key, + periodCount: request.periodCount, + periodUnit: request.periodUnit, + recipient: request.recipient, + reference: hashActivate, + subscriptionExpires: request.subscriptionExpires, + }), + } + }, + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async ({ input }) => { + const key = input.headers.get('X-Subscription-Key') + return key ? { accessKey, key } : null + }, + 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-Subscription-Key': subscriptionKey }, + }), + ) + + expect(challengeResult.status).toBe(402) + if (challengeResult.status !== 402) throw new Error('expected activation challenge') + + const challenge = Challenge.fromResponse(challengeResult.challenge) + const challengeRequest = challenge.request as ReturnType< + typeof Methods.subscription.schema.request.parse + > + expect(challengeRequest.methodDetails?.accessKey).toEqual({ + ...accessKey, + accessKeyAddress: accessKey.accessKeyAddress.toLowerCase(), + }) + const credential = await createCredential(challenge) + + const activated = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { + Authorization: Credential.serialize(credential), + 'X-Subscription-Key': subscriptionKey, + }, + }), + ) + + expect(activated.status).toBe(200) + expect(activationCount).toBe(1) + + const replayed = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { + Authorization: Credential.serialize(credential), + 'X-Subscription-Key': subscriptionKey, + }, + }), + ) + + expect(replayed.status).toBe(402) + expect(activationCount).toBe(1) + + const reused = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { + 'X-Subscription-Key': subscriptionKey, + }, + }), + ) + + 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('automatically creates an access key and submits the activation payment', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + const { client, rpcMethods } = createBillingClient([hashActivate]) + const method = subscription({ + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + getClient: async () => client, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + waitForConfirmation: false, + }) + + 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 activation challenge') + + const challenge = Challenge.fromResponse(challengeResult.challenge) + const challengeRequest = challenge.request as ReturnType< + typeof Methods.subscription.schema.request.parse + > + const generatedAccessKey = challengeRequest.methodDetails?.accessKey + expect(generatedAccessKey?.keyType).toBe('secp256k1') + if (!generatedAccessKey) throw new Error('expected generated access key') + + const credential = await createCredential(challenge, rootAccount.address, generatedAccessKey) + 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.reference).toBe(hashActivate) + expect(receipt.subscriptionId).toMatch(/^[A-Za-z0-9_-]+$/) + expect(rpcMethods.filter((method) => method === 'eth_sendRawTransaction')).toHaveLength(1) + + const record = await subscriptions.getByKey(subscriptionKey) + expect(record?.accessKey).toEqual(generatedAccessKey) + expect(record?.keyAuthorization).toBe(credential.payload.signature) + expect(record?.payer?.address.toLowerCase()).toBe(rootAccount.address.toLowerCase()) + expect(record?.lastChargedPeriod).toBe(0) + + const reused = await mppx.tempo.subscription({})(new Request('https://example.com/resource')) + expect(reused.status).toBe(200) + expect(rpcMethods.filter((method) => method === 'eth_sendRawTransaction')).toHaveLength(1) + }) + + test('automatically renews overdue subscriptions on the request path', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + const { client, rpcMethods } = createBillingClient([hashActivate, hashRenewed]) + const method = subscription({ + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + getClient: async () => client, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + waitForConfirmation: false, + }) + + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const challengeResult = await mppx.tempo.subscription({})( + new Request('https://example.com/resource'), + ) + if (challengeResult.status !== 402) throw new Error('expected activation challenge') + const challenge = Challenge.fromResponse(challengeResult.challenge) + const accessKey = ( + challenge.request as ReturnType + ).methodDetails?.accessKey + if (!accessKey) throw new Error('expected generated access key') + const credential = await createCredential(challenge, rootAccount.address, accessKey) + const activated = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + expect(activated.status).toBe(200) + + const record = await subscriptions.getByKey(subscriptionKey) + if (!record) throw new Error('expected subscription record') + await subscriptions.put({ + ...record, + billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(), + lastChargedPeriod: 0, + reference: hashStale, + }) + + const renewed = await mppx.tempo.subscription({})(new Request('https://example.com/resource')) + expect(renewed.status).toBe(200) + if (renewed.status !== 200) throw new Error('expected renewal') + + const receipt = Receipt.fromResponse(renewed.withReceipt(new Response('OK'))) + expect(receipt.reference).toBe(hashRenewed) + expect(rpcMethods.filter((method) => method === 'eth_sendRawTransaction')).toHaveLength(2) + expect((await subscriptions.get(record.subscriptionId))?.lastChargedPeriod).toBeGreaterThan(0) + }) + + test('requires an access key before issuing a subscription challenge', async () => { + const method = subscription({ + activate: async () => ({ + receipt: createReceipt('unused'), + subscription: createRecord({ subscriptionId: 'unused' }), + }), + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store: Store.memory(), + subscriptionExpires: activeSubscriptionExpires, + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + + await expect( + mppx.tempo.subscription({})(new Request('https://example.com/resource')), + ).rejects.toThrow('subscription accessKey is missing') + }) + + test('defaults omitted subscription chainId to Tempo testnet', async () => { + const method = subscription({ + accessKey: async () => accessKey, + activate: async () => ({ + receipt: createReceipt('unused'), + subscription: createRecord({ subscriptionId: 'unused' }), + }), + amount: subscriptionAmount, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store: Store.memory(), + subscriptionExpires: activeSubscriptionExpires, + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const challengeResult = await mppx.tempo.subscription({})( + new Request('https://example.com/resource'), + ) + if (challengeResult.status !== 402) throw new Error('expected activation challenge') + + const challenge = Challenge.fromResponse(challengeResult.challenge) + const challengeRequest = challenge.request as ReturnType< + typeof Methods.subscription.schema.request.parse + > + expect(challengeRequest.methodDetails?.chainId).toBe(subscriptionDefaultChainId) + }) + + test('reuses a stored active subscription access key without a resolver callback', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + await subscriptions.put(createRecord({ accessKey, lookupKey: subscriptionKey })) + const method = subscription({ + activate: async () => ({ + receipt: createReceipt('unused'), + subscription: createRecord({ subscriptionId: 'unused' }), + }), + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + + const reused = await mppx.tempo.subscription({})(new Request('https://example.com/resource')) + + expect(reused.status).toBe(200) + }) + + test('serializes concurrent fresh activations for the same lookup key', async () => { + const store = Store.memory() + let activationCount = 0 + let releaseActivation!: () => void + let markActivationStarted!: () => void + const activationStarted = new Promise((resolve) => { + markActivationStarted = resolve + }) + const activationReleased = new Promise((resolve) => { + releaseActivation = resolve + }) + const method = subscription({ + accessKey: async () => accessKey, + activate: async ({ request, resolved }) => { + activationCount += 1 + markActivationStarted() + await activationReleased + return { + receipt: createReceipt('sub_123', hashActivate), + subscription: createRecord({ + amount: request.amount, + chainId: request.methodDetails?.chainId, + currency: request.currency, + lookupKey: resolved.key, + periodCount: request.periodCount, + periodUnit: request.periodUnit, + recipient: request.recipient, + reference: hashActivate, + subscriptionExpires: request.subscriptionExpires, + }), + } + }, + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + }) + + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const firstChallengeResult = await mppx.tempo.subscription({ + expires: '2027-01-01T00:01:00.000Z', + })(new Request('https://example.com/resource')) + const secondChallengeResult = await mppx.tempo.subscription({ + expires: '2027-01-01T00:02:00.000Z', + })(new Request('https://example.com/resource')) + if (firstChallengeResult.status !== 402 || secondChallengeResult.status !== 402) { + throw new Error('expected activation challenges') + } + + const firstChallenge = Challenge.fromResponse(firstChallengeResult.challenge) + const secondChallenge = Challenge.fromResponse(secondChallengeResult.challenge) + expect(firstChallenge.id).not.toBe(secondChallenge.id) + + const firstCredential = await createCredential(firstChallenge) + const secondCredential = await createCredential(secondChallenge) + const firstActivation = mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(firstCredential) }, + }), + ) + await activationStarted + + const secondActivation = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(secondCredential) }, + }), + ) + releaseActivation() + const activated = await firstActivation + + expect(activated.status).toBe(200) + expect(secondActivation.status).toBe(402) + expect(activationCount).toBe(1) + }) + + test('allows retry after a stale failed activation attempt', async () => { + const store = Store.memory() + let activationCount = 0 + const method = subscription({ + accessKey: async () => accessKey, + activationTimeoutMs: 0, + activate: async ({ request, resolved }) => { + activationCount += 1 + if (activationCount === 1) throw new Error('activation failed before charge') + return { + receipt: createReceipt('sub_123', hashActivate), + subscription: createRecord({ + amount: request.amount, + chainId: request.methodDetails?.chainId, + currency: request.currency, + lookupKey: resolved.key, + periodCount: request.periodCount, + periodUnit: request.periodUnit, + recipient: request.recipient, + reference: hashActivate, + subscriptionExpires: request.subscriptionExpires, + }), + } + }, + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + }) + + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const firstChallengeResult = await mppx.tempo.subscription({ + expires: '2027-01-01T00:03:00.000Z', + })(new Request('https://example.com/resource')) + const secondChallengeResult = await mppx.tempo.subscription({ + expires: '2027-01-01T00:04:00.000Z', + })(new Request('https://example.com/resource')) + if (firstChallengeResult.status !== 402 || secondChallengeResult.status !== 402) { + throw new Error('expected activation challenges') + } + + const firstCredential = await createCredential( + Challenge.fromResponse(firstChallengeResult.challenge), + ) + const firstRejected = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(firstCredential) }, + }), + ) + expect(firstRejected.status).toBe(402) + + const secondCredential = await createCredential( + Challenge.fromResponse(secondChallengeResult.challenge), + ) + const retried = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(secondCredential) }, + }), + ) + + expect(retried.status).toBe(200) + expect(activationCount).toBe(2) + }) + + test('new activation replaces the previous subscription for the same lookup key', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + + // Seed an expired subscription so authorize() falls through to a new challenge. + const expiredDate = new Date(Date.now() - 1_000).toISOString() + await subscriptions.put( + createRecord({ + lookupKey: subscriptionKey, + subscriptionId: 'sub_old', + reference: hashOld, + subscriptionExpires: expiredDate, + }), + ) + + const method = subscription({ + accessKey: async () => accessKey, + activate: async ({ request, resolved }) => ({ + receipt: createReceipt('sub_new', hashActivate), + subscription: createRecord({ + amount: request.amount, + chainId: request.methodDetails?.chainId, + currency: request.currency, + lookupKey: resolved.key, + periodCount: request.periodCount, + periodUnit: request.periodUnit, + recipient: request.recipient, + reference: hashActivate, + subscriptionExpires: request.subscriptionExpires, + subscriptionId: 'sub_new', + }), + }), + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + 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 = await createCredential(challenge) + + const activated = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { + Authorization: Credential.serialize(credential), + 'X-Subscription-Key': subscriptionKey, + }, + }), + ) + 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') + + const current = await subscriptions.getByKey(subscriptionKey) + expect(current?.subscriptionId).toBe('sub_new') + }) + + test('rejects activation when the dynamic access key does not match the credential', async () => { + const store = Store.memory() + const activateCalls: unknown[] = [] + const method = subscription({ + accessKey: async () => ({ + accessKeyAddress: accessAccount.address, + keyType: 'p256', + }), + activate: async (parameters) => { + activateCalls.push(parameters) + return { + receipt: createReceipt('sub_unused'), + subscription: createRecord({ subscriptionId: 'sub_unused' }), + } + }, + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const challengeResult = await mppx.tempo.subscription({})( + new Request('https://example.com/resource'), + ) + if (challengeResult.status !== 402) throw new Error('expected activation challenge') + + const challenge = Challenge.fromResponse(challengeResult.challenge) + const credential = await createCredential(challenge) + const rejected = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(rejected.status).toBe(402) + expect(activateCalls.length).toBe(0) + }) + + test('rejects activation settlements that do not match the challenged request', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + const method = subscription({ + accessKey: async () => accessKey, + activate: async ({ request, resolved }) => ({ + receipt: createReceipt('sub_bad', hashActivate), + subscription: createRecord({ + amount: String(BigInt(request.amount) + 1n), + chainId: request.methodDetails?.chainId, + currency: request.currency, + lookupKey: resolved.key, + periodCount: request.periodCount, + periodUnit: request.periodUnit, + recipient: request.recipient, + reference: hashActivate, + subscriptionExpires: request.subscriptionExpires, + subscriptionId: 'sub_bad', + }), + }), + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const challengeResult = await mppx.tempo.subscription({})( + new Request('https://example.com/resource'), + ) + if (challengeResult.status !== 402) throw new Error('expected activation challenge') + + const challenge = Challenge.fromResponse(challengeResult.challenge) + const credential = await createCredential(challenge) + const rejected = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(rejected.status).toBe(402) + expect(await subscriptions.getByKey(subscriptionKey)).toBe(null) + }) + + test('rejects activation settlements with a mismatched chainId', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + const method = subscription({ + accessKey: async () => accessKey, + activate: async ({ request, resolved }) => ({ + receipt: createReceipt('sub_bad', hashActivate), + subscription: createRecord({ + amount: request.amount, + chainId: chainId + 1, + currency: request.currency, + lookupKey: resolved.key, + periodCount: request.periodCount, + periodUnit: request.periodUnit, + recipient: request.recipient, + reference: hashActivate, + subscriptionExpires: request.subscriptionExpires, + subscriptionId: 'sub_bad', + }), + }), + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const challengeResult = await mppx.tempo.subscription({})( + new Request('https://example.com/resource'), + ) + if (challengeResult.status !== 402) throw new Error('expected activation challenge') + + const challenge = Challenge.fromResponse(challengeResult.challenge) + const credential = await createCredential(challenge) + const rejected = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(rejected.status).toBe(402) + expect(await subscriptions.getByKey(subscriptionKey)).toBe(null) + }) + + test('rejects activation settlements with a mismatched externalId', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + const method = subscription({ + accessKey: async () => accessKey, + activate: async ({ request, resolved }) => ({ + receipt: createReceipt('sub_bad', hashActivate), + subscription: createRecord({ + amount: request.amount, + chainId: request.methodDetails?.chainId, + currency: request.currency, + externalId: 'external_2', + lookupKey: resolved.key, + periodCount: request.periodCount, + periodUnit: request.periodUnit, + recipient: request.recipient, + reference: hashActivate, + subscriptionExpires: request.subscriptionExpires, + subscriptionId: 'sub_bad', + }), + }), + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + externalId: 'external_1', + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const challengeResult = await mppx.tempo.subscription({})( + new Request('https://example.com/resource'), + ) + if (challengeResult.status !== 402) throw new Error('expected activation challenge') + + const challenge = Challenge.fromResponse(challengeResult.challenge) + const credential = await createCredential(challenge) + const rejected = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(rejected.status).toBe(402) + expect(await subscriptions.getByKey(subscriptionKey)).toBe(null) + }) + + test('rejects credentials when the current request externalId differs from the challenge', async () => { + const store = Store.memory() + let activationCount = 0 + const method = subscription({ + accessKey: async () => accessKey, + activate: async ({ request, resolved }) => { + activationCount += 1 + return { + receipt: createReceipt('sub_unused'), + subscription: createRecord({ + amount: request.amount, + chainId: request.methodDetails?.chainId, + currency: request.currency, + externalId: request.externalId, + lookupKey: resolved.key, + periodCount: request.periodCount, + periodUnit: request.periodUnit, + recipient: request.recipient, + subscriptionExpires: request.subscriptionExpires, + subscriptionId: 'sub_unused', + }), + } + }, + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const challengeResult = await mppx.tempo.subscription({ externalId: 'external_1' })( + new Request('https://example.com/resource'), + ) + if (challengeResult.status !== 402) throw new Error('expected activation challenge') + + const credential = await createCredential(Challenge.fromResponse(challengeResult.challenge)) + const rejected = await mppx.tempo.subscription({ externalId: 'external_2' })( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(rejected.status).toBe(402) + expect(activationCount).toBe(0) + }) + + test('rejects credentials whose declared source does not match the key authorization signer', async () => { + const store = Store.memory() + const activateCalls: unknown[] = [] + const method = subscription({ + accessKey: async () => accessKey, + activate: async (parameters) => { + activateCalls.push(parameters) + return { + receipt: createReceipt('sub_unused'), + subscription: createRecord({ subscriptionId: 'sub_unused' }), + } + }, + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const challengeResult = await mppx.tempo.subscription({})( + new Request('https://example.com/resource'), + ) + if (challengeResult.status !== 402) throw new Error('expected activation challenge') + + const challenge = Challenge.fromResponse(challengeResult.challenge) + const credential = await createCredential(challenge, otherAccessAccount.address) + const rejected = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(rejected.status).toBe(402) + expect(activateCalls.length).toBe(0) + }) + + test('renews an overdue matching subscription before falling back to 402', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + const renewCalls: number[] = [] + const renewalReferences: string[] = [] + const method = subscription({ + accessKey: async () => accessKey, + activate: async () => ({ + receipt: createReceipt('unused'), + subscription: createRecord({ subscriptionId: 'unused' }), + }), + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + renew: async ({ inFlightReference, periodIndex, subscription }) => { + renewCalls.push(periodIndex) + renewalReferences.push(inFlightReference) + expect(subscription.inFlightReference).toBe(inFlightReference) + return { + receipt: createReceipt(subscription.subscriptionId, hashRenewed), + subscription: { + ...subscription, + lastChargedPeriod: periodIndex, + reference: hashRenewed, + }, + } + }, + store, + subscriptionExpires: activeSubscriptionExpires, + }) + + await subscriptions.put( + createRecord({ + billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(), + lastChargedPeriod: 0, + lookupKey: subscriptionKey, + reference: hashStale, + subscriptionId: 'sub_due', + }), + ) + + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const result = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { 'X-Subscription-Key': subscriptionKey }, + }), + ) + + expect(result.status).toBe(200) + expect(renewCalls.length).toBe(1) + expect(renewCalls[0]).toBeGreaterThan(0) + expect(renewalReferences[0]).toBe(`renewal:sub_due:${renewCalls[0]}`) + if (result.status !== 200) throw new Error('expected renewal success') + + const receipt = Receipt.fromResponse(result.withReceipt(new Response('OK'))) + expect(receipt.reference).toBe(hashRenewed) + expect(receipt.subscriptionId).toBe('sub_due') + expect((await subscriptions.get('sub_due'))?.inFlightReference).toBe(undefined) + }) + + test('rejects renewals that change the active subscriptionId', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + const method = subscription({ + accessKey: async () => accessKey, + activate: async () => ({ + receipt: createReceipt('unused'), + subscription: createRecord({ subscriptionId: 'unused' }), + }), + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + renew: async ({ periodIndex, subscription }) => { + const record = { + ...subscription, + lastChargedPeriod: periodIndex, + reference: hashRenewed, + subscriptionId: 'sub_other', + } + return { + receipt: createReceipt(record.subscriptionId, hashRenewed), + subscription: record, + } + }, + store, + subscriptionExpires: activeSubscriptionExpires, + }) + + await subscriptions.put( + createRecord({ + billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(), + lastChargedPeriod: 0, + lookupKey: subscriptionKey, + reference: hashStale, + subscriptionId: 'sub_due', + }), + ) + + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const rejected = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { 'X-Subscription-Key': subscriptionKey }, + }), + ) + + expect(rejected.status).toBe(402) + expect((await subscriptions.getByKey(subscriptionKey))?.subscriptionId).toBe('sub_due') + expect((await subscriptions.get('sub_due'))?.lastChargedPeriod).toBe(0) + }) + + test('charges an overdue subscription outside the request path', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + const renewCalls: number[] = [] + + await subscriptions.put( + createRecord({ + billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(), + lastChargedPeriod: 0, + lookupKey: subscriptionKey, + reference: hashStale, + subscriptionId: 'sub_background', + }), + ) + + const result = await renew({ + renew: async ({ periodIndex, subscription }) => { + renewCalls.push(periodIndex) + return { + receipt: createReceipt(subscription.subscriptionId, hashBackground), + subscription: { + ...subscription, + lastChargedPeriod: periodIndex, + reference: hashBackground, + }, + } + }, + store, + subscriptionId: 'sub_background', + }) + + expect(result?.receipt.reference).toBe(hashBackground) + expect(renewCalls.length).toBe(1) + expect((await subscriptions.get('sub_background'))?.reference).toBe(hashBackground) + }) + + test('automatically renews an overdue subscription outside the request path', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + const { client, rpcMethods } = createBillingClient([hashActivate, hashBackground]) + const method = subscription({ + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + getClient: async () => client, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + waitForConfirmation: false, + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const challengeResult = await mppx.tempo.subscription({})( + new Request('https://example.com/resource'), + ) + if (challengeResult.status !== 402) throw new Error('expected activation challenge') + const challenge = Challenge.fromResponse(challengeResult.challenge) + const accessKey = ( + challenge.request as ReturnType + ).methodDetails?.accessKey + if (!accessKey) throw new Error('expected generated access key') + const credential = await createCredential(challenge, rootAccount.address, accessKey) + const activated = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + expect(activated.status).toBe(200) + + const record = await subscriptions.getByKey(subscriptionKey) + if (!record) throw new Error('expected subscription record') + await subscriptions.put({ + ...record, + billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(), + lastChargedPeriod: 0, + reference: hashStale, + }) + + const result = await renew({ + getClient: async () => client, + store, + subscriptionId: record.subscriptionId, + waitForConfirmation: false, + }) + + expect(result?.receipt.reference).toBe(hashBackground) + expect(rpcMethods.filter((method) => method === 'eth_sendRawTransaction')).toHaveLength(2) + expect((await subscriptions.get(record.subscriptionId))?.reference).toBe(hashBackground) + }) +}) diff --git a/src/tempo/server/Subscription.ts b/src/tempo/server/Subscription.ts new file mode 100644 index 00000000..4ce38287 --- /dev/null +++ b/src/tempo/server/Subscription.ts @@ -0,0 +1,969 @@ +import { Base64 } from 'ox' +import { KeyAuthorization } from 'ox/tempo' +import { encodeFunctionData, isAddressEqual, type Address, type Client as ViemClient } from 'viem' +import { + call as viem_call, + sendRawTransaction, + sendRawTransactionSync, + signTransaction, +} from 'viem/actions' +import { tempo as tempo_chain } from 'viem/chains' +import { Abis, Account as TempoAccount, Transaction } from 'viem/tempo' + +import { VerificationFailedError } from '../../Errors.js' +import type { LooseOmit, MaybePromise, NoExtraKeys } from '../../internal/types.js' +import * as Method from '../../Method.js' +import * as Store from '../../Store.js' +import type * as Client from '../../viem/Client.js' +import * as ClientResolver from '../../viem/Client.js' +import * as Attribution from '../Attribution.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 { + assertSubscriptionTiming, + toSubscriptionPeriodSeconds, + verifySubscriptionKeyAuthorization, +} from '../subscription/KeyAuthorization.js' +import * as SubscriptionReceipt from '../subscription/Receipt.js' +import * as SubscriptionStore from '../subscription/Store.js' +import type { + SubscriptionAccessKey, + SubscriptionCredentialPayload, + SubscriptionLookup, + SubscriptionPeriodUnit, + SubscriptionRecord, + SubscriptionReceipt as SubscriptionReceiptValue, +} from '../subscription/Types.js' + +type SubscriptionRequest = ReturnType + +/** + * Creates a Tempo subscription method for recurring TIP-20 token payments. + * + * The method handles activation, request-path reuse, and optional lazy renewals. + */ +export function subscription( + p: NoExtraKeys, +) { + const parameters = p as parameters + const rawStore = (parameters.store ?? Store.memory()) as Store.AtomicStore< + Record + > + if (typeof rawStore.update !== 'function') { + throw new Error('tempo.subscription() requires an atomic store with `update`.') + } + const defaultChainId = parameters.chainId ?? defaults.chainId.testnet + const { + amount, + currency = defaults.resolveCurrency({ chainId: defaultChainId }), + decimals = defaults.decimals, + description, + externalId, + periodCount, + periodUnit, + subscriptionExpires, + waitForConfirmation = true, + } = parameters + + const store = SubscriptionStore.fromStore(rawStore, { + activationTimeoutMs: parameters.activationTimeoutMs, + }) + const { recipient } = Account.resolve(parameters) + const getClient = ClientResolver.getResolver({ + chain: tempo_chain, + getClient: parameters.getClient, + rpcUrl: defaults.rpcUrl, + }) + + type Defaults = subscription.DeriveDefaults + return Method.toServer(Methods.subscription, { + defaults: { + amount, + currency, + decimals, + description, + externalId, + periodCount, + periodUnit, + recipient, + subscriptionExpires, + } as unknown as Defaults, + + async authorize({ input, request }) { + const resolved = await parameters.resolve({ input, request }) + if (!resolved) return undefined + + const subscription = await store.getByKey(resolved.key) + if (!subscription || !isActive(subscription)) return undefined + + const periodIndex = getPeriodIndex(subscription) + if (periodIndex > subscription.lastChargedPeriod) { + const renew = resolveRenewalHandler({ + getClient, + parameters, + store, + subscription, + waitForConfirmation, + }) + if (!renew) return undefined + + const renewal = await settleRenewal({ + expectedLookupKey: resolved.key, + periodIndex, + renew, + request, + store, + subscription, + }) + if (!renewal) return undefined + if (renewal.status === 'charged') return { receipt: renewal.receipt } + + await parameters.hooks?.renewed?.({ + periodIndex, + receipt: renewal.result.receipt, + subscription: renewal.result.subscription, + }) + return { + receipt: renewal.result.receipt, + } + } + + return { + receipt: SubscriptionReceipt.fromRecord(subscription), + } + }, + + async request({ capturedRequest, credential, request }) { + const credentialRequest = credential?.challenge.request as SubscriptionRequest | undefined + const chainId = await (async () => { + if (request.chainId) return request.chainId + if (parameters.chainId) return parameters.chainId + if (credentialRequest?.methodDetails?.chainId) + return credentialRequest.methodDetails.chainId + return defaults.chainId.testnet + })() + const parsedRequest = Methods.subscription.schema.request.parse({ + ...request, + chainId, + }) + const input = capturedRequest + ? new Request(capturedRequest.url, { + headers: capturedRequest.headers, + method: capturedRequest.method, + }) + : new Request('https://subscription.invalid') + const resolved = await parameters.resolve({ input, request: parsedRequest }) + const existing = resolved ? await store.getByKey(resolved.key) : null + const accessKey = + resolved && !credential + ? await resolveChallengeAccessKey({ + existing, + input, + parameters, + request: parsedRequest, + resolved, + store, + }) + : (credentialRequest?.methodDetails?.accessKey ?? parsedRequest.methodDetails?.accessKey) + if (!accessKey) { + throw new VerificationFailedError({ reason: 'subscription accessKey is missing' }) + } + + // Challenges carry the server-generated key in methodDetails so the shared request shape stays spec-compatible. + return { + ...request, + methodDetails: { + ...request.methodDetails, + accessKey, + }, + chainId, + } + }, + + stableBinding(request) { + return subscriptionBinding(request) + }, + + async verify({ credential, envelope, request }) { + const input = envelope + ? new Request(envelope.capturedRequest.url, { + headers: envelope.capturedRequest.headers, + method: envelope.capturedRequest.method, + }) + : new Request('https://subscription.invalid') + const parsedRequest = Methods.subscription.schema.request.parse(request) + assertSubscriptionTiming({ + challengeExpires: credential.challenge.expires, + request: parsedRequest, + }) + const resolved = await parameters.resolve({ input, request: parsedRequest }) + + if (!resolved) { + throw new VerificationFailedError({ reason: 'subscription could not be resolved' }) + } + const challengeRequest = credential.challenge.request as SubscriptionRequest + const accessKey = + challengeRequest.methodDetails?.accessKey ?? + parsedRequest.methodDetails?.accessKey ?? + (await resolveAccessKey({ input, parameters, request: parsedRequest, resolved })) + if (!accessKey) { + throw new VerificationFailedError({ reason: 'subscription accessKey is missing' }) + } + const verified = verifySubscriptionKeyAuthorization({ + accessKey, + chainId: parsedRequest.methodDetails?.chainId ?? defaults.chainId.testnet, + payload: credential.payload as SubscriptionCredentialPayload, + request: parsedRequest, + }) + const declaredSource = credential.source ? Proof.parsePkhSource(credential.source) : null + if ( + declaredSource && + (declaredSource.chainId !== verified.source.chainId || + !isAddressEqual(declaredSource.address, verified.source.address)) + ) { + throw new VerificationFailedError({ reason: 'credential source does not match signature' }) + } + + // Claim the challenge before activation so replayed credentials cannot reach the charge hook. + const activationClaimed = await store.claimActivation(credential.challenge.id) + if (!activationClaimed) { + throw new VerificationFailedError({ + reason: 'subscription credential has already been used', + }) + } + + const existing = await store.getByKey(resolved.key) + if (existing && isActive(existing)) { + return SubscriptionReceipt.fromRecord(existing) + } + + // Distinct challenges can target the same subscription key; serialize activation by key + // before the first-period charge hook so concurrent fresh credentials cannot double-charge. + const activationStarted = await store.beginActivation(resolved.key, credential.challenge.id) + if (activationStarted.status !== 'started') { + throw new VerificationFailedError({ + reason: 'subscription activation is already in flight', + }) + } + + const activation = withSubscriptionAccessKey( + await activateSubscription({ + accessKey, + auto: { + challengeId: credential.challenge.id, + getClient, + keyAuthorization: (credential.payload as SubscriptionCredentialPayload).signature, + realm: credential.challenge.realm, + store, + waitForConfirmation, + }, + credential: credential as typeof credential & { + payload: SubscriptionCredentialPayload + }, + input, + parameters, + request: parsedRequest, + resolved, + source: verified.source, + }), + accessKey, + ) + + validateSubscriptionSettlement(activation, { + expectedLookupKey: resolved.key, + expectedPeriodIndex: 0, + request: parsedRequest, + }) + + const activationCommitted = await store.commitActivation( + activation.subscription, + credential.challenge.id, + ) + if (!activationCommitted) { + throw new VerificationFailedError({ + reason: 'subscription activation claim mismatch', + }) + } + await parameters.hooks?.activated?.({ + receipt: activation.receipt, + subscription: activation.subscription, + }) + return activation.receipt + }, + }) +} + +async function resolveAccessKey(parameters: { + input: Request + parameters: subscription.Parameters + request: SubscriptionRequest + resolved: subscription.ResolvedSubscription +}) { + const { input, parameters: subscriptionParameters, request, resolved } = parameters + return ( + resolved.accessKey ?? + (subscriptionParameters.accessKey + ? await subscriptionParameters.accessKey({ input, request, resolved }) + : undefined) + ) +} + +async function resolveChallengeAccessKey(parameters: { + existing: SubscriptionRecord | null + input: Request + parameters: subscription.Parameters + request: SubscriptionRequest + resolved: subscription.ResolvedSubscription + store: SubscriptionStore.SubscriptionStore +}) { + const { + existing, + input, + parameters: subscriptionParameters, + request, + resolved, + store, + } = parameters + if (!subscriptionParameters.activate) { + // In automatic mode, the SDK owns the server access key so apps can issue + // challenges from only their resolved subscription lookup key. + const accessKey = await store.getOrCreateAccessKey(resolved.key) + return { + accessKeyAddress: accessKey.accessKeyAddress, + keyType: accessKey.keyType, + } satisfies SubscriptionAccessKey + } + // Manual activation keeps the lower-level API: callers can provide the + // access key for new challenges, while active subscriptions reuse the stored key. + return ( + (await resolveAccessKey({ input, parameters: subscriptionParameters, request, resolved })) ?? + (existing && isActive(existing) ? existing.accessKey : undefined) + ) +} + +async function activateSubscription(parameters: { + accessKey: SubscriptionAccessKey + auto: { + challengeId: string + getClient: (parameters: { chainId?: number | undefined }) => MaybePromise + keyAuthorization: `0x${string}` + realm: string + store: SubscriptionStore.SubscriptionStore + waitForConfirmation: boolean + } + credential: { + payload: SubscriptionCredentialPayload + source?: string | undefined + } + input: Request + parameters: subscription.Parameters + request: SubscriptionRequest + resolved: subscription.ResolvedSubscription + source: { address: Address; chainId: number } | null +}) { + const { + accessKey, + auto, + credential, + input, + parameters: subscriptionParameters, + request, + resolved, + source, + } = parameters + if (subscriptionParameters.activate) { + // A custom activate hook owns settlement and record creation. + return subscriptionParameters.activate({ + accessKey, + credential, + input, + request, + resolved, + source, + }) + } + if (!source) { + throw new VerificationFailedError({ reason: 'subscription payer is missing' }) + } + + // Automatic activation bills the first period and persists the recurring + // billing authority needed for request-path and background renewals. + const reference = await submitSubscriptionPayment({ + accessKey, + getClient: auto.getClient, + keyAuthorization: auto.keyAuthorization, + lookupKey: resolved.key, + request, + settlementReference: auto.challengeId, + source, + store: auto.store, + waitForConfirmation: auto.waitForConfirmation, + memoServerId: auto.realm, + }) + const timestamp = new Date().toISOString() + const subscription = { + accessKey, + amount: request.amount, + billingAnchor: timestamp, + chainId: request.methodDetails?.chainId, + currency: request.currency, + externalId: request.externalId, + keyAuthorization: auto.keyAuthorization, + lastChargedPeriod: 0, + lookupKey: resolved.key, + payer: source, + periodCount: request.periodCount, + periodUnit: request.periodUnit, + recipient: request.recipient, + reference, + subscriptionExpires: request.subscriptionExpires, + subscriptionId: createSubscriptionId(), + timestamp, + } satisfies SubscriptionRecord + + return { + receipt: SubscriptionReceipt.createSubscriptionReceipt(subscription), + subscription, + } +} + +async function settleRenewal(parameters: { + expectedLookupKey: string + periodIndex: number + renew: (parameters: { + inFlightReference: string + periodIndex: number + subscription: SubscriptionRecord + }) => Promise + request?: SubscriptionRequest | undefined + store: SubscriptionStore.SubscriptionStore + subscription: SubscriptionRecord +}): Promise< + | { status: 'charged'; receipt: SubscriptionReceiptValue } + | { status: 'renewed'; result: subscription.RenewalResult } + | null +> { + const { expectedLookupKey, periodIndex, renew, request, store, subscription } = parameters + const inFlightReference = renewalReference(subscription.subscriptionId, periodIndex) + const started = await store.beginRenewal( + subscription.subscriptionId, + periodIndex, + inFlightReference, + ) + if (started.status === 'charged') { + return { receipt: SubscriptionReceipt.fromRecord(started.subscription), status: 'charged' } + } + if (started.status !== 'started') return null + + const renewed = withSubscriptionAccessKey( + await renew({ + inFlightReference, + periodIndex, + subscription: started.subscription, + }).catch(async (error) => { + await store.failRenewal(subscription.subscriptionId, periodIndex) + throw error + }), + started.subscription.accessKey, + ) + validateSubscriptionSettlement(renewed, { + expectedLookupKey, + expectedPeriodIndex: periodIndex, + expectedSubscriptionId: subscription.subscriptionId, + request, + }) + const committed = await store.commitRenewal( + subscription.subscriptionId, + renewed.subscription, + periodIndex, + ) + if (!committed) { + throw new VerificationFailedError({ reason: 'subscription renewal claim mismatch' }) + } + return { result: renewed, status: 'renewed' } +} + +function renewalReference(subscriptionId: string, periodIndex: number): string { + // This stable identifier is persisted before the billing hook runs so apps can + // use it as an idempotency/reconciliation key if a renewal crashes mid-flight. + return `renewal:${subscriptionId}:${periodIndex}` +} + +function withSubscriptionAccessKey< + result extends subscription.ActivationResult | subscription.RenewalResult, +>(result: result, accessKey: SubscriptionAccessKey | undefined): result { + if (!accessKey || result.subscription.accessKey) return result + return { + ...result, + subscription: { + ...result.subscription, + accessKey, + }, + } +} + +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 + } + + let periodSeconds: number + try { + periodSeconds = toSubscriptionPeriodSeconds(subscription) + } catch { + 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 validateSubscriptionSettlement( + result: subscription.ActivationResult | subscription.RenewalResult, + options: { + expectedLookupKey: string + expectedPeriodIndex: number + expectedSubscriptionId?: string | undefined + request?: SubscriptionRequest | undefined + }, +) { + const { receipt, subscription } = result + assertSubscriptionReceipt(receipt, subscription) + assertSubscriptionRecord(subscription, options) + + if (options.request) { + assertSubscriptionRequestMatch(subscription, options.request) + } +} + +function assertSubscriptionReceipt( + receipt: SubscriptionReceiptValue, + subscription: SubscriptionRecord, +) { + if (receipt.method !== 'tempo' || receipt.status !== 'success') { + throw new VerificationFailedError({ reason: 'subscription receipt is invalid' }) + } + if (receipt.subscriptionId !== subscription.subscriptionId) { + throw new VerificationFailedError({ reason: 'subscription receipt id mismatch' }) + } + if (receipt.reference !== subscription.reference) { + throw new VerificationFailedError({ reason: 'subscription receipt reference mismatch' }) + } + if (receipt.timestamp !== subscription.timestamp) { + throw new VerificationFailedError({ reason: 'subscription receipt timestamp mismatch' }) + } + assertTransactionHash(receipt.reference, 'subscription reference must be a transaction hash') + assertValidDate(receipt.timestamp, 'subscription receipt timestamp is invalid') +} + +function assertSubscriptionRecord( + subscription: SubscriptionRecord, + options: { + expectedLookupKey: string + expectedPeriodIndex: number + expectedSubscriptionId?: string | undefined + }, +) { + assertBase64Url(subscription.subscriptionId, 'subscriptionId must be base64url') + assertTransactionHash(subscription.reference, 'subscription reference must be a transaction hash') + const billingAnchor = assertValidDate( + subscription.billingAnchor, + 'subscription billingAnchor is invalid', + ) + const subscriptionExpires = assertValidDate( + subscription.subscriptionExpires, + 'subscriptionExpires is invalid', + ) + + assertEqual(subscription.lookupKey, options.expectedLookupKey, { + reason: 'subscription lookupKey does not match the resolved key', + }) + assertEqual(subscription.lastChargedPeriod, options.expectedPeriodIndex, { + reason: 'subscription lastChargedPeriod does not match the settled period', + }) + if (options.expectedSubscriptionId) { + assertEqual(subscription.subscriptionId, options.expectedSubscriptionId, { + reason: 'subscriptionId does not match the active subscription', + }) + } + if (billingAnchor >= subscriptionExpires) { + throw new VerificationFailedError({ + reason: 'subscription billingAnchor must be before subscriptionExpires', + }) + } +} + +function assertSubscriptionRequestMatch( + subscription: SubscriptionRecord, + request: SubscriptionRequest, +) { + const matches = + subscription.amount === request.amount && + subscription.chainId === request.methodDetails?.chainId && + subscription.currency.toLowerCase() === request.currency.toLowerCase() && + subscription.externalId === request.externalId && + subscription.periodCount === request.periodCount && + subscription.periodUnit === request.periodUnit && + subscription.recipient.toLowerCase() === request.recipient.toLowerCase() && + subscription.subscriptionExpires === request.subscriptionExpires + + if (!matches) { + throw new VerificationFailedError({ reason: 'subscription record does not match request' }) + } +} + +function assertBase64Url(value: string, reason: string) { + if (!/^[A-Za-z0-9_-]+$/.test(value)) { + throw new VerificationFailedError({ reason }) + } +} + +function assertTransactionHash(value: string, reason: string) { + if (!/^0x[0-9a-fA-F]{64}$/.test(value)) { + throw new VerificationFailedError({ reason }) + } +} + +function assertValidDate(value: string, reason: string) { + const milliseconds = new Date(value).getTime() + if (!Number.isFinite(milliseconds)) { + throw new VerificationFailedError({ reason }) + } + return milliseconds +} + +function assertEqual(actual: value, expected: value, options: { reason: string }) { + if (actual !== expected) { + throw new VerificationFailedError(options) + } +} + +function subscriptionBinding(request: SubscriptionRequest) { + return { + amount: request.amount, + chainId: request.methodDetails?.chainId, + currency: request.currency, + externalId: request.externalId, + periodCount: request.periodCount, + periodUnit: request.periodUnit, + recipient: request.recipient, + subscriptionExpires: request.subscriptionExpires, + } +} + +function resolveRenewalHandler(parameters: { + getClient: (parameters: { chainId?: number | undefined }) => MaybePromise + parameters: { + renew?: + | ((parameters: { + inFlightReference: string + periodIndex: number + subscription: SubscriptionRecord + }) => Promise) + | undefined + } + store: SubscriptionStore.SubscriptionStore + subscription: SubscriptionRecord + waitForConfirmation: boolean +}): + | ((parameters: { + inFlightReference: string + periodIndex: number + subscription: SubscriptionRecord + }) => Promise) + | undefined { + const { + getClient, + parameters: subscriptionParameters, + store, + subscription, + waitForConfirmation, + } = parameters + if (subscriptionParameters.renew) return subscriptionParameters.renew + if (!subscription.accessKey || !subscription.keyAuthorization || !subscription.payer) + return undefined + return async ({ inFlightReference, periodIndex, subscription }) => { + const reference = await submitSubscriptionPayment({ + accessKey: subscription.accessKey!, + getClient, + keyAuthorization: subscription.keyAuthorization!, + lookupKey: subscription.lookupKey, + memoServerId: subscription.lookupKey, + request: subscription, + settlementReference: inFlightReference, + source: subscription.payer!, + store, + waitForConfirmation, + }) + const record = { + ...subscription, + lastChargedPeriod: periodIndex, + reference, + timestamp: new Date().toISOString(), + } satisfies SubscriptionRecord + return { + receipt: SubscriptionReceipt.createSubscriptionReceipt(record), + subscription: record, + } + } +} + +async function submitSubscriptionPayment(parameters: { + accessKey: SubscriptionAccessKey + getClient: (parameters: { chainId?: number | undefined }) => MaybePromise + keyAuthorization: `0x${string}` + lookupKey: string + memoServerId: string + request: Pick & { + methodDetails?: { chainId?: number | undefined } | undefined + } & { currency: Address | string; recipient: Address | string } + settlementReference: string + source: { address: Address; chainId: number } + store: SubscriptionStore.SubscriptionStore + waitForConfirmation: boolean +}) { + const { + accessKey, + getClient, + keyAuthorization, + lookupKey, + memoServerId, + request, + settlementReference, + source, + store, + waitForConfirmation, + } = parameters + const stored = await store.getAccessKey(lookupKey) + if (!stored) { + throw new VerificationFailedError({ reason: 'subscription access key is missing' }) + } + const rawAccessAccount = TempoAccount.fromSecp256k1(stored.privateKey) + if (!isAddressEqual(rawAccessAccount.address, accessKey.accessKeyAddress)) { + throw new VerificationFailedError({ + reason: 'subscription access key does not match stored key', + }) + } + + const chainId = request.methodDetails?.chainId ?? source.chainId + const client = await getClient({ chainId }) + const account = TempoAccount.fromSecp256k1(stored.privateKey, { + access: source.address, + }) + const memo = Attribution.encode({ + challengeId: settlementReference, + serverId: memoServerId, + }) + const serializedTransaction = await signTransaction(client, { + account, + calls: [ + { + data: encodeFunctionData({ + abi: Abis.tip20, + functionName: 'transferWithMemo', + args: [request.recipient as Address, BigInt(request.amount), memo], + }), + to: request.currency as Address, + }, + ], + chainId, + keyAuthorization: KeyAuthorization.deserialize(keyAuthorization), + } as never) + const transaction = Transaction.deserialize( + serializedTransaction as Transaction.TransactionSerializedTempo, + ) + await viem_call(client, { + ...transaction, + account: transaction.from, + calls: transaction.calls, + } as never) + + if (!waitForConfirmation) { + return sendRawTransaction(client, { + serializedTransaction: serializedTransaction as Transaction.TransactionSerializedTempo, + }) + } + + const receipt = await sendRawTransactionSync(client, { + serializedTransaction: serializedTransaction as Transaction.TransactionSerializedTempo, + }) + if (receipt.status !== 'success') { + throw new VerificationFailedError({ + reason: `subscription transaction reverted: ${receipt.transactionHash}`, + }) + } + return receipt.transactionHash +} + +function createSubscriptionId() { + const bytes = new Uint8Array(18) + globalThis.crypto.getRandomValues(bytes) + return Base64.fromBytes(bytes, { url: true }).replace(/=+$/, '') +} + +/** + * Renews 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 renew(parameters: renew.Parameters): Promise { + const { store: rawStore, waitForConfirmation = true } = parameters + const store = SubscriptionStore.fromStore(rawStore) + const getClient = ClientResolver.getResolver({ + chain: tempo_chain, + getClient: parameters.getClient, + rpcUrl: defaults.rpcUrl, + }) + + 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 renew = resolveRenewalHandler({ + getClient, + parameters, + store, + subscription: record, + waitForConfirmation, + }) + if (!renew) return null + + const renewal = await settleRenewal({ + expectedLookupKey: record.lookupKey, + periodIndex, + renew, + store, + subscription: record, + }) + return renewal?.status === 'renewed' ? renewal.result : null +} + +export declare namespace renew { + /** Parameters for renewing an overdue subscription outside the request path. */ + type Parameters = { + /** The subscription to renew. */ + subscriptionId: string + /** Billing callback — same signature as the `renew` hook on {@link subscription}. */ + renew?: + | ((parameters: { + /** Stable idempotency/reconciliation reference persisted before the renewal hook runs. */ + inFlightReference: string + periodIndex: number + subscription: SubscriptionRecord + }) => Promise) + | undefined + /** Store containing subscription records. */ + store: Store.AtomicStore> + waitForConfirmation?: boolean | undefined + } & Client.getResolver.Parameters + + /** Renewal result returned by {@link renew}. */ + type Result = subscription.RenewalResult +} + +export declare namespace subscription { + /** Request-scoped lookup key used to find the active subscription. */ + type ResolvedSubscription = SubscriptionLookup + + /** Activation result returned after the initial credential is verified. */ + type ActivationResult = { + receipt: SubscriptionReceiptValue + subscription: SubscriptionRecord + } + + /** Renewal result returned when an overdue subscription is charged. */ + type RenewalResult = { + receipt: SubscriptionReceiptValue + subscription: SubscriptionRecord + } + + /** Request defaults supported by the subscription method. */ + type Defaults = LooseOmit< + Method.RequestDefaults, + 'accessKey' | 'recipient' + > + + /** Parameters for configuring a Tempo subscription method. */ + type Parameters = Account.resolve.Parameters & + Client.getResolver.Parameters & { + accessKey?: + | ((parameters: { + input: Request + request: SubscriptionRequest + resolved: ResolvedSubscription + }) => MaybePromise) + | undefined + /** + * Milliseconds before an in-flight activation lock can be replaced. + * Keeps concurrent activation safe while allowing recovery from abandoned attempts. + */ + activationTimeoutMs?: number | undefined + activate?: + | ((parameters: { + accessKey: SubscriptionAccessKey + credential: { + payload: SubscriptionCredentialPayload + source?: string | undefined + } + input: Request + request: SubscriptionRequest + resolved: ResolvedSubscription + source: { address: Address; chainId: number } | null + }) => Promise) + | undefined + hooks?: + | { + activated?: + | ((parameters: { + receipt: SubscriptionReceiptValue + subscription: SubscriptionRecord + }) => MaybePromise) + | undefined + renewed?: + | ((parameters: { + periodIndex: number + receipt: SubscriptionReceiptValue + subscription: SubscriptionRecord + }) => MaybePromise) + | undefined + } + | undefined + periodCount?: string | undefined + periodUnit?: SubscriptionPeriodUnit | undefined + resolve: (parameters: { + input: Request + request: SubscriptionRequest + }) => MaybePromise + renew?: (parameters: { + /** Stable idempotency/reconciliation reference persisted before the renewal hook runs. */ + inFlightReference: string + periodIndex: number + subscription: SubscriptionRecord + }) => Promise + store?: Store.AtomicStore> | undefined + testnet?: boolean | undefined + waitForConfirmation?: boolean | undefined + } & Defaults + + /** Derived defaults after account and chain configuration are applied. */ + type DeriveDefaults = types.DeriveDefaults< + parameters, + Defaults + > & { + decimals: number + } +} diff --git a/src/tempo/server/index.ts b/src/tempo/server/index.ts index 05d96e40..b556c971 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 { renew as renewSubscription, subscription } from './Subscription.js' diff --git a/src/tempo/subscription/KeyAuthorization.test.ts b/src/tempo/subscription/KeyAuthorization.test.ts new file mode 100644 index 00000000..66328a5d --- /dev/null +++ b/src/tempo/subscription/KeyAuthorization.test.ts @@ -0,0 +1,185 @@ +import { KeyAuthorization } from 'ox/tempo' +import { privateKeyToAccount } from 'viem/accounts' +import { describe, expect, test } from 'vp/test' + +import * as Methods from '../Methods.js' +import { + assertSubscriptionTiming, + getSubscriptionRpcAllowedCalls, + getSubscriptionScopes, + signSubscriptionKeyAuthorization, + toSubscriptionExpiryDate, + toSubscriptionExpirySeconds, + toSubscriptionPeriodSeconds, + verifySubscriptionKeyAuthorization, +} from './KeyAuthorization.js' +import type { SubscriptionAccessKey } from './Types.js' + +const secondsPerDay = 86_400 + +const rootAccount = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000001', +) +const accessAccount = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000002', +) +const otherAccessAccount = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000003', +) +const accessKey = { + accessKeyAddress: accessAccount.address, + keyType: 'secp256k1', +} as const satisfies SubscriptionAccessKey +const currency = '0x20c0000000000000000000000000000000000001' +const recipient = '0x1234567890abcdef1234567890abcdef12345678' +const otherRecipient = '0x2222222222222222222222222222222222222222' +const subscriptionExpires = new Date( + Math.ceil((Date.now() + 365 * 24 * 60 * 60 * 1_000) / 1_000) * 1_000, +).toISOString() + +function parseRequest( + overrides: Partial[0]> = {}, +) { + return Methods.subscription.schema.request.parse({ + amount: '10', + chainId: 4217, + currency, + decimals: 6, + periodCount: '1', + periodUnit: 'day', + recipient, + subscriptionExpires, + ...overrides, + }) +} + +async function createPayload(request = parseRequest()) { + const keyAuthorization = await signSubscriptionKeyAuthorization({ + accessKey, + account: rootAccount, + chainId: 4217, + request, + }) + if (!keyAuthorization) throw new Error('expected key authorization') + return { + signature: KeyAuthorization.serialize(keyAuthorization), + type: 'keyAuthorization', + } as const +} + +describe('tempo subscription key authorization', () => { + test('signs and verifies a scoped key authorization', async () => { + const request = parseRequest() + const payload = await createPayload(request) + + const result = verifySubscriptionKeyAuthorization({ + accessKey, + chainId: 4217, + payload, + request, + }) + + expect(result.source.address.toLowerCase()).toBe(rootAccount.address.toLowerCase()) + expect(result.authorization.address.toLowerCase()).toBe( + accessKey.accessKeyAddress.toLowerCase(), + ) + }) + + test('builds wallet allowed calls from the subscription request', () => { + const request = parseRequest() + + expect(getSubscriptionScopes(request)).toMatchObject([ + { address: currency, recipients: [recipient] }, + { address: currency, recipients: [recipient] }, + ]) + expect(getSubscriptionRpcAllowedCalls(request)).toMatchObject([ + { + target: currency, + selectorRules: [{ recipients: [recipient] }, { recipients: [recipient] }], + }, + ]) + }) + + test('rejects key authorizations that do not match the request', async () => { + const request = parseRequest() + const payload = await createPayload(request) + + const cases = [ + { + request: parseRequest({ amount: '11' }), + reason: 'keyAuthorization amount mismatch', + }, + { + request: parseRequest({ currency: otherRecipient }), + reason: 'keyAuthorization currency mismatch', + }, + { + request: parseRequest({ periodCount: '2' }), + reason: 'keyAuthorization period mismatch', + }, + { + request: parseRequest({ recipient: otherRecipient }), + reason: 'keyAuthorization recipient mismatch', + }, + ] + + for (const { reason, request } of cases) { + expect(() => + verifySubscriptionKeyAuthorization({ + accessKey, + chainId: 4217, + payload, + request, + }), + ).toThrow(reason) + } + }) + + test('rejects key authorizations for the wrong access key', async () => { + const request = parseRequest() + const payload = await createPayload(request) + + expect(() => + verifySubscriptionKeyAuthorization({ + accessKey: { + accessKeyAddress: otherAccessAccount.address, + keyType: 'secp256k1', + }, + chainId: 4217, + payload, + request, + }), + ).toThrow('keyAuthorization access key mismatch') + }) + + test('rejects subscription periods that cannot be represented by the Tempo client', () => { + expect(() => toSubscriptionPeriodSeconds({ periodCount: '0', periodUnit: 'day' })).toThrow( + 'periodCount is invalid', + ) + expect(() => + toSubscriptionPeriodSeconds({ + periodCount: String(Math.floor(Number.MAX_SAFE_INTEGER / secondsPerDay) + 1), + periodUnit: 'day', + }), + ).toThrow('subscription period cannot be represented exactly by this Tempo client') + }) + + test('rejects subscription expiries that cannot be represented by Tempo key authorizations', () => { + expect(() => + toSubscriptionExpirySeconds(toSubscriptionExpiryDate('2026-01-01T00:00:00.500Z')), + ).toThrow('subscriptionExpires must be representable as whole seconds') + }) + + test('requires subscription expiry to outlive the challenge expiry', () => { + const request = parseRequest({ + subscriptionExpires: '2026-01-01T00:00:00.000Z', + }) + + expect(() => + assertSubscriptionTiming({ + challengeExpires: '2026-01-01T00:00:00.000Z', + request, + }), + ).toThrow('subscriptionExpires must be strictly later than challenge expires') + }) +}) diff --git a/src/tempo/subscription/KeyAuthorization.ts b/src/tempo/subscription/KeyAuthorization.ts new file mode 100644 index 00000000..8022a876 --- /dev/null +++ b/src/tempo/subscription/KeyAuthorization.ts @@ -0,0 +1,391 @@ +import { KeyAuthorization, SignatureEnvelope } from 'ox/tempo' +import { isAddress, isAddressEqual, type Address } from 'viem' + +import { VerificationFailedError } from '../../Errors.js' +import type * as Methods from '../Methods.js' +import type { + SubscriptionAccessKey, + SubscriptionCredentialPayload, + SubscriptionPeriodUnit, +} from './Types.js' + +/** 4-byte selector for TIP-20 `transfer(address,uint256)`. */ +export const transferSelector = '0xa9059cbb' + +/** 4-byte selector for TIP-20 `transferWithMemo(address,uint256,bytes)`. */ +export const transferWithMemoSelector = '0x95777d59' + +const uint64Max = (1n << 64n) - 1n +const secondsPerDay = 86_400n +const secondsPerWeek = 604_800n + +type SubscriptionRequest = ReturnType +type Authorization = KeyAuthorization.KeyAuthorization +type SubscriptionLimit = NonNullable[number] + +/** + * Converts a subscription expiry timestamp into the Unix seconds value required by Tempo key + * authorizations. + */ +export function toSubscriptionExpiryDate(subscriptionExpires: string | Date): Date { + return subscriptionExpires instanceof Date ? subscriptionExpires : new Date(subscriptionExpires) +} + +export function toSubscriptionExpirySeconds(subscriptionExpires: Date): number { + const milliseconds = subscriptionExpires.getTime() + if (!Number.isFinite(milliseconds)) { + throw new VerificationFailedError({ reason: 'subscriptionExpires is invalid' }) + } + if (milliseconds % 1_000 !== 0) { + throw new VerificationFailedError({ + reason: 'subscriptionExpires must be representable as whole seconds', + }) + } + + const seconds = milliseconds / 1_000 + if (seconds <= 0 || !Number.isSafeInteger(seconds)) { + throw new VerificationFailedError({ + reason: 'subscriptionExpires cannot be represented in a Tempo key authorization', + }) + } + + return seconds +} + +/** + * Converts the shared subscription period fields into the numeric period accepted by Tempo key + * authorizations. + */ +export function toSubscriptionPeriodSeconds(request: { + periodCount: string + periodUnit: SubscriptionPeriodUnit +}): number { + if (!/^[1-9]\d*$/.test(request.periodCount)) { + throw new VerificationFailedError({ reason: 'periodCount is invalid' }) + } + if (request.periodUnit !== 'day' && request.periodUnit !== 'week') { + throw new VerificationFailedError({ reason: 'periodUnit is invalid' }) + } + + const unitSeconds = request.periodUnit === 'day' ? secondsPerDay : secondsPerWeek + const value = BigInt(request.periodCount) * unitSeconds + if (value > uint64Max) { + throw new VerificationFailedError({ + reason: 'subscription period cannot be represented as an unsigned 64-bit integer', + }) + } + if (value > BigInt(Number.MAX_SAFE_INTEGER)) { + throw new VerificationFailedError({ + reason: 'subscription period cannot be represented exactly by this Tempo client', + }) + } + + return Number(value) +} + +/** + * Verifies that the subscription duration is representable and lasts beyond the payment challenge. + */ +export function assertSubscriptionTiming(parameters: { + challengeExpires?: string | undefined + request: Pick +}) { + const { challengeExpires, request } = parameters + toSubscriptionPeriodSeconds(request) + const subscriptionExpiry = toSubscriptionExpirySeconds( + toSubscriptionExpiryDate(request.subscriptionExpires), + ) + + if (challengeExpires) { + const challengeExpiry = Math.floor(new Date(challengeExpires).getTime() / 1_000) + if (!Number.isFinite(challengeExpiry) || subscriptionExpiry <= challengeExpiry) { + throw new VerificationFailedError({ + reason: 'subscriptionExpires must be strictly later than challenge expires', + }) + } + } +} + +/** Builds the Tempo access-key call scopes required for a subscription payment. */ +export function getSubscriptionScopes( + request: Pick, +) { + const currency = normalizeAddress(request.currency, 'currency') + const recipient = normalizeAddress(request.recipient, 'recipient') + return [ + { + address: currency, + selector: transferSelector, + recipients: [recipient], + }, + { + address: currency, + selector: transferWithMemoSelector, + recipients: [recipient], + }, + ] as const +} + +/** Builds the RPC `allowedCalls` payload passed to `wallet_authorizeAccessKey`. */ +export function getSubscriptionRpcAllowedCalls( + request: Pick, +) { + const [transfer, transferWithMemo] = getSubscriptionScopes(request) + return [ + { + target: normalizeAddress(request.currency, 'currency'), + selectorRules: [ + { + selector: transfer.selector, + recipients: transfer.recipients, + }, + { + selector: transferWithMemo.selector, + recipients: transferWithMemo.recipients, + }, + ], + }, + ] as const +} + +/** + * Creates and signs a Tempo key authorization for subscription payments when the account can sign + * arbitrary hashes locally. + */ +export async function signSubscriptionKeyAuthorization(parameters: { + accessKey: SubscriptionAccessKey + account: { + sign?: ((parameters: { hash: `0x${string}` }) => Promise<`0x${string}`>) | undefined + } + chainId: number + request: Pick< + SubscriptionRequest, + 'amount' | 'currency' | 'periodCount' | 'periodUnit' | 'recipient' | 'subscriptionExpires' + > +}) { + const { accessKey, account, chainId, request } = parameters + if (typeof account.sign !== 'function') return undefined + + const authorization = createUnsignedAuthorization({ + accessKey, + chainId, + request, + }) + const signature = await account.sign({ + hash: KeyAuthorization.getSignPayload(authorization), + }) + return KeyAuthorization.from(authorization, { + signature: SignatureEnvelope.from(signature), + }) +} + +/** + * Verifies that a subscription credential contains a key authorization scoped to the requested + * token, recipient, amount, period, expiry, chain, and server-issued access key. + */ +export function verifySubscriptionKeyAuthorization(parameters: { + accessKey?: SubscriptionAccessKey | undefined + chainId: number + payload: SubscriptionCredentialPayload + request: SubscriptionRequest +}) { + const { accessKey, chainId, payload, request } = parameters + if (payload.type !== 'keyAuthorization') { + throw new VerificationFailedError({ reason: 'invalid keyAuthorization payload' }) + } + + const authorization = deserializeAuthorization(payload.signature) + const signature = getPrimitiveSignature(authorization) + + assertAuthorizationKey({ + accessKey, + authorization, + chainId, + }) + assertAuthorizationExpiry(authorization, request) + assertAuthorizationLimit(getSingleTokenLimit(authorization), request) + assertAuthorizationScopes(authorization.scopes, request) + const source = recoverAuthorizationSource(authorization, signature) + + return { + authorization, + source: { + address: source as Address, + chainId, + }, + } +} + +function createUnsignedAuthorization(parameters: { + accessKey: SubscriptionAccessKey + chainId: number + request: Pick< + SubscriptionRequest, + 'amount' | 'currency' | 'periodCount' | 'periodUnit' | 'recipient' | 'subscriptionExpires' + > +}) { + const { accessKey, chainId, request } = parameters + return KeyAuthorization.from({ + address: normalizeAddress(accessKey.accessKeyAddress, 'accessKeyAddress'), + chainId: BigInt(chainId), + expiry: toSubscriptionExpirySeconds(toSubscriptionExpiryDate(request.subscriptionExpires)), + limits: [ + { + token: normalizeAddress(request.currency, 'currency'), + limit: BigInt(request.amount), + period: toSubscriptionPeriodSeconds(request), + }, + ], + scopes: getSubscriptionScopes(request), + type: accessKey.keyType, + }) +} + +function deserializeAuthorization(signature: `0x${string}`) { + try { + return KeyAuthorization.deserialize(signature) + } catch { + throw new VerificationFailedError({ reason: 'invalid keyAuthorization payload' }) + } +} + +function getPrimitiveSignature(authorization: Authorization) { + const signature = authorization.signature + if (!signature || signature.type === 'keychain') { + throw new VerificationFailedError({ + reason: 'keyAuthorization must use a primitive signature', + }) + } + return signature +} + +function assertAuthorizationKey(parameters: { + accessKey?: SubscriptionAccessKey | undefined + authorization: Authorization + chainId: number +}) { + const { accessKey, authorization, chainId } = parameters + if (authorization.chainId !== BigInt(chainId)) { + throw new VerificationFailedError({ reason: 'keyAuthorization chainId mismatch' }) + } + if (!accessKey) return + + if ( + !isAddressEqual( + authorization.address, + normalizeAddress(accessKey.accessKeyAddress, 'accessKeyAddress'), + ) + ) { + throw new VerificationFailedError({ reason: 'keyAuthorization access key mismatch' }) + } + if (authorization.type !== accessKey.keyType) { + throw new VerificationFailedError({ reason: 'keyAuthorization key type mismatch' }) + } +} + +function assertAuthorizationExpiry( + authorization: Authorization, + request: Pick, +) { + assertSubscriptionTiming({ request }) + if ( + authorization.expiry !== + toSubscriptionExpirySeconds(toSubscriptionExpiryDate(request.subscriptionExpires)) + ) { + throw new VerificationFailedError({ reason: 'keyAuthorization expiry mismatch' }) + } +} + +function getSingleTokenLimit(authorization: Authorization): SubscriptionLimit { + const [limit] = authorization.limits ?? [] + if (!limit || authorization.limits?.length !== 1) { + throw new VerificationFailedError({ + reason: 'keyAuthorization must contain exactly one token limit', + }) + } + return limit +} + +function assertAuthorizationLimit( + limit: SubscriptionLimit, + request: Pick, +) { + if (!isAddressEqual(limit.token, normalizeAddress(request.currency, 'currency'))) { + throw new VerificationFailedError({ reason: 'keyAuthorization currency mismatch' }) + } + if (limit.limit !== BigInt(request.amount)) { + throw new VerificationFailedError({ reason: 'keyAuthorization amount mismatch' }) + } + if (limit.period !== toSubscriptionPeriodSeconds(request)) { + throw new VerificationFailedError({ reason: 'keyAuthorization period mismatch' }) + } +} + +function assertAuthorizationScopes( + scopes: readonly KeyAuthorization.Scope[] | undefined, + request: Pick, +) { + if (!scopes || scopes.length < 1 || scopes.length > 2) { + throw new VerificationFailedError({ + reason: 'keyAuthorization must contain recipient-scoped transfer calls', + }) + } + + const currency = normalizeAddress(request.currency, 'currency') + const recipient = normalizeAddress(request.recipient, 'recipient') + const seen = new Set() + + for (const scope of scopes) { + if (!isAddressEqual(scope.address, currency)) { + throw new VerificationFailedError({ reason: 'keyAuthorization call target mismatch' }) + } + const selector = normalizeSelector(scope.selector) + if (selector !== transferSelector && selector !== transferWithMemoSelector) { + throw new VerificationFailedError({ reason: 'keyAuthorization selector not allowed' }) + } + if (seen.has(selector)) { + throw new VerificationFailedError({ reason: 'keyAuthorization duplicate selector' }) + } + seen.add(selector) + + if (scope.recipients?.length !== 1 || !isAddressEqual(scope.recipients[0]!, recipient)) { + throw new VerificationFailedError({ reason: 'keyAuthorization recipient mismatch' }) + } + } + + if (!seen.has(transferSelector)) { + throw new VerificationFailedError({ reason: 'keyAuthorization must allow transfer' }) + } +} + +function recoverAuthorizationSource( + authorization: Authorization, + signature: NonNullable, +) { + const signPayload = KeyAuthorization.getSignPayload(authorization) + try { + const source = SignatureEnvelope.extractAddress({ + payload: signPayload, + signature, + }) + if (!SignatureEnvelope.verify(signature, { address: source, payload: signPayload })) { + throw new VerificationFailedError({ reason: 'keyAuthorization signature is invalid' }) + } + return source + } catch (error) { + if (error instanceof VerificationFailedError) throw error + throw new VerificationFailedError({ reason: 'keyAuthorization signature is invalid' }) + } +} + +function normalizeAddress(value: string, name: string): Address { + if (!isAddress(value)) { + throw new VerificationFailedError({ reason: `${name} must be an address` }) + } + return value.toLowerCase() as Address +} + +function normalizeSelector(value: unknown): string { + if (typeof value !== 'string') return '' + return value.toLowerCase() +} diff --git a/src/tempo/subscription/Receipt.ts b/src/tempo/subscription/Receipt.ts new file mode 100644 index 00000000..c35355d8 --- /dev/null +++ b/src/tempo/subscription/Receipt.ts @@ -0,0 +1,28 @@ +import type { SubscriptionRecord, SubscriptionReceipt } from './Types.js' + +/** Creates a subscription receipt from persisted subscription fields. */ +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 { + /** Fields required to build a subscription receipt. */ + type Parameters = Pick< + SubscriptionRecord, + 'externalId' | 'reference' | 'subscriptionId' | 'timestamp' + > +} + +/** Converts a stored subscription record into a receipt. */ +export function fromRecord(record: SubscriptionRecord): SubscriptionReceipt { + return createSubscriptionReceipt(record) +} diff --git a/src/tempo/subscription/Store.test.ts b/src/tempo/subscription/Store.test.ts new file mode 100644 index 00000000..b982f554 --- /dev/null +++ b/src/tempo/subscription/Store.test.ts @@ -0,0 +1,121 @@ +import { describe, expect, test } from 'vp/test' + +import * as Store from '../../Store.js' +import { fromStore } from './Store.js' +import type { SubscriptionRecord } from './Types.js' + +const subscriptionId = 'sub_123' + +function createRecord(overrides: Partial = {}): SubscriptionRecord { + return { + amount: '10000000', + billingAnchor: '2025-01-01T00:00:00.000Z', + chainId: 4217, + currency: '0x20c0000000000000000000000000000000000001', + lastChargedPeriod: 0, + lookupKey: 'user-1:plan:pro', + periodCount: '1', + periodUnit: 'day', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + reference: `0x${'a'.repeat(64)}`, + subscriptionExpires: '2026-01-01T00:00:00.000Z', + subscriptionId, + timestamp: '2025-01-01T00:00:00.000Z', + ...overrides, + } +} + +describe('tempo subscription store', () => { + test('claims an activation challenge once', async () => { + const store = fromStore(Store.memory()) + + expect(await store.claimActivation('challenge-1')).toBe(true) + expect(await store.claimActivation('challenge-1')).toBe(false) + expect(await store.claimActivation('challenge-2')).toBe(true) + }) + + test('tracks a resolved lookup key activation until committed', async () => { + const store = fromStore(Store.memory()) + + expect(await store.beginActivation('user-1:plan:pro', 'challenge-1')).toEqual({ + status: 'started', + }) + expect(await store.beginActivation('user-1:plan:pro', 'challenge-2')).toEqual({ + status: 'inFlight', + }) + + expect(await store.commitActivation(createRecord(), 'challenge-2')).toBe(false) + expect(await store.commitActivation(createRecord(), 'challenge-1')).toBe(true) + + expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe(subscriptionId) + expect(await store.beginActivation('user-1:plan:pro', 'challenge-3')).toEqual({ + status: 'started', + }) + }) + + test('replaces a stale activation marker after the timeout', async () => { + const store = fromStore(Store.memory(), { activationTimeoutMs: 0 }) + + expect(await store.beginActivation('user-1:plan:pro', 'challenge-1')).toEqual({ + status: 'started', + }) + expect(await store.beginActivation('user-1:plan:pro', 'challenge-2')).toEqual({ + status: 'started', + }) + expect(await store.commitActivation(createRecord(), 'challenge-1')).toBe(false) + expect(await store.commitActivation(createRecord(), 'challenge-2')).toBe(true) + }) + + test('tracks an in-flight renewal and commits it once', async () => { + const store = fromStore(Store.memory()) + await store.put(createRecord()) + + const started = await store.beginRenewal(subscriptionId, 1, '0xrenewal') + expect(started.status).toBe('started') + expect((await store.get(subscriptionId))?.inFlightPeriod).toBe(1) + expect((await store.get(subscriptionId))?.inFlightReference).toBe('0xrenewal') + + const duplicate = await store.beginRenewal(subscriptionId, 1) + expect(duplicate.status).toBe('inFlight') + + expect( + await store.commitRenewal( + subscriptionId, + createRecord({ + lastChargedPeriod: 1, + reference: `0x${'b'.repeat(64)}`, + }), + 1, + ), + ).toBe(true) + + expect( + await store.commitRenewal( + 'sub_missing', + createRecord({ + lastChargedPeriod: 2, + reference: `0x${'c'.repeat(64)}`, + }), + 2, + ), + ).toBe(false) + + const committed = await store.get(subscriptionId) + expect(committed?.lastChargedPeriod).toBe(1) + expect(committed?.inFlightPeriod).toBe(undefined) + + const charged = await store.beginRenewal(subscriptionId, 1) + expect(charged.status).toBe('charged') + }) + + test('clears an in-flight renewal after failure', async () => { + const store = fromStore(Store.memory()) + await store.put(createRecord()) + + await store.beginRenewal(subscriptionId, 1) + await store.failRenewal(subscriptionId, 1) + + expect((await store.get(subscriptionId))?.inFlightPeriod).toBe(undefined) + expect((await store.beginRenewal(subscriptionId, 1)).status).toBe('started') + }) +}) diff --git a/src/tempo/subscription/Store.ts b/src/tempo/subscription/Store.ts new file mode 100644 index 00000000..2f857d14 --- /dev/null +++ b/src/tempo/subscription/Store.ts @@ -0,0 +1,290 @@ +import { Secp256k1 } from 'ox' +import { Account as TempoAccount } from 'viem/tempo' + +import * as Store from '../../Store.js' +import type { SubscriptionAccessKeyRecord, SubscriptionRecord } from './Types.js' + +const defaultRecordPrefix = 'tempo:subscription:record:' +const defaultKeyPrefix = 'tempo:subscription:key:' +const defaultActivationPrefix = 'tempo:subscription:activation:' +const defaultAccessKeyPrefix = 'tempo:subscription:access-key:' +const defaultCredentialPrefix = 'tempo:subscription:credential:' +const defaultActivationTimeoutMs = 15 * 60 * 1_000 + +/** Subscription-aware wrapper around a generic key-value store. */ +export type SubscriptionStore = { + /** Atomically marks a resolved subscription key as being activated. */ + beginActivation: (lookupKey: string, challengeId: string) => Promise + /** Atomically marks a subscription period as being renewed. */ + beginRenewal: ( + subscriptionId: string, + periodIndex: number, + inFlightReference?: string | undefined, + ) => Promise + /** Atomically claims a subscription activation challenge for single-use credentials. */ + claimActivation: (challengeId: string) => Promise + /** Stores an activated subscription and clears its in-flight activation marker. */ + commitActivation: (subscription: SubscriptionRecord, challengeId: string) => Promise + /** Atomically stores a successful renewal and clears the in-flight marker. */ + commitRenewal: ( + subscriptionId: string, + subscription: SubscriptionRecord, + periodIndex: number, + ) => Promise + /** Clears an in-flight renewal marker after a failed renewal attempt. */ + failRenewal: (subscriptionId: string, periodIndex: number) => Promise + /** Looks up a subscription by subscription ID. */ + get: (subscriptionId: string) => Promise + /** Looks up a generated access key for a resolved request key. */ + getAccessKey: (key: string) => Promise + /** Looks up the active subscription for a resolved request key. */ + getByKey: (key: string) => Promise + /** Gets or creates the server-owned access key for a resolved request key. */ + getOrCreateAccessKey: (key: string) => Promise + /** Upserts a subscription record and marks it as active for its lookup key. */ + put: (record: SubscriptionRecord) => Promise +} + +/** Result from attempting to mark a resolved subscription key as in-flight. */ +export type BeginActivationResult = { status: 'started' } | { status: 'inFlight' } + +/** Result from attempting to mark a subscription period as in-flight. */ +export type BeginRenewalResult = + | { status: 'started'; subscription: SubscriptionRecord } + | { status: 'charged'; subscription: SubscriptionRecord } + | { status: 'inFlight'; subscription: SubscriptionRecord } + | { status: 'missing' } + +/** Wraps a generic key-value {@link Store.Store} with subscription-specific accessors. */ +export function fromStore( + store: Store.AtomicStore>, + options?: fromStore.Options, +): SubscriptionStore { + const recordPrefix = options?.recordPrefix ?? defaultRecordPrefix + const keyPrefix = options?.keyPrefix ?? defaultKeyPrefix + const activationPrefix = options?.activationPrefix ?? defaultActivationPrefix + const accessKeyPrefix = options?.accessKeyPrefix ?? defaultAccessKeyPrefix + const activationTimeoutMs = options?.activationTimeoutMs ?? defaultActivationTimeoutMs + const credentialPrefix = options?.credentialPrefix ?? defaultCredentialPrefix + + function recordKey(subscriptionId: string): string { + return `${recordPrefix}${subscriptionId}` + } + + function activationKey(key: string): string { + return `${activationPrefix}${key}` + } + + function credentialKey(challengeId: string): string { + return `${credentialPrefix}${challengeId}` + } + + function accessKeyKey(key: string): string { + return `${accessKeyPrefix}${key}` + } + + function lookupKey(key: string): string { + return `${keyPrefix}${key}` + } + + async function getByLookupKey(key: string): Promise { + const id = (await store.get(lookupKey(key))) as string | null + if (!id) return null + return (await store.get(recordKey(id))) as SubscriptionRecord | null + } + + return { + async beginActivation(key, challengeId) { + return store.update( + activationKey(key), + (current): Store.Change => { + const marker = current as { startedAt?: string } | null + if (marker && !isStaleActivation(marker, activationTimeoutMs)) { + return { op: 'noop', result: { status: 'inFlight' as const } } + } + return { + op: 'set', + value: { + challengeId, + startedAt: new Date().toISOString(), + }, + result: { status: 'started' as const }, + } + }, + ) + }, + + async beginRenewal(subscriptionId, periodIndex, inFlightReference) { + return store.update( + recordKey(subscriptionId), + (current): Store.Change => { + const subscription = current as SubscriptionRecord | null + if (!subscription) return { op: 'noop', result: { status: 'missing' as const } } + if (subscription.lastChargedPeriod >= periodIndex) { + return { + op: 'noop', + result: { status: 'charged' as const, subscription }, + } + } + if (subscription.inFlightPeriod === periodIndex) { + return { + op: 'noop', + result: { status: 'inFlight' as const, subscription }, + } + } + + const next = { + ...subscription, + inFlightPeriod: periodIndex, + inFlightReference, + inFlightStartedAt: new Date().toISOString(), + } + return { + op: 'set', + value: next, + result: { status: 'started' as const, subscription: next }, + } + }, + ) + }, + + async claimActivation(challengeId) { + return store.update(credentialKey(challengeId), (current) => { + // Challenge IDs are single-use for activation credentials. + if (current) return { op: 'noop', result: false } + return { + op: 'set', + value: { claimedAt: new Date().toISOString() }, + result: true, + } + }) + }, + + async commitActivation(subscription, challengeId) { + const claimed = await store.update(activationKey(subscription.lookupKey), (current) => { + const marker = current as { challengeId?: string; startedAt?: string } | null + if (marker?.challengeId !== challengeId) return { op: 'noop', result: false } + return { + op: 'set', + value: { ...marker, committingAt: new Date().toISOString() }, + result: true, + } + }) + if (!claimed) return false + + await store.put(recordKey(subscription.subscriptionId), subscription) + await store.put(lookupKey(subscription.lookupKey), subscription.subscriptionId) + await store.update(activationKey(subscription.lookupKey), (current) => { + const marker = current as { challengeId?: string } | null + if (marker?.challengeId !== challengeId) return { op: 'noop', result: undefined } + return { op: 'delete', result: undefined } + }) + return true + }, + + async commitRenewal(subscriptionId, subscription, periodIndex) { + const committed = await store.update(recordKey(subscriptionId), (current) => { + const existing = current as SubscriptionRecord | null + if (!existing || existing.inFlightPeriod !== periodIndex) { + return { op: 'noop', result: false } + } + + return { + op: 'set', + value: { + ...subscription, + inFlightPeriod: undefined, + inFlightReference: undefined, + inFlightStartedAt: undefined, + lastChargedPeriod: periodIndex, + subscriptionId, + }, + result: true, + } + }) + if (committed) await store.put(lookupKey(subscription.lookupKey), subscriptionId) + return committed + }, + + async failRenewal(subscriptionId, periodIndex) { + await store.update(recordKey(subscriptionId), (current) => { + const subscription = current as SubscriptionRecord | null + if (!subscription || subscription.inFlightPeriod !== periodIndex) { + return { op: 'noop', result: undefined } + } + return { + op: 'set', + value: { + ...subscription, + inFlightPeriod: undefined, + inFlightReference: undefined, + inFlightStartedAt: undefined, + }, + result: undefined, + } + }) + }, + + async get(subscriptionId) { + return (await store.get(recordKey(subscriptionId))) as SubscriptionRecord | null + }, + + async getAccessKey(key) { + return (await store.get(accessKeyKey(key))) as SubscriptionAccessKeyRecord | null + }, + + /** Looks up the active subscription for a resolved request key. */ + async getByKey(key) { + return getByLookupKey(key) + }, + + async getOrCreateAccessKey(key) { + const privateKey = Secp256k1.randomPrivateKey() + const account = TempoAccount.fromSecp256k1(privateKey) + const candidate = { + accessKeyAddress: account.address.toLowerCase() as `0x${string}`, + keyType: account.keyType, + privateKey, + } satisfies SubscriptionAccessKeyRecord + return store.update( + accessKeyKey(key), + (current): Store.Change => { + if (current) { + return { op: 'noop', result: current as SubscriptionAccessKeyRecord } + } + return { op: 'set', value: candidate, result: candidate } + }, + ) + }, + + /** Upserts a subscription record and marks it as active for its lookup key. */ + async put(record) { + await store.put(recordKey(record.subscriptionId), record) + await store.put(lookupKey(record.lookupKey), record.subscriptionId) + }, + } +} + +export declare namespace fromStore { + type Options = { + /** Key prefix for server-owned subscription access keys. @default `'tempo:subscription:access-key:'` */ + accessKeyPrefix?: string | undefined + /** Key prefix for resolved subscription activation locks. @default `'tempo:subscription:activation:'` */ + activationPrefix?: string | undefined + /** Milliseconds before a stuck activation lock can be replaced. @default `900000` */ + activationTimeoutMs?: number | undefined + /** Key prefix for single-use activation credential markers. @default `'tempo:subscription:credential:'` */ + credentialPrefix?: string | undefined + /** Key prefix for subscription records. @default `'tempo:subscription:record:'` */ + recordPrefix?: string | undefined + /** Key prefix for resolved request keys. @default `'tempo:subscription:key:'` */ + keyPrefix?: string | undefined + } +} + +function isStaleActivation(marker: { startedAt?: string }, timeoutMs: number) { + if (!Number.isFinite(timeoutMs) || timeoutMs < 0) return false + const startedAt = new Date(marker.startedAt ?? '').getTime() + if (!Number.isFinite(startedAt)) return true + return Date.now() - startedAt >= timeoutMs +} diff --git a/src/tempo/subscription/Types.ts b/src/tempo/subscription/Types.ts new file mode 100644 index 00000000..232bc82b --- /dev/null +++ b/src/tempo/subscription/Types.ts @@ -0,0 +1,66 @@ +import type { Address } from 'viem' + +/** Tempo-supported subscription period units. The shared intent also defines `month`, but Tempo cannot represent calendar-month periods exactly. */ +export type SubscriptionPeriodUnit = 'day' | 'week' + +/** Access key information used to authorize recurring Tempo payments. */ +export type SubscriptionAccessKey = { + accessKeyAddress: Address + keyType: 'p256' | 'secp256k1' | 'webAuthn' +} + +/** Server-owned subscription access key persisted for automatic billing. */ +export type SubscriptionAccessKeyRecord = SubscriptionAccessKey & { + privateKey: `0x${string}` +} + +/** Request-scoped lookup key for the active subscription tied to a route. */ +export type SubscriptionLookup = { + accessKey?: SubscriptionAccessKey | undefined + key: string +} + +/** Persisted recurring Tempo subscription state. */ +export type SubscriptionRecord = { + amount: string + billingAnchor: string + chainId?: number | undefined + currency: Address | string + externalId?: string | undefined + accessKey?: SubscriptionAccessKey | undefined + inFlightPeriod?: number | undefined + /** Stable idempotency/reconciliation reference for a renewal currently in progress. */ + inFlightReference?: string | undefined + inFlightStartedAt?: string | undefined + /** Signed key authorization used by automatic renewals. */ + keyAuthorization?: `0x${string}` | undefined + lastChargedPeriod: number + lookupKey: string + /** Root account that authorized the stored subscription access key. */ + payer?: { address: Address; chainId: number } | undefined + periodCount: string + periodUnit: SubscriptionPeriodUnit + recipient: Address | string + reference: string + subscriptionExpires: string + subscriptionId: string + timestamp: string + canceledAt?: string | undefined + revokedAt?: string | undefined +} + +/** Credential payload for a Tempo subscription activation. */ +export type SubscriptionCredentialPayload = { + signature: `0x${string}` + type: 'keyAuthorization' +} + +/** Receipt returned for a Tempo subscription activation or renewal. */ +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..8aba22d7 --- /dev/null +++ b/src/tempo/subscription/index.ts @@ -0,0 +1,23 @@ +export { createSubscriptionReceipt, fromRecord } from './Receipt.js' +export { + getSubscriptionRpcAllowedCalls, + getSubscriptionScopes, + signSubscriptionKeyAuthorization, + toSubscriptionExpiryDate, + toSubscriptionExpirySeconds, + toSubscriptionPeriodSeconds, + transferSelector, + transferWithMemoSelector, + verifySubscriptionKeyAuthorization, +} from './KeyAuthorization.js' +export { fromStore } from './Store.js' +export type { BeginRenewalResult, SubscriptionStore } from './Store.js' +export type { + SubscriptionAccessKey, + SubscriptionAccessKeyRecord, + SubscriptionCredentialPayload, + SubscriptionLookup, + SubscriptionPeriodUnit, + SubscriptionRecord, + SubscriptionReceipt, +} from './Types.js' From 3bb74cecb941317890c9db0f461611c966364692 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 8 May 2026 10:03:02 -0700 Subject: [PATCH 2/8] refactor: collapse subscription store lifecycle --- src/tempo/server/Subscription.ts | 197 +++++++--------- src/tempo/subscription/Store.test.ts | 189 ++++++++++----- src/tempo/subscription/Store.ts | 336 +++++++++++++++------------ src/tempo/subscription/index.ts | 2 +- 4 files changed, 415 insertions(+), 309 deletions(-) diff --git a/src/tempo/server/Subscription.ts b/src/tempo/server/Subscription.ts index 4ce38287..c6b9d2bb 100644 --- a/src/tempo/server/Subscription.ts +++ b/src/tempo/server/Subscription.ts @@ -138,23 +138,16 @@ export function subscription( async request({ capturedRequest, credential, request }) { const credentialRequest = credential?.challenge.request as SubscriptionRequest | undefined - const chainId = await (async () => { - if (request.chainId) return request.chainId - if (parameters.chainId) return parameters.chainId - if (credentialRequest?.methodDetails?.chainId) - return credentialRequest.methodDetails.chainId - return defaults.chainId.testnet - })() + const chainId = + request.chainId ?? + parameters.chainId ?? + credentialRequest?.methodDetails?.chainId ?? + defaults.chainId.testnet const parsedRequest = Methods.subscription.schema.request.parse({ ...request, chainId, }) - const input = capturedRequest - ? new Request(capturedRequest.url, { - headers: capturedRequest.headers, - method: capturedRequest.method, - }) - : new Request('https://subscription.invalid') + const input = requestFromCaptured(capturedRequest) const resolved = await parameters.resolve({ input, request: parsedRequest }) const existing = resolved ? await store.getByKey(resolved.key) : null const accessKey = @@ -183,17 +176,10 @@ export function subscription( } }, - stableBinding(request) { - return subscriptionBinding(request) - }, + stableBinding: subscriptionBinding, async verify({ credential, envelope, request }) { - const input = envelope - ? new Request(envelope.capturedRequest.url, { - headers: envelope.capturedRequest.headers, - method: envelope.capturedRequest.method, - }) - : new Request('https://subscription.invalid') + const input = requestFromCaptured(envelope?.capturedRequest) const parsedRequest = Methods.subscription.schema.request.parse(request) assertSubscriptionTiming({ challengeExpires: credential.challenge.expires, @@ -227,75 +213,75 @@ export function subscription( throw new VerificationFailedError({ reason: 'credential source does not match signature' }) } - // Claim the challenge before activation so replayed credentials cannot reach the charge hook. - const activationClaimed = await store.claimActivation(credential.challenge.id) - if (!activationClaimed) { + const activation = await store.activate({ + challengeId: credential.challenge.id, + isReusable: isActive, + lookupKey: resolved.key, + async create() { + const activation = withSubscriptionAccessKey( + await activateSubscription({ + accessKey, + auto: { + challengeId: credential.challenge.id, + getClient, + keyAuthorization: (credential.payload as SubscriptionCredentialPayload).signature, + realm: credential.challenge.realm, + store, + waitForConfirmation, + }, + credential: credential as typeof credential & { + payload: SubscriptionCredentialPayload + }, + input, + parameters, + request: parsedRequest, + resolved, + source: verified.source, + }), + accessKey, + ) + validateSubscriptionSettlement(activation, { + expectedLookupKey: resolved.key, + expectedPeriodIndex: 0, + request: parsedRequest, + }) + return activation + }, + }) + if (activation.status === 'replayed') { throw new VerificationFailedError({ reason: 'subscription credential has already been used', }) } - - const existing = await store.getByKey(resolved.key) - if (existing && isActive(existing)) { - return SubscriptionReceipt.fromRecord(existing) - } - - // Distinct challenges can target the same subscription key; serialize activation by key - // before the first-period charge hook so concurrent fresh credentials cannot double-charge. - const activationStarted = await store.beginActivation(resolved.key, credential.challenge.id) - if (activationStarted.status !== 'started') { + if (activation.status === 'inFlight') { throw new VerificationFailedError({ reason: 'subscription activation is already in flight', }) } - - const activation = withSubscriptionAccessKey( - await activateSubscription({ - accessKey, - auto: { - challengeId: credential.challenge.id, - getClient, - keyAuthorization: (credential.payload as SubscriptionCredentialPayload).signature, - realm: credential.challenge.realm, - store, - waitForConfirmation, - }, - credential: credential as typeof credential & { - payload: SubscriptionCredentialPayload - }, - input, - parameters, - request: parsedRequest, - resolved, - source: verified.source, - }), - accessKey, - ) - - validateSubscriptionSettlement(activation, { - expectedLookupKey: resolved.key, - expectedPeriodIndex: 0, - request: parsedRequest, - }) - - const activationCommitted = await store.commitActivation( - activation.subscription, - credential.challenge.id, - ) - if (!activationCommitted) { - throw new VerificationFailedError({ - reason: 'subscription activation claim mismatch', - }) + if (activation.status === 'claimMismatch') { + throw new VerificationFailedError({ reason: 'subscription activation claim mismatch' }) } + if (activation.status === 'existing') { + return SubscriptionReceipt.fromRecord(activation.subscription) + } + await parameters.hooks?.activated?.({ - receipt: activation.receipt, - subscription: activation.subscription, + receipt: activation.result.receipt, + subscription: activation.result.subscription, }) - return activation.receipt + return activation.result.receipt }, }) } +function requestFromCaptured(capturedRequest: Method.CapturedRequest | undefined): Request { + if (!capturedRequest) return new Request('https://subscription.invalid') + return new Request(capturedRequest.url, { + headers: capturedRequest.headers, + method: capturedRequest.method, + }) +} + async function resolveAccessKey(parameters: { input: Request parameters: subscription.Parameters @@ -448,42 +434,37 @@ async function settleRenewal(parameters: { > { const { expectedLookupKey, periodIndex, renew, request, store, subscription } = parameters const inFlightReference = renewalReference(subscription.subscriptionId, periodIndex) - const started = await store.beginRenewal( - subscription.subscriptionId, - periodIndex, + const renewal = await store.renew({ inFlightReference, - ) - if (started.status === 'charged') { - return { receipt: SubscriptionReceipt.fromRecord(started.subscription), status: 'charged' } - } - if (started.status !== 'started') return null - - const renewed = withSubscriptionAccessKey( - await renew({ - inFlightReference, - periodIndex, - subscription: started.subscription, - }).catch(async (error) => { - await store.failRenewal(subscription.subscriptionId, periodIndex) - throw error - }), - started.subscription.accessKey, - ) - validateSubscriptionSettlement(renewed, { - expectedLookupKey, - expectedPeriodIndex: periodIndex, - expectedSubscriptionId: subscription.subscriptionId, - request, - }) - const committed = await store.commitRenewal( - subscription.subscriptionId, - renewed.subscription, periodIndex, - ) - if (!committed) { + async renew({ inFlightReference, periodIndex, subscription: started }) { + const renewed = withSubscriptionAccessKey( + await renew({ + inFlightReference, + periodIndex, + subscription: started, + }), + started.accessKey, + ) + validateSubscriptionSettlement(renewed, { + expectedLookupKey, + expectedPeriodIndex: periodIndex, + expectedSubscriptionId: subscription.subscriptionId, + request, + }) + return renewed + }, + subscriptionId: subscription.subscriptionId, + }) + + if (renewal.status === 'charged') { + return { receipt: SubscriptionReceipt.fromRecord(renewal.subscription), status: 'charged' } + } + if (renewal.status === 'renewed') return { result: renewal.result, status: 'renewed' } + if (renewal.status === 'claimMismatch') { throw new VerificationFailedError({ reason: 'subscription renewal claim mismatch' }) } - return { result: renewed, status: 'renewed' } + return null } function renewalReference(subscriptionId: string, periodIndex: number): string { diff --git a/src/tempo/subscription/Store.test.ts b/src/tempo/subscription/Store.test.ts index b982f554..16acf2d2 100644 --- a/src/tempo/subscription/Store.test.ts +++ b/src/tempo/subscription/Store.test.ts @@ -26,96 +26,179 @@ function createRecord(overrides: Partial = {}): Subscription } describe('tempo subscription store', () => { - test('claims an activation challenge once', async () => { + test('rejects a replayed activation challenge', async () => { const store = fromStore(Store.memory()) - expect(await store.claimActivation('challenge-1')).toBe(true) - expect(await store.claimActivation('challenge-1')).toBe(false) - expect(await store.claimActivation('challenge-2')).toBe(true) + const first = await store.activate({ + challengeId: 'challenge-1', + create: async () => ({ subscription: createRecord() }), + lookupKey: 'user-1:plan:pro', + }) + expect(first.status).toBe('activated') + + expect( + await store.activate({ + challengeId: 'challenge-1', + create: async () => ({ subscription: createRecord() }), + lookupKey: 'user-1:plan:pro', + }), + ).toEqual({ status: 'replayed' }) }) test('tracks a resolved lookup key activation until committed', async () => { const store = fromStore(Store.memory()) - - expect(await store.beginActivation('user-1:plan:pro', 'challenge-1')).toEqual({ - status: 'started', - }) - expect(await store.beginActivation('user-1:plan:pro', 'challenge-2')).toEqual({ - status: 'inFlight', + let finishActivation!: () => void + const pendingActivation = new Promise((resolve) => { + finishActivation = resolve }) - expect(await store.commitActivation(createRecord(), 'challenge-2')).toBe(false) - expect(await store.commitActivation(createRecord(), 'challenge-1')).toBe(true) - - expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe(subscriptionId) - expect(await store.beginActivation('user-1:plan:pro', 'challenge-3')).toEqual({ - status: 'started', + const first = store.activate({ + challengeId: 'challenge-1', + create: async () => { + await pendingActivation + return { subscription: createRecord() } + }, + lookupKey: 'user-1:plan:pro', }) + expect( + await store.activate({ + challengeId: 'challenge-2', + create: async () => ({ subscription: createRecord() }), + lookupKey: 'user-1:plan:pro', + }), + ).toEqual({ status: 'inFlight' }) + + finishActivation() + expect((await first).status).toBe('activated') + expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe(subscriptionId) }) test('replaces a stale activation marker after the timeout', async () => { const store = fromStore(Store.memory(), { activationTimeoutMs: 0 }) + let finishActivation!: () => void + const pendingActivation = new Promise((resolve) => { + finishActivation = resolve + }) - expect(await store.beginActivation('user-1:plan:pro', 'challenge-1')).toEqual({ - status: 'started', + const first = store.activate({ + challengeId: 'challenge-1', + create: async () => { + await pendingActivation + return { subscription: createRecord({ reference: `0x${'b'.repeat(64)}` }) } + }, + lookupKey: 'user-1:plan:pro', }) - expect(await store.beginActivation('user-1:plan:pro', 'challenge-2')).toEqual({ - status: 'started', + + const second = await store.activate({ + challengeId: 'challenge-2', + create: async () => ({ subscription: createRecord() }), + lookupKey: 'user-1:plan:pro', }) - expect(await store.commitActivation(createRecord(), 'challenge-1')).toBe(false) - expect(await store.commitActivation(createRecord(), 'challenge-2')).toBe(true) + expect(second.status).toBe('activated') + + finishActivation() + expect(await first).toEqual({ status: 'claimMismatch' }) }) test('tracks an in-flight renewal and commits it once', async () => { const store = fromStore(Store.memory()) await store.put(createRecord()) - const started = await store.beginRenewal(subscriptionId, 1, '0xrenewal') - expect(started.status).toBe('started') - expect((await store.get(subscriptionId))?.inFlightPeriod).toBe(1) - expect((await store.get(subscriptionId))?.inFlightReference).toBe('0xrenewal') - - const duplicate = await store.beginRenewal(subscriptionId, 1) - expect(duplicate.status).toBe('inFlight') - - expect( - await store.commitRenewal( - subscriptionId, - createRecord({ - lastChargedPeriod: 1, - reference: `0x${'b'.repeat(64)}`, - }), - 1, - ), - ).toBe(true) + const renewed = await store.renew({ + inFlightReference: '0xrenewal', + periodIndex: 1, + renew: async ({ inFlightReference, subscription }) => { + expect(inFlightReference).toBe('0xrenewal') + return { + subscription: { + ...subscription, + lastChargedPeriod: 1, + reference: `0x${'b'.repeat(64)}`, + }, + } + }, + subscriptionId, + }) + expect(renewed.status).toBe('renewed') + expect((await store.get(subscriptionId))?.inFlightPeriod).toBe(undefined) expect( - await store.commitRenewal( - 'sub_missing', - createRecord({ - lastChargedPeriod: 2, - reference: `0x${'c'.repeat(64)}`, - }), - 2, - ), - ).toBe(false) + await store.renew({ + inFlightReference: '0xmissing', + periodIndex: 2, + renew: async () => ({ subscription: createRecord() }), + subscriptionId: 'sub_missing', + }), + ).toEqual({ status: 'missing' }) const committed = await store.get(subscriptionId) expect(committed?.lastChargedPeriod).toBe(1) expect(committed?.inFlightPeriod).toBe(undefined) - const charged = await store.beginRenewal(subscriptionId, 1) + const charged = await store.renew({ + inFlightReference: '0xrenewal', + periodIndex: 1, + renew: async () => ({ subscription: createRecord() }), + subscriptionId, + }) expect(charged.status).toBe('charged') }) + test('returns in-flight for a duplicate renewal period', async () => { + const store = fromStore(Store.memory()) + await store.put(createRecord()) + let finishRenewal!: () => void + const pendingRenewal = new Promise((resolve) => { + finishRenewal = resolve + }) + + const first = store.renew({ + inFlightReference: '0xrenewal', + periodIndex: 1, + renew: async ({ subscription }) => { + await pendingRenewal + return { subscription } + }, + subscriptionId, + }) + + const duplicate = await store.renew({ + inFlightReference: '0xrenewal', + periodIndex: 1, + renew: async ({ subscription }) => ({ subscription }), + subscriptionId, + }) + expect(duplicate.status).toBe('inFlight') + + finishRenewal() + expect((await first).status).toBe('renewed') + }) + test('clears an in-flight renewal after failure', async () => { const store = fromStore(Store.memory()) await store.put(createRecord()) - await store.beginRenewal(subscriptionId, 1) - await store.failRenewal(subscriptionId, 1) + await expect( + store.renew({ + inFlightReference: '0xrenewal', + periodIndex: 1, + renew: async () => { + throw new Error('renewal failed') + }, + subscriptionId, + }), + ).rejects.toThrow('renewal failed') expect((await store.get(subscriptionId))?.inFlightPeriod).toBe(undefined) - expect((await store.beginRenewal(subscriptionId, 1)).status).toBe('started') + expect( + ( + await store.renew({ + inFlightReference: '0xrenewal', + periodIndex: 1, + renew: async ({ subscription }) => ({ subscription }), + subscriptionId, + }) + ).status, + ).toBe('renewed') }) }) diff --git a/src/tempo/subscription/Store.ts b/src/tempo/subscription/Store.ts index 2f857d14..9b417308 100644 --- a/src/tempo/subscription/Store.ts +++ b/src/tempo/subscription/Store.ts @@ -13,59 +13,76 @@ const defaultActivationTimeoutMs = 15 * 60 * 1_000 /** Subscription-aware wrapper around a generic key-value store. */ export type SubscriptionStore = { - /** Atomically marks a resolved subscription key as being activated. */ - beginActivation: (lookupKey: string, challengeId: string) => Promise - /** Atomically marks a subscription period as being renewed. */ - beginRenewal: ( - subscriptionId: string, - periodIndex: number, - inFlightReference?: string | undefined, - ) => Promise - /** Atomically claims a subscription activation challenge for single-use credentials. */ - claimActivation: (challengeId: string) => Promise - /** Stores an activated subscription and clears its in-flight activation marker. */ - commitActivation: (subscription: SubscriptionRecord, challengeId: string) => Promise - /** Atomically stores a successful renewal and clears the in-flight marker. */ - commitRenewal: ( - subscriptionId: string, - subscription: SubscriptionRecord, - periodIndex: number, - ) => Promise - /** Clears an in-flight renewal marker after a failed renewal attempt. */ - failRenewal: (subscriptionId: string, periodIndex: number) => Promise + /** Runs activation once for a challenge and resolved lookup key. */ + activate( + parameters: ActivateParameters, + ): Promise> /** Looks up a subscription by subscription ID. */ - get: (subscriptionId: string) => Promise + get(subscriptionId: string): Promise /** Looks up a generated access key for a resolved request key. */ - getAccessKey: (key: string) => Promise + getAccessKey(key: string): Promise /** Looks up the active subscription for a resolved request key. */ - getByKey: (key: string) => Promise + getByKey(key: string): Promise /** Gets or creates the server-owned access key for a resolved request key. */ - getOrCreateAccessKey: (key: string) => Promise + getOrCreateAccessKey(key: string): Promise /** Upserts a subscription record and marks it as active for its lookup key. */ - put: (record: SubscriptionRecord) => Promise + put(record: SubscriptionRecord): Promise + /** Runs renewal once for a subscription period. */ + renew( + parameters: RenewParameters, + ): Promise> } -/** Result from attempting to mark a resolved subscription key as in-flight. */ -export type BeginActivationResult = { status: 'started' } | { status: 'inFlight' } +type ActivateParameters = { + challengeId: string + create: () => Promise + isReusable?: ((subscription: SubscriptionRecord) => boolean) | undefined + lookupKey: string +} + +export type ActivateResult = + | { status: 'activated'; result: result } + | { status: 'claimMismatch' } + | { status: 'existing'; subscription: SubscriptionRecord } + | { status: 'inFlight' } + | { status: 'replayed' } -/** Result from attempting to mark a subscription period as in-flight. */ -export type BeginRenewalResult = - | { status: 'started'; subscription: SubscriptionRecord } +type RenewParameters = { + inFlightReference: string + periodIndex: number + renew: (parameters: { + inFlightReference: string + periodIndex: number + subscription: SubscriptionRecord + }) => Promise + subscriptionId: string +} + +export type RenewResult = | { status: 'charged'; subscription: SubscriptionRecord } | { status: 'inFlight'; subscription: SubscriptionRecord } | { status: 'missing' } + | { status: 'renewed'; result: result } + | { status: 'claimMismatch' } + +type ActivationMarker = { + challengeId?: string + startedAt?: string +} /** Wraps a generic key-value {@link Store.Store} with subscription-specific accessors. */ export function fromStore( store: Store.AtomicStore>, options?: fromStore.Options, ): SubscriptionStore { - const recordPrefix = options?.recordPrefix ?? defaultRecordPrefix - const keyPrefix = options?.keyPrefix ?? defaultKeyPrefix - const activationPrefix = options?.activationPrefix ?? defaultActivationPrefix - const accessKeyPrefix = options?.accessKeyPrefix ?? defaultAccessKeyPrefix - const activationTimeoutMs = options?.activationTimeoutMs ?? defaultActivationTimeoutMs - const credentialPrefix = options?.credentialPrefix ?? defaultCredentialPrefix + const { + accessKeyPrefix = defaultAccessKeyPrefix, + activationPrefix = defaultActivationPrefix, + activationTimeoutMs = defaultActivationTimeoutMs, + credentialPrefix = defaultCredentialPrefix, + keyPrefix = defaultKeyPrefix, + recordPrefix = defaultRecordPrefix, + } = options ?? {} function recordKey(subscriptionId: string): string { return `${recordPrefix}${subscriptionId}` @@ -83,22 +100,51 @@ export function fromStore( return `${accessKeyPrefix}${key}` } - function lookupKey(key: string): string { + function lookupRecordKey(key: string): string { return `${keyPrefix}${key}` } async function getByLookupKey(key: string): Promise { - const id = (await store.get(lookupKey(key))) as string | null - if (!id) return null - return (await store.get(recordKey(id))) as SubscriptionRecord | null + const subscriptionId = (await store.get(lookupRecordKey(key))) as string | null + if (!subscriptionId) return null + return (await store.get(recordKey(subscriptionId))) as SubscriptionRecord | null + } + + async function clearRenewalState(subscriptionId: string, periodIndex: number) { + await store.update(recordKey(subscriptionId), (current) => { + const subscription = current as SubscriptionRecord | null + if (!subscription || subscription.inFlightPeriod !== periodIndex) { + return { op: 'noop', result: undefined } + } + return { + op: 'set', + value: clearRenewal(subscription), + result: undefined, + } + }) } return { - async beginActivation(key, challengeId) { - return store.update( - activationKey(key), - (current): Store.Change => { - const marker = current as { startedAt?: string } | null + async activate({ challengeId, create, isReusable, lookupKey }) { + const claimed = await store.update(credentialKey(challengeId), (current) => { + if (current) return { op: 'noop', result: false } + return { + op: 'set', + value: { claimedAt: timestamp() }, + result: true, + } + }) + if (!claimed) return { status: 'replayed' } + + const existing = await getByLookupKey(lookupKey) + if (existing && isReusable?.(existing)) { + return { status: 'existing', subscription: existing } + } + + const started = await store.update( + activationKey(lookupKey), + (current): Store.Change => { + const marker = current as ActivationMarker | null if (marker && !isStaleActivation(marker, activationTimeoutMs)) { return { op: 'noop', result: { status: 'inFlight' as const } } } @@ -106,123 +152,35 @@ export function fromStore( op: 'set', value: { challengeId, - startedAt: new Date().toISOString(), + startedAt: timestamp(), }, result: { status: 'started' as const }, } }, ) - }, + if (started.status !== 'started') return { status: 'inFlight' } - async beginRenewal(subscriptionId, periodIndex, inFlightReference) { - return store.update( - recordKey(subscriptionId), - (current): Store.Change => { - const subscription = current as SubscriptionRecord | null - if (!subscription) return { op: 'noop', result: { status: 'missing' as const } } - if (subscription.lastChargedPeriod >= periodIndex) { - return { - op: 'noop', - result: { status: 'charged' as const, subscription }, - } - } - if (subscription.inFlightPeriod === periodIndex) { - return { - op: 'noop', - result: { status: 'inFlight' as const, subscription }, - } - } - - const next = { - ...subscription, - inFlightPeriod: periodIndex, - inFlightReference, - inFlightStartedAt: new Date().toISOString(), - } - return { - op: 'set', - value: next, - result: { status: 'started' as const, subscription: next }, - } - }, - ) - }, - - async claimActivation(challengeId) { - return store.update(credentialKey(challengeId), (current) => { - // Challenge IDs are single-use for activation credentials. - if (current) return { op: 'noop', result: false } - return { - op: 'set', - value: { claimedAt: new Date().toISOString() }, - result: true, - } - }) - }, - - async commitActivation(subscription, challengeId) { - const claimed = await store.update(activationKey(subscription.lookupKey), (current) => { - const marker = current as { challengeId?: string; startedAt?: string } | null + const result = await create() + const { subscription } = result + const committed = await store.update(activationKey(subscription.lookupKey), (current) => { + const marker = current as ActivationMarker | null if (marker?.challengeId !== challengeId) return { op: 'noop', result: false } return { op: 'set', - value: { ...marker, committingAt: new Date().toISOString() }, + value: { ...marker, committingAt: timestamp() }, result: true, } }) - if (!claimed) return false + if (!committed) return { status: 'claimMismatch' } await store.put(recordKey(subscription.subscriptionId), subscription) - await store.put(lookupKey(subscription.lookupKey), subscription.subscriptionId) + await store.put(lookupRecordKey(subscription.lookupKey), subscription.subscriptionId) await store.update(activationKey(subscription.lookupKey), (current) => { - const marker = current as { challengeId?: string } | null + const marker = current as ActivationMarker | null if (marker?.challengeId !== challengeId) return { op: 'noop', result: undefined } return { op: 'delete', result: undefined } }) - return true - }, - - async commitRenewal(subscriptionId, subscription, periodIndex) { - const committed = await store.update(recordKey(subscriptionId), (current) => { - const existing = current as SubscriptionRecord | null - if (!existing || existing.inFlightPeriod !== periodIndex) { - return { op: 'noop', result: false } - } - - return { - op: 'set', - value: { - ...subscription, - inFlightPeriod: undefined, - inFlightReference: undefined, - inFlightStartedAt: undefined, - lastChargedPeriod: periodIndex, - subscriptionId, - }, - result: true, - } - }) - if (committed) await store.put(lookupKey(subscription.lookupKey), subscriptionId) - return committed - }, - - async failRenewal(subscriptionId, periodIndex) { - await store.update(recordKey(subscriptionId), (current) => { - const subscription = current as SubscriptionRecord | null - if (!subscription || subscription.inFlightPeriod !== periodIndex) { - return { op: 'noop', result: undefined } - } - return { - op: 'set', - value: { - ...subscription, - inFlightPeriod: undefined, - inFlightReference: undefined, - inFlightStartedAt: undefined, - }, - result: undefined, - } - }) + return { status: 'activated', result } }, async get(subscriptionId) { @@ -233,7 +191,6 @@ export function fromStore( return (await store.get(accessKeyKey(key))) as SubscriptionAccessKeyRecord | null }, - /** Looks up the active subscription for a resolved request key. */ async getByKey(key) { return getByLookupKey(key) }, @@ -257,10 +214,82 @@ export function fromStore( ) }, - /** Upserts a subscription record and marks it as active for its lookup key. */ async put(record) { await store.put(recordKey(record.subscriptionId), record) - await store.put(lookupKey(record.lookupKey), record.subscriptionId) + await store.put(lookupRecordKey(record.lookupKey), record.subscriptionId) + }, + + async renew({ inFlightReference, periodIndex, renew, subscriptionId }) { + const started = await store.update( + recordKey(subscriptionId), + ( + current, + ): Store.Change< + unknown, + | { status: 'started'; subscription: SubscriptionRecord } + | { status: 'charged'; subscription: SubscriptionRecord } + | { status: 'inFlight'; subscription: SubscriptionRecord } + | { status: 'missing' } + > => { + const subscription = current as SubscriptionRecord | null + if (!subscription) return { op: 'noop', result: { status: 'missing' as const } } + if (subscription.lastChargedPeriod >= periodIndex) { + return { + op: 'noop', + result: { status: 'charged' as const, subscription }, + } + } + if (subscription.inFlightPeriod === periodIndex) { + return { + op: 'noop', + result: { status: 'inFlight' as const, subscription }, + } + } + + const next = { + ...subscription, + inFlightPeriod: periodIndex, + inFlightReference, + inFlightStartedAt: timestamp(), + } + return { + op: 'set', + value: next, + result: { status: 'started' as const, subscription: next }, + } + }, + ) + if (started.status !== 'started') return started + + const result = await renew({ + inFlightReference, + periodIndex, + subscription: started.subscription, + }).catch(async (error) => { + await clearRenewalState(subscriptionId, periodIndex) + throw error + }) + + const committed = await store.update(recordKey(subscriptionId), (current) => { + const existing = current as SubscriptionRecord | null + if (!existing || existing.inFlightPeriod !== periodIndex) { + return { op: 'noop', result: false } + } + + return { + op: 'set', + value: clearRenewal({ + ...result.subscription, + lastChargedPeriod: periodIndex, + subscriptionId, + }), + result: true, + } + }) + if (!committed) return { status: 'claimMismatch' } + + await store.put(lookupRecordKey(result.subscription.lookupKey), subscriptionId) + return { status: 'renewed', result } }, } } @@ -288,3 +317,16 @@ function isStaleActivation(marker: { startedAt?: string }, timeoutMs: number) { if (!Number.isFinite(startedAt)) return true return Date.now() - startedAt >= timeoutMs } + +function clearRenewal(subscription: SubscriptionRecord): SubscriptionRecord { + return { + ...subscription, + inFlightPeriod: undefined, + inFlightReference: undefined, + inFlightStartedAt: undefined, + } +} + +function timestamp() { + return new Date().toISOString() +} diff --git a/src/tempo/subscription/index.ts b/src/tempo/subscription/index.ts index 8aba22d7..aa4bceeb 100644 --- a/src/tempo/subscription/index.ts +++ b/src/tempo/subscription/index.ts @@ -11,7 +11,7 @@ export { verifySubscriptionKeyAuthorization, } from './KeyAuthorization.js' export { fromStore } from './Store.js' -export type { BeginRenewalResult, SubscriptionStore } from './Store.js' +export type { ActivateResult, RenewResult, SubscriptionStore } from './Store.js' export type { SubscriptionAccessKey, SubscriptionAccessKeyRecord, From 72cd2d5dd50481d7fdecdbb11a7eb9a83c30fd4b Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 8 May 2026 13:46:18 -0700 Subject: [PATCH 3/8] fix: address cyclops subscription findings --- .changeset/harden-tempo-subscriptions.md | 2 +- src/Method.ts | 3 + src/middlewares/elysia.test.ts | 32 +++- src/middlewares/elysia.ts | 13 ++ src/middlewares/hono.test.ts | 31 +++- src/middlewares/hono.ts | 13 ++ src/middlewares/nextjs.test.ts | 29 +++- src/middlewares/nextjs.ts | 13 ++ src/proxy/Service.test.ts | 33 ++++ src/proxy/Service.ts | 6 + src/server/Mppx.test.ts | 33 +++- src/server/Mppx.ts | 54 ++++++- src/tempo/server/Subscription.test.ts | 151 +++++++++++++++++- src/tempo/server/Subscription.ts | 78 ++++++--- .../subscription/KeyAuthorization.test.ts | 19 +++ src/tempo/subscription/KeyAuthorization.ts | 3 + src/tempo/subscription/Store.test.ts | 86 ++++++++++ src/tempo/subscription/Store.ts | 29 +++- src/tempo/subscription/Types.ts | 2 +- 19 files changed, 599 insertions(+), 31 deletions(-) diff --git a/.changeset/harden-tempo-subscriptions.md b/.changeset/harden-tempo-subscriptions.md index fc2e6899..a868be51 100644 --- a/.changeset/harden-tempo-subscriptions.md +++ b/.changeset/harden-tempo-subscriptions.md @@ -2,4 +2,4 @@ 'mppx': patch --- -Added Tempo subscription key authorization, activation replay protection, renewal idempotency, and dynamic access key handling. +Added Tempo subscription key authorization, subscription receipt identifiers, activation replay protection, renewal idempotency, and dynamic access key handling. diff --git a/src/Method.ts b/src/Method.ts index e24519f4..45ddd38e 100755 --- a/src/Method.ts +++ b/src/Method.ts @@ -166,6 +166,9 @@ export type RequestFn = ( * * **HTTP-only.** The `input` parameter is a Fetch `Request`; non-HTTP transports * do not invoke this hook. + * + * Transports that require credential context for `withReceipt()` should return a + * `response` from this hook so adapters can short-circuit protected handlers. */ export type AuthorizeFn = (parameters: { challenge: Challenge.Challenge< diff --git a/src/middlewares/elysia.test.ts b/src/middlewares/elysia.test.ts index dd5108a5..5b2ccdec 100644 --- a/src/middlewares/elysia.test.ts +++ b/src/middlewares/elysia.test.ts @@ -3,7 +3,7 @@ import * as http from 'node:http' import { Elysia } from 'elysia' import { Receipt } from 'mppx' import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client' -import { Mppx, discovery } from 'mppx/elysia' +import { Mppx, discovery, payment } from 'mppx/elysia' import { tempo as tempo_server } from 'mppx/server' import type { Address } from 'viem' import { Addresses } from 'viem/tempo' @@ -35,6 +35,36 @@ function createServer(app: Elysia) { const secretKey = 'test-secret-key' +describe('payment', () => { + test('short-circuits management responses', async () => { + let handlerRan = false + const intent = () => async () => ({ + status: 200 as const, + withReceipt: () => + new Response(null, { + headers: { 'Payment-Receipt': 'management-receipt' }, + status: 204, + }), + }) + + const app = new Elysia().guard({ beforeHandle: payment(intent as any, {} as any) }, (app) => + app.get('/', () => { + handlerRan = true + return { data: 'content' } + }), + ) + + const server = await createServer(app) + const response = await globalThis.fetch(server.url) + expect(response.status).toBe(204) + expect(response.headers.get('Payment-Receipt')).toBe('management-receipt') + expect(await response.text()).toBe('') + expect(handlerRan).toBe(false) + + server.close() + }) +}) + function createChargeHarness(feePayer: boolean) { const mppx = Mppx.create({ methods: [ diff --git a/src/middlewares/elysia.ts b/src/middlewares/elysia.ts index 6fe42893..393dbafa 100644 --- a/src/middlewares/elysia.ts +++ b/src/middlewares/elysia.ts @@ -64,12 +64,25 @@ export function payment( return async ({ request, set }) => { const result = await intent(options)(request) if (result.status === 402) return result.challenge + const managementResponse = getManagementResponse(result) + if (managementResponse) return managementResponse const receipt = result.withReceipt(new Response()) const header = receipt.headers.get('Payment-Receipt') if (header) set.headers['Payment-Receipt'] = header } } +function getManagementResponse(result: { withReceipt: (response?: Response) => Response }) { + try { + return result.withReceipt() + } catch (error) { + if (error instanceof Error && error.message === 'withReceipt() requires a response argument') { + return null + } + throw error + } +} + export type DiscoveryConfig = Omit & { path?: string routes?: RouteConfig[] diff --git a/src/middlewares/hono.test.ts b/src/middlewares/hono.test.ts index 23a56e81..8983c56c 100644 --- a/src/middlewares/hono.test.ts +++ b/src/middlewares/hono.test.ts @@ -2,7 +2,7 @@ import { serve } from '@hono/node-server' import { Hono } from 'hono' import { Challenge, Credential, Method, Receipt, z } from 'mppx' import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client' -import { Mppx, discovery } from 'mppx/hono' +import { Mppx, discovery, payment } from 'mppx/hono' import { tempo as tempo_server } from 'mppx/server' import type { Address } from 'viem' import { Addresses } from 'viem/tempo' @@ -26,6 +26,35 @@ function createServer(app: Hono) { const secretKey = 'test-secret-key' +describe('payment', () => { + test('short-circuits management responses', async () => { + let handlerRan = false + const intent = () => async () => ({ + status: 200 as const, + withReceipt: () => + new Response(null, { + headers: { 'Payment-Receipt': 'management-receipt' }, + status: 204, + }), + }) + + const app = new Hono() + app.get('/', payment(intent as any, {} as any), (c) => { + handlerRan = true + return c.json({ data: 'content' }) + }) + + const server = await createServer(app) + const response = await globalThis.fetch(server.url) + expect(response.status).toBe(204) + expect(response.headers.get('Payment-Receipt')).toBe('management-receipt') + expect(await response.text()).toBe('') + expect(handlerRan).toBe(false) + + server.close() + }) +}) + const scopeMethod = Method.toServer( Method.from({ name: 'mock', diff --git a/src/middlewares/hono.ts b/src/middlewares/hono.ts index df25e1f6..ef2c39ad 100644 --- a/src/middlewares/hono.ts +++ b/src/middlewares/hono.ts @@ -63,11 +63,24 @@ export function payment( : c.req.raw const result = await intent(options)(request) if (result.status === 402) return result.challenge + const managementResponse = getManagementResponse(result) + if (managementResponse) return managementResponse await next() c.res = result.withReceipt(c.res) } } +function getManagementResponse(result: { withReceipt: (response?: Response) => Response }) { + try { + return result.withReceipt() + } catch (error) { + if (error instanceof Error && error.message === 'withReceipt() requires a response argument') { + return null + } + throw error + } +} + export type DiscoveryConfig = Omit & { auto?: boolean path?: string diff --git a/src/middlewares/nextjs.test.ts b/src/middlewares/nextjs.test.ts index 3db1cff3..d93eaa86 100644 --- a/src/middlewares/nextjs.test.ts +++ b/src/middlewares/nextjs.test.ts @@ -2,7 +2,7 @@ import * as http from 'node:http' import { Challenge, Credential, Receipt } from 'mppx' import { Mppx as Mppx_client, session as sessionIntent, tempo as tempo_client } from 'mppx/client' -import { Mppx, discovery } from 'mppx/nextjs' +import { Mppx, discovery, payment } from 'mppx/nextjs' import { tempo as tempo_server } from 'mppx/server' import type { Address } from 'viem' import { Addresses } from 'viem/tempo' @@ -34,6 +34,33 @@ function createServer(handler: (request: Request) => Promise | Respons const secretKey = 'test-secret-key' +describe('payment', () => { + test('short-circuits management responses', async () => { + let handlerRan = false + const intent = () => async () => ({ + status: 200 as const, + withReceipt: () => + new Response(null, { + headers: { 'Payment-Receipt': 'management-receipt' }, + status: 204, + }), + }) + const handler = payment(intent as any, {} as any, () => { + handlerRan = true + return Response.json({ data: 'content' }) + }) + + const server = await createServer(handler) + const response = await globalThis.fetch(server.url) + expect(response.status).toBe(204) + expect(response.headers.get('Payment-Receipt')).toBe('management-receipt') + expect(await response.text()).toBe('') + expect(handlerRan).toBe(false) + + server.close() + }) +}) + function createChargeHarness(feePayer: boolean) { const mppx = Mppx.create({ methods: [ diff --git a/src/middlewares/nextjs.ts b/src/middlewares/nextjs.ts index 89eb4181..14750a0c 100644 --- a/src/middlewares/nextjs.ts +++ b/src/middlewares/nextjs.ts @@ -61,11 +61,24 @@ export function payment( return async (request) => { const result = await intent(options)(request) if (result.status === 402) return result.challenge + const managementResponse = getManagementResponse(result) + if (managementResponse) return managementResponse const response = await handler(request) return result.withReceipt(response) } } +function getManagementResponse(result: { withReceipt: (response?: Response) => Response }) { + try { + return result.withReceipt() + } catch (error) { + if (error instanceof Error && error.message === 'withReceipt() requires a response argument') { + return null + } + throw error + } +} + export type DiscoveryConfig = Omit & { routes?: RouteConfig[] } diff --git a/src/proxy/Service.test.ts b/src/proxy/Service.test.ts index 9fcc2141..bfb26c24 100644 --- a/src/proxy/Service.test.ts +++ b/src/proxy/Service.test.ts @@ -122,6 +122,39 @@ describe('paymentOf', () => { }) expect(Service.paymentOf({ pay: handler, options: {} })).toBeNull() }) + + test('behavior: strips function internals from payment metadata', () => { + const handler = Object.assign( + async () => ({ + status: 200 as const, + withReceipt: (r: T) => r, + }), + { + _internal: { + _canonicalRequest: () => ({}), + _stableBinding: () => ({}), + amount: '1', + authorize: () => undefined, + decimals: 6, + defaults: {}, + intent: 'charge', + name: 'mock', + request: () => ({}), + respond: () => undefined, + schema: {}, + transport: {}, + verify: () => undefined, + }, + }, + ) + + expect(Service.paymentOf(handler as never)).toEqual({ + amount: '1000000', + decimals: 6, + intent: 'charge', + method: 'mock', + }) + }) }) describe('getOptions', () => { diff --git a/src/proxy/Service.ts b/src/proxy/Service.ts index 04b422d9..518cdd61 100644 --- a/src/proxy/Service.ts +++ b/src/proxy/Service.ts @@ -216,6 +216,12 @@ export function paymentOf(endpoint: Endpoint): Record | null { defaults: _, schema: _s, _canonicalRequest, + _stableBinding: _sb, + authorize: _a, + request: _r, + respond: _re, + transport: _t, + verify: _v, ...rest } = handler._internal as Record const amount = (() => { diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index 90d7e7e1..cc1da7eb 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -1,6 +1,6 @@ import * as http from 'node:http' -import { Challenge, Credential, Method, z } from 'mppx' +import { Challenge, Credential, Errors, Method, z } from 'mppx' import { Mppx as Mppx_client, session as tempo_session_client, @@ -3252,6 +3252,37 @@ describe('challenge', () => { expect(challenge.request.methodDetails).toEqual({ chainId: 42431 }) }) + test('request hook payment errors are normalized to 402 responses', async () => { + const errorMethod = Method.toServer( + Method.from({ + name: 'error', + intent: 'charge', + schema: { + credential: { payload: z.object({ token: z.string() }) }, + request: z.object({ amount: z.string() }), + }, + }), + { + request() { + throw new Errors.VerificationFailedError({ reason: 'request rejected' }) + }, + async verify() { + return mockReceipt('error') + }, + }, + ) + const mppx = Mppx.create({ methods: [errorMethod], realm, secretKey }) + + const result = await mppx.error.charge({ amount: '1' })( + new Request('https://example.com/resource'), + ) + + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error('expected challenge') + const body = (await result.challenge.json()) as { detail?: string } + expect(body.detail).toBe('Payment verification failed: request rejected.') + }) + test('challenge produced by mppx.challenge is accepted by the 402 handler', async () => { const mppx = Mppx.create({ methods: [alphaChargeServer], diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index dd99fa95..cb7992db 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -53,6 +53,8 @@ export type Mppx< * server methods passed to `Mppx.create()`, looked up by `name`+`intent`. * * Only available on HTTP transports. + * No-credential authorize hooks run in entry order; the first 200 response + * wins, and earlier hooks may have already run side effects. * * @example * ```ts @@ -105,6 +107,9 @@ export type Mppx< * HMAC-check, match to a registered method, validate payload schema, * check expiry, and call the method's verify function. * + * Method verification can settle payments and persist state. For example, + * subscription credentials may activate or renew a subscription. + * * @example * ```ts * const receipt = await mppx.verifyCredential('eyJjaGFsbGVuZ2...') @@ -462,7 +467,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R return [null, e as Error] as const } })() - const { challenge, request } = await resolveRouteChallenge({ + const routeChallenge = await resolveRouteChallenge({ capturedRequest, credential, defaults, @@ -474,7 +479,29 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R request: parameters.request, routeRequest: rest, secretKey, + }).catch(async (e) => { + if (!(e instanceof Errors.PaymentError)) throw e + const challenge = createFallbackChallenge({ + capturedRequest, + defaults: defaults ?? {}, + description, + expires, + meta: effectiveMeta, + method, + realm, + routeRequest: rest, + secretKey, + }) + const response = await transport.respondChallenge({ + challenge, + input, + error: e, + html: method.html, + }) + return { response } }) + if ('response' in routeChallenge) return { challenge: routeChallenge.response, status: 402 } + const { challenge, request } = routeChallenge // Credential was provided but malformed if (credentialError) { @@ -859,6 +886,31 @@ async function resolveRouteChallenge(parameters: { } } +function createFallbackChallenge(parameters: { + capturedRequest?: Method.CapturedRequest | undefined + defaults: Record + description?: string | undefined + expires?: string | undefined + meta?: Record | undefined + method: Method.Method + realm?: string | undefined + routeRequest: Record + secretKey: string +}) { + return Challenge.fromMethod(parameters.method, { + description: parameters.description, + expires: parameters.expires, + meta: parameters.meta, + realm: + parameters.realm ?? + (parameters.capturedRequest + ? resolveRealmFromCapturedRequest(parameters.capturedRequest) + : defaultRealm), + request: { ...parameters.defaults, ...parameters.routeRequest } as never, + secretKey: parameters.secretKey, + }) +} + /** * Captures the transport request into a frozen snapshot at the start of the * verification flow. This snapshot is threaded through request() → verify() → diff --git a/src/tempo/server/Subscription.test.ts b/src/tempo/server/Subscription.test.ts index d38dbe18..b50edd48 100644 --- a/src/tempo/server/Subscription.test.ts +++ b/src/tempo/server/Subscription.test.ts @@ -312,6 +312,7 @@ describe('tempo.subscription', () => { await subscriptions.put({ ...record, billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(), + keyAuthorization: undefined, lastChargedPeriod: 0, reference: hashStale, }) @@ -326,6 +327,40 @@ describe('tempo.subscription', () => { expect((await subscriptions.get(record.subscriptionId))?.lastChargedPeriod).toBeGreaterThan(0) }) + test('does not authorize an active subscription whose request binding differs', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + await subscriptions.put( + createRecord({ + accessKey, + amount: '1000000', + lookupKey: subscriptionKey, + subscriptionId: 'sub_basic', + }), + ) + const method = subscription({ + accessKey: async () => accessKey, + activate: async () => ({ + receipt: createReceipt('sub_unused'), + subscription: createRecord({ subscriptionId: 'sub_unused' }), + }), + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + + const result = await mppx.tempo.subscription({})(new Request('https://example.com/resource')) + + expect(result.status).toBe(402) + }) + test('requires an access key before issuing a subscription challenge', async () => { const method = subscription({ activate: async () => ({ @@ -344,9 +379,11 @@ describe('tempo.subscription', () => { }) const mppx = Mppx.create({ methods: [method], realm, secretKey }) - await expect( - mppx.tempo.subscription({})(new Request('https://example.com/resource')), - ).rejects.toThrow('subscription accessKey is missing') + const result = await mppx.tempo.subscription({})(new Request('https://example.com/resource')) + expect(result.status).toBe(402) + if (result.status !== 402) throw new Error('expected challenge') + const body = (await result.challenge.json()) as { detail?: string } + expect(body.detail).toBe('Payment verification failed: subscription accessKey is missing.') }) test('defaults omitted subscription chainId to Tempo testnet', async () => { @@ -625,6 +662,114 @@ describe('tempo.subscription', () => { expect(current?.subscriptionId).toBe('sub_new') }) + test('does not reuse an active subscription whose request binding differs during verify', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + let activationCount = 0 + await subscriptions.put( + createRecord({ + accessKey, + amount: '1000000', + lookupKey: subscriptionKey, + subscriptionId: 'sub_basic', + }), + ) + const method = subscription({ + accessKey: async () => accessKey, + activate: async ({ request, resolved }) => { + activationCount += 1 + return { + receipt: createReceipt('sub_premium'), + subscription: createRecord({ + amount: request.amount, + chainId: request.methodDetails?.chainId, + currency: request.currency, + lookupKey: resolved.key, + periodCount: request.periodCount, + periodUnit: request.periodUnit, + recipient: request.recipient, + subscriptionExpires: request.subscriptionExpires, + subscriptionId: 'sub_premium', + }), + } + }, + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const challengeResult = await mppx.tempo.subscription({})( + new Request('https://example.com/resource'), + ) + if (challengeResult.status !== 402) throw new Error('expected activation challenge') + + const credential = await createCredential(Challenge.fromResponse(challengeResult.challenge)) + const activated = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(activated.status).toBe(200) + expect(activationCount).toBe(1) + if (activated.status !== 200) throw new Error('expected activation') + const receipt = Receipt.fromResponse(activated.withReceipt(new Response('OK'))) + expect(receipt.subscriptionId).toBe('sub_premium') + }) + + test('does not reuse an overdue subscription during verify', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + let activationCount = 0 + await subscriptions.put( + createRecord({ + accessKey, + billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(), + lastChargedPeriod: 0, + lookupKey: subscriptionKey, + reference: hashStale, + subscriptionId: 'sub_due', + }), + ) + const method = subscription({ + accessKey: async () => accessKey, + activate: async () => { + activationCount += 1 + throw new Error('overdue subscription must renew') + }, + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const challengeResult = await mppx.tempo.subscription({})( + new Request('https://example.com/resource'), + ) + if (challengeResult.status !== 402) throw new Error('expected activation challenge') + + const credential = await createCredential(Challenge.fromResponse(challengeResult.challenge)) + const rejected = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(rejected.status).toBe(402) + expect(activationCount).toBe(1) + }) + test('rejects activation when the dynamic access key does not match the credential', async () => { const store = Store.memory() const activateCalls: unknown[] = [] diff --git a/src/tempo/server/Subscription.ts b/src/tempo/server/Subscription.ts index c6b9d2bb..59002f2f 100644 --- a/src/tempo/server/Subscription.ts +++ b/src/tempo/server/Subscription.ts @@ -70,6 +70,7 @@ export function subscription( const store = SubscriptionStore.fromStore(rawStore, { activationTimeoutMs: parameters.activationTimeoutMs, + renewalTimeoutMs: parameters.renewalTimeoutMs, }) const { recipient } = Account.resolve(parameters) const getClient = ClientResolver.getResolver({ @@ -98,6 +99,7 @@ export function subscription( const subscription = await store.getByKey(resolved.key) if (!subscription || !isActive(subscription)) return undefined + if (!subscriptionMatchesRequest(subscription, request)) return undefined const periodIndex = getPeriodIndex(subscription) if (periodIndex > subscription.lastChargedPeriod) { @@ -215,7 +217,7 @@ export function subscription( const activation = await store.activate({ challengeId: credential.challenge.id, - isReusable: isActive, + isReusable: (subscription) => isReusableSubscription(subscription, parsedRequest), lookupKey: resolved.key, async create() { const activation = withSubscriptionAccessKey( @@ -509,6 +511,43 @@ function isActive(subscription: SubscriptionRecord): boolean { return new Date(subscription.subscriptionExpires).getTime() > Date.now() } +function isReusableSubscription( + subscription: SubscriptionRecord, + request: SubscriptionRequest, +): boolean { + return ( + isActive(subscription) && + getPeriodIndex(subscription) <= subscription.lastChargedPeriod && + subscriptionMatchesRequest(subscription, request) + ) +} + +function subscriptionMatchesRequest( + subscription: SubscriptionRecord, + request: SubscriptionRequest, +): boolean { + const actual = comparableSubscriptionBinding(subscription) + const expected = comparableSubscriptionBinding(request) + return (Object.keys(expected) as (keyof typeof expected)[]).every( + (key) => actual[key] === expected[key], + ) +} + +function comparableSubscriptionBinding(value: SubscriptionRecord | SubscriptionRequest) { + const chainId = + 'chainId' in value ? value.chainId : (value as SubscriptionRequest).methodDetails?.chainId + return { + amount: value.amount, + chainId, + currency: value.currency.toLowerCase(), + externalId: value.externalId, + periodCount: value.periodCount, + periodUnit: value.periodUnit, + recipient: value.recipient.toLowerCase(), + subscriptionExpires: value.subscriptionExpires, + } +} + function validateSubscriptionSettlement( result: subscription.ActivationResult | subscription.RenewalResult, options: { @@ -588,17 +627,7 @@ function assertSubscriptionRequestMatch( subscription: SubscriptionRecord, request: SubscriptionRequest, ) { - const matches = - subscription.amount === request.amount && - subscription.chainId === request.methodDetails?.chainId && - subscription.currency.toLowerCase() === request.currency.toLowerCase() && - subscription.externalId === request.externalId && - subscription.periodCount === request.periodCount && - subscription.periodUnit === request.periodUnit && - subscription.recipient.toLowerCase() === request.recipient.toLowerCase() && - subscription.subscriptionExpires === request.subscriptionExpires - - if (!matches) { + if (!subscriptionMatchesRequest(subscription, request)) { throw new VerificationFailedError({ reason: 'subscription record does not match request' }) } } @@ -671,13 +700,11 @@ function resolveRenewalHandler(parameters: { waitForConfirmation, } = parameters if (subscriptionParameters.renew) return subscriptionParameters.renew - if (!subscription.accessKey || !subscription.keyAuthorization || !subscription.payer) - return undefined + if (!subscription.accessKey || !subscription.payer) return undefined return async ({ inFlightReference, periodIndex, subscription }) => { const reference = await submitSubscriptionPayment({ accessKey: subscription.accessKey!, getClient, - keyAuthorization: subscription.keyAuthorization!, lookupKey: subscription.lookupKey, memoServerId: subscription.lookupKey, request: subscription, @@ -702,7 +729,7 @@ function resolveRenewalHandler(parameters: { async function submitSubscriptionPayment(parameters: { accessKey: SubscriptionAccessKey getClient: (parameters: { chainId?: number | undefined }) => MaybePromise - keyAuthorization: `0x${string}` + keyAuthorization?: `0x${string}` | undefined lookupKey: string memoServerId: string request: Pick & { @@ -758,7 +785,9 @@ async function submitSubscriptionPayment(parameters: { }, ], chainId, - keyAuthorization: KeyAuthorization.deserialize(keyAuthorization), + ...(keyAuthorization + ? { keyAuthorization: KeyAuthorization.deserialize(keyAuthorization) } + : {}), } as never) const transaction = Transaction.deserialize( serializedTransaction as Transaction.TransactionSerializedTempo, @@ -800,7 +829,9 @@ function createSubscriptionId() { */ export async function renew(parameters: renew.Parameters): Promise { const { store: rawStore, waitForConfirmation = true } = parameters - const store = SubscriptionStore.fromStore(rawStore) + const store = SubscriptionStore.fromStore(rawStore, { + renewalTimeoutMs: parameters.renewalTimeoutMs, + }) const getClient = ClientResolver.getResolver({ chain: tempo_chain, getClient: parameters.getClient, @@ -849,6 +880,11 @@ export declare namespace renew { | undefined /** Store containing subscription records. */ store: Store.AtomicStore> + /** + * Milliseconds before an in-flight renewal lock can be replaced. + * Keeps concurrent renewal safe while allowing recovery from abandoned attempts. + */ + renewalTimeoutMs?: number | undefined waitForConfirmation?: boolean | undefined } & Client.getResolver.Parameters @@ -893,8 +929,14 @@ export declare namespace subscription { * Keeps concurrent activation safe while allowing recovery from abandoned attempts. */ activationTimeoutMs?: number | undefined + /** + * Milliseconds before an in-flight renewal lock can be replaced. + * Keeps concurrent renewal safe while allowing recovery from abandoned attempts. + */ + renewalTimeoutMs?: number | undefined activate?: | ((parameters: { + /** Custom activation must verify this access key matches the resolved subscription. */ accessKey: SubscriptionAccessKey credential: { payload: SubscriptionCredentialPayload diff --git a/src/tempo/subscription/KeyAuthorization.test.ts b/src/tempo/subscription/KeyAuthorization.test.ts index 66328a5d..91efb4ab 100644 --- a/src/tempo/subscription/KeyAuthorization.test.ts +++ b/src/tempo/subscription/KeyAuthorization.test.ts @@ -152,6 +152,25 @@ describe('tempo subscription key authorization', () => { ).toThrow('keyAuthorization access key mismatch') }) + test('requires transferWithMemo authorization', async () => { + const request = parseRequest() + const payload = await createPayload(request) + const authorization = KeyAuthorization.deserialize(payload.signature) + const transferOnly = KeyAuthorization.serialize({ + ...authorization, + scopes: authorization.scopes?.slice(0, 1), + }) + + expect(() => + verifySubscriptionKeyAuthorization({ + accessKey, + chainId: 4217, + payload: { ...payload, signature: transferOnly }, + request, + }), + ).toThrow('keyAuthorization must allow transferWithMemo') + }) + test('rejects subscription periods that cannot be represented by the Tempo client', () => { expect(() => toSubscriptionPeriodSeconds({ periodCount: '0', periodUnit: 'day' })).toThrow( 'periodCount is invalid', diff --git a/src/tempo/subscription/KeyAuthorization.ts b/src/tempo/subscription/KeyAuthorization.ts index 8022a876..44f4cf76 100644 --- a/src/tempo/subscription/KeyAuthorization.ts +++ b/src/tempo/subscription/KeyAuthorization.ts @@ -356,6 +356,9 @@ function assertAuthorizationScopes( if (!seen.has(transferSelector)) { throw new VerificationFailedError({ reason: 'keyAuthorization must allow transfer' }) } + if (!seen.has(transferWithMemoSelector)) { + throw new VerificationFailedError({ reason: 'keyAuthorization must allow transferWithMemo' }) + } } function recoverAuthorizationSource( diff --git a/src/tempo/subscription/Store.test.ts b/src/tempo/subscription/Store.test.ts index 16acf2d2..03a17e9e 100644 --- a/src/tempo/subscription/Store.test.ts +++ b/src/tempo/subscription/Store.test.ts @@ -100,6 +100,27 @@ describe('tempo subscription store', () => { expect(await first).toEqual({ status: 'claimMismatch' }) }) + test('clears the activation marker when creation fails', async () => { + const store = fromStore(Store.memory()) + + await expect( + store.activate({ + challengeId: 'challenge-1', + create: async () => { + throw new Error('activation failed') + }, + lookupKey: 'user-1:plan:pro', + }), + ).rejects.toThrow('activation failed') + + const retried = await store.activate({ + challengeId: 'challenge-2', + create: async () => ({ subscription: createRecord() }), + lookupKey: 'user-1:plan:pro', + }) + expect(retried.status).toBe('activated') + }) + test('tracks an in-flight renewal and commits it once', async () => { const store = fromStore(Store.memory()) await store.put(createRecord()) @@ -174,6 +195,41 @@ describe('tempo subscription store', () => { expect((await first).status).toBe('renewed') }) + test('replaces a stale in-flight renewal after the timeout', async () => { + const store = fromStore(Store.memory(), { renewalTimeoutMs: 0 }) + await store.put(createRecord()) + let finishRenewal!: () => void + const pendingRenewal = new Promise((resolve) => { + finishRenewal = resolve + }) + + const first = store.renew({ + inFlightReference: '0xfirst', + periodIndex: 1, + renew: async ({ subscription }) => { + await pendingRenewal + return { subscription } + }, + subscriptionId, + }) + + const second = await store.renew({ + inFlightReference: '0xsecond', + periodIndex: 1, + renew: async ({ subscription }) => ({ + subscription: { + ...subscription, + reference: `0x${'b'.repeat(64)}`, + }, + }), + subscriptionId, + }) + expect(second.status).toBe('renewed') + + finishRenewal() + expect(await first).toEqual({ status: 'claimMismatch' }) + }) + test('clears an in-flight renewal after failure', async () => { const store = fromStore(Store.memory()) await store.put(createRecord()) @@ -201,4 +257,34 @@ describe('tempo subscription store', () => { ).status, ).toBe('renewed') }) + + test('preserves cancellation that lands during an in-flight renewal', async () => { + const store = fromStore(Store.memory()) + await store.put(createRecord()) + + const renewed = await store.renew({ + inFlightReference: '0xrenewal', + periodIndex: 1, + renew: async ({ subscription }) => { + await store.put({ + ...subscription, + canceledAt: '2025-01-02T00:00:00.000Z', + }) + return { + subscription: { + ...subscription, + lastChargedPeriod: 1, + reference: `0x${'b'.repeat(64)}`, + }, + } + }, + subscriptionId, + }) + expect(renewed.status).toBe('renewed') + + const committed = await store.get(subscriptionId) + expect(committed?.canceledAt).toBe('2025-01-02T00:00:00.000Z') + expect(committed?.lastChargedPeriod).toBe(1) + expect(committed?.inFlightPeriod).toBe(undefined) + }) }) diff --git a/src/tempo/subscription/Store.ts b/src/tempo/subscription/Store.ts index 9b417308..836396bc 100644 --- a/src/tempo/subscription/Store.ts +++ b/src/tempo/subscription/Store.ts @@ -10,6 +10,7 @@ const defaultActivationPrefix = 'tempo:subscription:activation:' const defaultAccessKeyPrefix = 'tempo:subscription:access-key:' const defaultCredentialPrefix = 'tempo:subscription:credential:' const defaultActivationTimeoutMs = 15 * 60 * 1_000 +const defaultRenewalTimeoutMs = 15 * 60 * 1_000 /** Subscription-aware wrapper around a generic key-value store. */ export type SubscriptionStore = { @@ -82,6 +83,7 @@ export function fromStore( credentialPrefix = defaultCredentialPrefix, keyPrefix = defaultKeyPrefix, recordPrefix = defaultRecordPrefix, + renewalTimeoutMs = defaultRenewalTimeoutMs, } = options ?? {} function recordKey(subscriptionId: string): string { @@ -160,7 +162,14 @@ export function fromStore( ) if (started.status !== 'started') return { status: 'inFlight' } - const result = await create() + const result = await create().catch(async (error) => { + await store.update(activationKey(lookupKey), (current) => { + const marker = current as ActivationMarker | null + if (marker?.challengeId !== challengeId) return { op: 'noop', result: undefined } + return { op: 'delete', result: undefined } + }) + throw error + }) const { subscription } = result const committed = await store.update(activationKey(subscription.lookupKey), (current) => { const marker = current as ActivationMarker | null @@ -239,7 +248,10 @@ export function fromStore( result: { status: 'charged' as const, subscription }, } } - if (subscription.inFlightPeriod === periodIndex) { + if ( + subscription.inFlightPeriod === periodIndex && + !isStaleRenewal(subscription, renewalTimeoutMs) + ) { return { op: 'noop', result: { status: 'inFlight' as const, subscription }, @@ -276,10 +288,15 @@ export function fromStore( return { op: 'noop', result: false } } + const terminal = { + ...(existing.canceledAt ? { canceledAt: existing.canceledAt } : {}), + ...(existing.revokedAt ? { revokedAt: existing.revokedAt } : {}), + } return { op: 'set', value: clearRenewal({ ...result.subscription, + ...terminal, lastChargedPeriod: periodIndex, subscriptionId, }), @@ -306,18 +323,24 @@ export declare namespace fromStore { credentialPrefix?: string | undefined /** Key prefix for subscription records. @default `'tempo:subscription:record:'` */ recordPrefix?: string | undefined + /** Milliseconds before a stuck renewal lock can be replaced. @default `900000` */ + renewalTimeoutMs?: number | undefined /** Key prefix for resolved request keys. @default `'tempo:subscription:key:'` */ keyPrefix?: string | undefined } } -function isStaleActivation(marker: { startedAt?: string }, timeoutMs: number) { +function isStaleActivation(marker: { startedAt?: string | undefined }, timeoutMs: number) { if (!Number.isFinite(timeoutMs) || timeoutMs < 0) return false const startedAt = new Date(marker.startedAt ?? '').getTime() if (!Number.isFinite(startedAt)) return true return Date.now() - startedAt >= timeoutMs } +function isStaleRenewal(subscription: SubscriptionRecord, timeoutMs: number) { + return isStaleActivation({ startedAt: subscription.inFlightStartedAt }, timeoutMs) +} + function clearRenewal(subscription: SubscriptionRecord): SubscriptionRecord { return { ...subscription, diff --git a/src/tempo/subscription/Types.ts b/src/tempo/subscription/Types.ts index 232bc82b..e1b0b69f 100644 --- a/src/tempo/subscription/Types.ts +++ b/src/tempo/subscription/Types.ts @@ -32,7 +32,7 @@ export type SubscriptionRecord = { /** Stable idempotency/reconciliation reference for a renewal currently in progress. */ inFlightReference?: string | undefined inFlightStartedAt?: string | undefined - /** Signed key authorization used by automatic renewals. */ + /** Signed key authorization used to activate the access key. */ keyAuthorization?: `0x${string}` | undefined lastChargedPeriod: number lookupKey: string From 41f4d839c1e1cbca14f7520090db3f6e88bb1912 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Fri, 8 May 2026 15:38:56 -0700 Subject: [PATCH 4/8] fix: harden subscription renewal races --- src/server/Mppx.test.ts | 31 ++++++++ src/server/Mppx.ts | 22 +++--- src/tempo/server/Subscription.test.ts | 109 ++++++++++++++++++++++++++ src/tempo/server/Subscription.ts | 18 ++++- src/tempo/subscription/Store.test.ts | 96 +++++++++++++++++++++++ src/tempo/subscription/Store.ts | 56 ++++++++++--- 6 files changed, 310 insertions(+), 22 deletions(-) diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index cc1da7eb..4b16243f 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -1779,6 +1779,37 @@ describe('compose: pre-dispatch narrowing edge cases', () => { expect(result.status).toBe(402) }) + test('ignores compose candidates whose stable binding throws on forged credentials', async () => { + const bindingMethod = Method.toServer(mockCharge, { + stableBinding(request) { + return { currency: request.currency.toLowerCase() } + }, + async verify() { + return mockReceipt() + }, + }) + const mppx = Mppx.create({ methods: [bindingMethod], realm, secretKey }) + const handle = mppx.compose([bindingMethod, challengeOpts]) + const credential = Credential.from({ + challenge: { + id: 'forged', + intent: 'charge', + method: 'alpha', + realm, + request: {}, + }, + payload: { token: 'valid' }, + }) + + const result = await handle( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(result.status).toBe(402) + }) + test('single handler in compose() returns 402 and then 200', async () => { const mppx = Mppx.create({ methods: [alphaMethod], realm, secretKey }) const handle = mppx.compose([alphaMethod, challengeOpts]) diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index cb7992db..91a26312 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -1310,16 +1310,20 @@ export function compose( // transformed fields (e.g. amount with decimals) match correctly. // Also checks inside methodDetails for fields moved there by transforms. const candidates = handlers.filter((h) => { - const internal = (h as ConfiguredHandler)._internal - if (!internal || internal.name !== credMethod || internal.intent !== credIntent) + try { + const internal = (h as ConfiguredHandler)._internal + if (!internal || internal.name !== credMethod || internal.intent !== credIntent) + return false + const mismatch = internal._stableBinding + ? getRequestBindingMismatch( + getStableBinding(internal._canonicalRequest, internal._stableBinding), + getStableBinding(credReq, internal._stableBinding), + ) + : getPinnedRequestBindingMismatch(internal._canonicalRequest, credReq) + return !mismatch && opaqueValuesMatch(internal.meta, credential.challenge.meta) + } catch { return false - const mismatch = internal._stableBinding - ? getRequestBindingMismatch( - getStableBinding(internal._canonicalRequest, internal._stableBinding), - getStableBinding(credReq, internal._stableBinding), - ) - : getPinnedRequestBindingMismatch(internal._canonicalRequest, credReq) - return !mismatch && opaqueValuesMatch(internal.meta, credential.challenge.meta) + } }) const match = diff --git a/src/tempo/server/Subscription.test.ts b/src/tempo/server/Subscription.test.ts index b50edd48..2bcd14e0 100644 --- a/src/tempo/server/Subscription.test.ts +++ b/src/tempo/server/Subscription.test.ts @@ -271,6 +271,36 @@ describe('tempo.subscription', () => { expect(rpcMethods.filter((method) => method === 'eth_sendRawTransaction')).toHaveLength(1) }) + test('verifyCredential activates a subscription credential with a canonical challenge request', async () => { + const store = Store.memory() + const { client } = createBillingClient([hashActivate]) + const method = subscription({ + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + getClient: async () => client, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + waitForConfirmation: false, + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const challenge = await mppx.challenge.tempo.subscription({}) + const generatedAccessKey = ( + challenge.request as ReturnType + ).methodDetails?.accessKey + if (!generatedAccessKey) throw new Error('expected generated access key') + const credential = await createCredential(challenge, rootAccount.address, generatedAccessKey) + + const receipt = await mppx.verifyCredential(credential) + + expect(receipt.status).toBe('success') + expect(receipt.reference).toBe(hashActivate) + }) + test('automatically renews overdue subscriptions on the request path', async () => { const store = Store.memory() const subscriptions = SubscriptionStore.fromStore(store) @@ -327,6 +357,48 @@ describe('tempo.subscription', () => { expect((await subscriptions.get(record.subscriptionId))?.lastChargedPeriod).toBeGreaterThan(0) }) + test('returns a management response while renewal is already in flight', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + const method = subscription({ + accessKey: async () => accessKey, + activate: async () => ({ + receipt: createReceipt('unused'), + subscription: createRecord({ subscriptionId: 'unused' }), + }), + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + resolve: async () => ({ key: subscriptionKey }), + renew: async () => { + throw new Error('renew should not run') + }, + store, + subscriptionExpires: activeSubscriptionExpires, + }) + await subscriptions.put( + createRecord({ + billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(), + inFlightPeriod: 1, + inFlightStartedAt: new Date().toISOString(), + lastChargedPeriod: 0, + subscriptionId: '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) + if (result.status !== 200) throw new Error('expected management response') + const response = (result.withReceipt as () => Response)() + expect(response.status).toBe(409) + expect(response.headers.get('Retry-After')).toBe('1') + }) + test('does not authorize an active subscription whose request binding differs', async () => { const store = Store.memory() const subscriptions = SubscriptionStore.fromStore(store) @@ -1203,6 +1275,43 @@ describe('tempo.subscription', () => { expect((await subscriptions.get('sub_background'))?.reference).toBe(hashBackground) }) + test('does not charge a superseded subscription outside the request path', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + let renewCalls = 0 + + await subscriptions.put( + createRecord({ + billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(), + lastChargedPeriod: 0, + reference: hashStale, + subscriptionId: 'sub_old', + }), + ) + await subscriptions.put( + createRecord({ + reference: hashBackground, + subscriptionId: 'sub_new', + }), + ) + + const result = await renew({ + renew: async ({ subscription }) => { + renewCalls += 1 + return { + receipt: createReceipt(subscription.subscriptionId, hashBackground), + subscription, + } + }, + store, + subscriptionId: 'sub_old', + }) + + expect(result).toBe(null) + expect(renewCalls).toBe(0) + expect((await subscriptions.getByKey(subscriptionKey))?.subscriptionId).toBe('sub_new') + }) + test('automatically renews an overdue subscription outside the request path', async () => { const store = Store.memory() const subscriptions = SubscriptionStore.fromStore(store) diff --git a/src/tempo/server/Subscription.ts b/src/tempo/server/Subscription.ts index 59002f2f..efa93bea 100644 --- a/src/tempo/server/Subscription.ts +++ b/src/tempo/server/Subscription.ts @@ -122,6 +122,15 @@ export function subscription( }) if (!renewal) return undefined if (renewal.status === 'charged') return { receipt: renewal.receipt } + if (renewal.status === 'inFlight') { + return { + receipt: renewal.receipt, + response: new Response(null, { + headers: { 'Retry-After': '1' }, + status: 409, + }), + } + } await parameters.hooks?.renewed?.({ periodIndex, @@ -182,7 +191,8 @@ export function subscription( async verify({ credential, envelope, request }) { const input = requestFromCaptured(envelope?.capturedRequest) - const parsedRequest = Methods.subscription.schema.request.parse(request) + const parsed = Methods.subscription.schema.request.safeParse(request) + const parsedRequest = parsed.success ? parsed.data : (request as SubscriptionRequest) assertSubscriptionTiming({ challengeExpires: credential.challenge.expires, request: parsedRequest, @@ -431,6 +441,7 @@ async function settleRenewal(parameters: { subscription: SubscriptionRecord }): Promise< | { status: 'charged'; receipt: SubscriptionReceiptValue } + | { status: 'inFlight'; receipt: SubscriptionReceiptValue } | { status: 'renewed'; result: subscription.RenewalResult } | null > { @@ -462,6 +473,9 @@ async function settleRenewal(parameters: { if (renewal.status === 'charged') { return { receipt: SubscriptionReceipt.fromRecord(renewal.subscription), status: 'charged' } } + if (renewal.status === 'inFlight') { + return { receipt: SubscriptionReceipt.fromRecord(renewal.subscription), status: 'inFlight' } + } if (renewal.status === 'renewed') return { result: renewal.result, status: 'renewed' } if (renewal.status === 'claimMismatch') { throw new VerificationFailedError({ reason: 'subscription renewal claim mismatch' }) @@ -841,6 +855,8 @@ export async function renew(parameters: renew.Parameters): Promise { expect(await first).toEqual({ status: 'claimMismatch' }) }) + test('rechecks the lookup key after claiming activation', async () => { + const rawStore = Store.memory() + const seeded = fromStore(rawStore) + const store = fromStore({ + ...rawStore, + async update(key, change) { + const result = await rawStore.update(key, change) + if (key === 'tempo:subscription:activation:user-1:plan:pro') { + await seeded.put(createRecord()) + } + return result + }, + }) + let createCalled = false + + const activated = await store.activate({ + challengeId: 'challenge-1', + create: async () => { + createCalled = true + return { subscription: createRecord({ subscriptionId: 'sub_new' }) } + }, + isReusable: () => true, + lookupKey: 'user-1:plan:pro', + }) + + expect(activated).toEqual({ status: 'existing', subscription: createRecord() }) + expect(createCalled).toBe(false) + }) + test('clears the activation marker when creation fails', async () => { const store = fromStore(Store.memory()) @@ -195,6 +224,27 @@ describe('tempo subscription store', () => { expect((await first).status).toBe('renewed') }) + test('returns in-flight when any non-stale renewal is active', async () => { + const store = fromStore(Store.memory()) + await store.put( + createRecord({ inFlightPeriod: 1, inFlightStartedAt: new Date().toISOString() }), + ) + let renewCalled = false + + const renewal = await store.renew({ + inFlightReference: '0xrenewal', + periodIndex: 2, + renew: async ({ subscription }) => { + renewCalled = true + return { subscription } + }, + subscriptionId, + }) + + expect(renewal.status).toBe('inFlight') + expect(renewCalled).toBe(false) + }) + test('replaces a stale in-flight renewal after the timeout', async () => { const store = fromStore(Store.memory(), { renewalTimeoutMs: 0 }) await store.put(createRecord()) @@ -287,4 +337,50 @@ describe('tempo subscription store', () => { expect(committed?.lastChargedPeriod).toBe(1) expect(committed?.inFlightPeriod).toBe(undefined) }) + + test('does not renew a superseded subscription record', async () => { + const store = fromStore(Store.memory()) + await store.put(createRecord({ subscriptionId: 'sub_old' })) + await store.put(createRecord({ reference: `0x${'b'.repeat(64)}`, subscriptionId: 'sub_new' })) + let renewCalled = false + + const renewal = await store.renew({ + inFlightReference: '0xrenewal', + periodIndex: 1, + renew: async ({ subscription }) => { + renewCalled = true + return { subscription } + }, + subscriptionId: 'sub_old', + }) + + expect(renewal.status).toBe('superseded') + expect(renewCalled).toBe(false) + expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe('sub_new') + }) + + test('does not reclaim lookup ownership when superseded during renewal', async () => { + const store = fromStore(Store.memory()) + await store.put(createRecord({ subscriptionId: 'sub_old' })) + + const renewal = await store.renew({ + inFlightReference: '0xrenewal', + periodIndex: 1, + renew: async ({ subscription }) => { + await store.put( + createRecord({ reference: `0x${'c'.repeat(64)}`, subscriptionId: 'sub_new' }), + ) + return { + subscription: { + ...subscription, + reference: `0x${'b'.repeat(64)}`, + }, + } + }, + subscriptionId: 'sub_old', + }) + + expect(renewal.status).toBe('superseded') + expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe('sub_new') + }) }) diff --git a/src/tempo/subscription/Store.ts b/src/tempo/subscription/Store.ts index 836396bc..6b5792ef 100644 --- a/src/tempo/subscription/Store.ts +++ b/src/tempo/subscription/Store.ts @@ -64,6 +64,7 @@ export type RenewResult = | { status: 'inFlight'; subscription: SubscriptionRecord } | { status: 'missing' } | { status: 'renewed'; result: result } + | { status: 'superseded'; subscription: SubscriptionRecord } | { status: 'claimMismatch' } type ActivationMarker = { @@ -126,6 +127,14 @@ export function fromStore( }) } + async function clearActivationState(lookupKey: string, challengeId: string) { + await store.update(activationKey(lookupKey), (current) => { + const marker = current as ActivationMarker | null + if (marker?.challengeId !== challengeId) return { op: 'noop', result: undefined } + return { op: 'delete', result: undefined } + }) + } + return { async activate({ challengeId, create, isReusable, lookupKey }) { const claimed = await store.update(credentialKey(challengeId), (current) => { @@ -162,12 +171,14 @@ export function fromStore( ) if (started.status !== 'started') return { status: 'inFlight' } + const claimedExisting = await getByLookupKey(lookupKey) + if (claimedExisting && isReusable?.(claimedExisting)) { + await clearActivationState(lookupKey, challengeId) + return { status: 'existing', subscription: claimedExisting } + } + const result = await create().catch(async (error) => { - await store.update(activationKey(lookupKey), (current) => { - const marker = current as ActivationMarker | null - if (marker?.challengeId !== challengeId) return { op: 'noop', result: undefined } - return { op: 'delete', result: undefined } - }) + await clearActivationState(lookupKey, challengeId) throw error }) const { subscription } = result @@ -182,13 +193,16 @@ export function fromStore( }) if (!committed) return { status: 'claimMismatch' } + const previous = await getByLookupKey(subscription.lookupKey) + if (previous && previous.subscriptionId !== subscription.subscriptionId) { + await store.put(recordKey(previous.subscriptionId), { + ...previous, + canceledAt: previous.canceledAt ?? timestamp(), + }) + } await store.put(recordKey(subscription.subscriptionId), subscription) await store.put(lookupRecordKey(subscription.lookupKey), subscription.subscriptionId) - await store.update(activationKey(subscription.lookupKey), (current) => { - const marker = current as ActivationMarker | null - if (marker?.challengeId !== challengeId) return { op: 'noop', result: undefined } - return { op: 'delete', result: undefined } - }) + await clearActivationState(subscription.lookupKey, challengeId) return { status: 'activated', result } }, @@ -249,7 +263,7 @@ export function fromStore( } } if ( - subscription.inFlightPeriod === periodIndex && + subscription.inFlightPeriod !== undefined && !isStaleRenewal(subscription, renewalTimeoutMs) ) { return { @@ -272,6 +286,11 @@ export function fromStore( }, ) if (started.status !== 'started') return started + const active = await getByLookupKey(started.subscription.lookupKey) + if (active?.subscriptionId !== subscriptionId) { + await clearRenewalState(subscriptionId, periodIndex) + return { status: 'superseded', subscription: started.subscription } + } const result = await renew({ inFlightReference, @@ -282,6 +301,12 @@ export function fromStore( throw error }) + const activeAfterRenew = await getByLookupKey(result.subscription.lookupKey) + if (activeAfterRenew?.subscriptionId !== subscriptionId) { + await clearRenewalState(subscriptionId, periodIndex) + return { status: 'superseded', subscription: started.subscription } + } + const committed = await store.update(recordKey(subscriptionId), (current) => { const existing = current as SubscriptionRecord | null if (!existing || existing.inFlightPeriod !== periodIndex) { @@ -305,7 +330,14 @@ export function fromStore( }) if (!committed) return { status: 'claimMismatch' } - await store.put(lookupRecordKey(result.subscription.lookupKey), subscriptionId) + const ownsLookup = await store.update( + lookupRecordKey(result.subscription.lookupKey), + (current) => { + if (current !== subscriptionId) return { op: 'noop', result: false } + return { op: 'set', value: subscriptionId, result: true } + }, + ) + if (!ownsLookup) return { status: 'superseded', subscription: started.subscription } return { status: 'renewed', result } }, } From 10f3171c2efa72e46eff99cb0c813168160ad526 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Mon, 11 May 2026 12:14:31 -0700 Subject: [PATCH 5/8] fix: harden tempo subscriptions --- .changeset/friendly-subscription-inputs.md | 5 + src/Challenge.test.ts | 13 ++ src/Challenge.ts | 6 +- src/server/Mppx.authorize.test.ts | 46 ++++++ src/server/Mppx.test-d.ts | 24 ++- src/server/Mppx.test.ts | 2 +- src/server/Mppx.ts | 39 ++++- src/tempo/Methods.test.ts | 46 ++++++ src/tempo/Methods.ts | 35 +++-- src/tempo/server/Subscription.test.ts | 39 +++++ src/tempo/server/Subscription.ts | 16 +- src/tempo/subscription/Store.test.ts | 168 +++++++++++++++++++++ src/tempo/subscription/Store.ts | 60 +++++++- src/tempo/subscription/Types.ts | 2 + src/zod.test.ts | 24 ++- src/zod.ts | 24 +++ 16 files changed, 512 insertions(+), 37 deletions(-) create mode 100644 .changeset/friendly-subscription-inputs.md diff --git a/.changeset/friendly-subscription-inputs.md b/.changeset/friendly-subscription-inputs.md new file mode 100644 index 00000000..5e9f8152 --- /dev/null +++ b/.changeset/friendly-subscription-inputs.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added Date challenge expirations and numeric Tempo subscription period counts. diff --git a/src/Challenge.test.ts b/src/Challenge.test.ts index 822ff3aa..50269293 100644 --- a/src/Challenge.test.ts +++ b/src/Challenge.test.ts @@ -53,6 +53,19 @@ describe('from', () => { `) }) + test('behavior: accepts expires as a Date', () => { + const challenge = Challenge.from({ + id: 'abc123', + realm: 'api.example.com', + method: 'tempo', + intent: 'charge', + request: { amount: '1000000' }, + expires: new Date('2025-01-06T12:00:00Z'), + }) + + expect(challenge.expires).toBe('2025-01-06T12:00:00.000Z') + }) + // --------------------------------------------------------------------------- // HMAC Challenge ID Test Vectors // diff --git a/src/Challenge.ts b/src/Challenge.ts index ce774cb1..c2c3f821 100644 --- a/src/Challenge.ts +++ b/src/Challenge.ts @@ -129,7 +129,7 @@ export function from< secretKey, } = parameters - const expires = parameters.expires as string + const expires = parameters.expires ? z.toDatetimeString(parameters.expires) : undefined const opaque = parameters.opaque ?? (meta !== undefined ? PaymentRequest.serialize(meta) : undefined) const id = secretKey @@ -173,7 +173,7 @@ export declare namespace from { /** Optional digest of the request body. */ digest?: string | undefined /** Optional expiration timestamp (ISO 8601). */ - expires?: string | undefined + expires?: z.DatetimeInput | undefined /** Intent type (e.g., "charge", "session"). */ intent: string /** Optional server-defined correlation data (serialized as `opaque` on the challenge). Flat string-to-string map; clients MUST NOT modify. */ @@ -266,7 +266,7 @@ export declare namespace fromMethod { /** Optional digest of the request body. */ digest?: string | undefined /** Optional expiration timestamp (ISO 8601). */ - expires?: string | undefined + expires?: z.DatetimeInput | undefined /** Optional server-defined correlation data (serialized as `opaque` on the challenge). Flat string-to-string map; clients MUST NOT modify. */ meta?: Record | undefined /** Server realm (e.g., hostname). */ diff --git a/src/server/Mppx.authorize.test.ts b/src/server/Mppx.authorize.test.ts index 78a6b812..d238e3ee 100644 --- a/src/server/Mppx.authorize.test.ts +++ b/src/server/Mppx.authorize.test.ts @@ -1,6 +1,7 @@ import { Challenge, Credential, Method, z } from 'mppx' import { Mppx } from 'mppx/server' import { describe, expect, test } from 'vp/test' +import * as Http from '~test/Http.js' const realm = 'api.example.com' const secretKey = 'test-secret-key' @@ -47,6 +48,51 @@ describe('authorize hook', () => { expect(response.headers.get('Payment-Receipt')).toBeTruthy() }) + test('toNodeListener forwards authorize management responses', 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(), + response: new Response('retry later', { + headers: { 'Retry-After': '1' }, + status: 409, + }), + } + }, + async verify() { + return successReceipt() + }, + }, + ) + + const handler = Mppx.create({ methods: [method], realm, secretKey }) + const server = await Http.createServer(async (req, res) => { + const result = await Mppx.toNodeListener(handler['mock/subscription']({ amount: '1' }))( + req, + res, + ) + if (result.status === 402) return + res.end('OK') + }) + + const response = await fetch(server.url) + expect(response.status).toBe(409) + expect(response.headers.get('Retry-After')).toBe('1') + expect(response.headers.get('Payment-Receipt')).toBeTruthy() + expect(await response.text()).toBe('retry later') + + server.close() + }) + test('compose evaluates authorize hooks sequentially on no-credential requests', async () => { const calls: string[] = [] const createMethod = ( diff --git a/src/server/Mppx.test-d.ts b/src/server/Mppx.test-d.ts index d3ee2c3f..8edc7966 100644 --- a/src/server/Mppx.test-d.ts +++ b/src/server/Mppx.test-d.ts @@ -1,5 +1,5 @@ import { Method, z } from 'mppx' -import { Mppx } from 'mppx/server' +import { Mppx, tempo } from 'mppx/server' import { assertType, describe, expectTypeOf, test } from 'vp/test' const mockChargeA = Method.from({ @@ -151,6 +151,7 @@ describe('Mppx type tests', () => { amount: '100', currency: '0x01', decimals: 6, + expires: new Date('2026-01-01T00:00:00Z'), recipient: '0x02', }) @@ -187,4 +188,25 @@ describe('Mppx type tests', () => { Promise >() }) + + test('tempo subscription accepts ergonomic date and period inputs', () => { + const method = tempo.subscription({ + amount: '10', + currency: '0x20c0000000000000000000000000000000000001', + periodCount: 1, + periodUnit: 'day', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + resolve: async () => ({ key: 'user-1:plan:pro' }), + subscriptionExpires: new Date('2026-01-01T00:00:00Z'), + }) + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + + expectTypeOf( + mppx.tempo.subscription({ + expires: new Date('2026-01-01T00:00:00Z'), + periodCount: 1n, + subscriptionExpires: new Date('2026-01-01T00:00:00Z'), + }), + ).toBeFunction() + }) }) diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index 4b16243f..3b02b364 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -972,7 +972,7 @@ describe('compose', () => { amount: '1000', currency: '0x0000000000000000000000000000000000000001', decimals: 6, - expires: new Date(Date.now() + 60_000).toISOString(), + expires: new Date(Date.now() + 60_000), recipient: '0x0000000000000000000000000000000000000002', } diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 91a26312..e64fbcf9 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -10,7 +10,7 @@ import * as Env from '../internal/env.js' import type * as Method from '../Method.js' import * as PaymentRequest from '../PaymentRequest.js' import type * as Receipt from '../Receipt.js' -import type * as z from '../zod.js' +import * as z from '../zod.js' import * as Html from './internal/html/config.js' import { serviceWorker } from './internal/html/serviceWorker.gen.js' import * as Scope from './internal/scope.js' @@ -451,7 +451,9 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R return Object.assign( async (input: Transport.InputOf): Promise => { const expires = - 'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5) + 'expires' in options + ? normalizeExpires(options.expires as z.DatetimeInput | undefined) + : Expires.minutes(5) const capturedRequest = await captureRequest(transport, input) const effectiveMeta = scope === undefined && input instanceof globalThis.Request @@ -757,14 +759,16 @@ function createChallengeFn(parameters: { return async (options) => { const { description, meta, scope, ...rest } = options as { description?: string - expires?: string + expires?: z.DatetimeInput meta?: Record scope?: string [key: string]: unknown } const effectiveMeta = Scope.merge({ meta, scope }) const expires = - 'expires' in options ? (options.expires as string | undefined) : Expires.minutes(5) + 'expires' in options + ? normalizeExpires(options.expires as z.DatetimeInput | undefined) + : Expires.minutes(5) return resolveRouteChallenge({ defaults, @@ -817,6 +821,10 @@ const Warnings = { realmFallback: 'realm-fallback', } as const +function normalizeExpires(expires: z.DatetimeInput | undefined): string | undefined { + return expires === undefined ? undefined : z.toDatetimeString(expires) +} + const _warned = new Set() function warnOnce(key: string, message: string) { if (_warned.has(key)) return @@ -1161,8 +1169,8 @@ declare namespace MethodFn { > = { /** Optional human-readable description of the payment. */ description?: string | undefined - /** Optional challenge expiration timestamp (ISO 8601). */ - expires?: string | undefined + /** Optional challenge expiration timestamp (ISO 8601) or Date. */ + expires?: z.DatetimeInput | undefined /** Optional server-defined correlation data (serialized as `opaque` in the request). Flat string-to-string map; clients MUST NOT modify. */ meta?: Record | undefined /** Optional route/resource scope bound via reserved challenge metadata. */ @@ -1498,6 +1506,12 @@ export function toNodeListener( if (result.status === 402) { await NodeListener.sendResponse(res, result.challenge as globalThis.Response) } else { + const managementResponse = getManagementResponse(result) + if (managementResponse) { + await NodeListener.sendResponse(res, managementResponse) + return { challenge: managementResponse, status: 402 } + } + const wrapped = result.withReceipt(new globalThis.Response()) as globalThis.Response res.setHeader('Payment-Receipt', wrapped.headers.get('Payment-Receipt')!) } @@ -1506,6 +1520,19 @@ export function toNodeListener( } } +function getManagementResponse( + result: Extract, { status: 200 }>, +): globalThis.Response | null { + try { + return (result.withReceipt as () => globalThis.Response)() + } catch (error) { + if (error instanceof Error && error.message === 'withReceipt() requires a response argument') { + return null + } + throw error + } +} + /** * Flattens a methods config tuple, preserving positional types. * @internal diff --git a/src/tempo/Methods.test.ts b/src/tempo/Methods.test.ts index fb5da08c..cb5f505b 100644 --- a/src/tempo/Methods.test.ts +++ b/src/tempo/Methods.test.ts @@ -281,6 +281,38 @@ describe('subscription', () => { expect('accessKey' in request).toBe(false) }) + test('schema: accepts subscriptionExpires as a Date', () => { + const request = Methods.subscription.schema.request.parse({ + amount: '10', + chainId: 4217, + currency: '0x20c0000000000000000000000000000000000001', + decimals: 6, + periodCount: '1', + periodUnit: 'day', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + subscriptionExpires: new Date('2026-01-01T00:00:00Z'), + }) + + expect(request.subscriptionExpires).toBe('2026-01-01T00:00:00.000Z') + }) + + test.each([ + { input: 1, expected: '1', desc: 'number' }, + { input: 1n, expected: '1', desc: 'bigint' }, + ])('schema: accepts periodCount as $desc', ({ input, expected }) => { + const request = Methods.subscription.schema.request.parse({ + amount: '10', + currency: '0x20c0000000000000000000000000000000000001', + decimals: 6, + periodCount: input, + periodUnit: 'day', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + subscriptionExpires: '2026-01-01T00:00:00Z', + }) + + expect(request.periodCount).toBe(expected) + }) + test('schema: rejects non-numeric periodCount', () => { const result = Methods.subscription.schema.request.safeParse({ amount: '10', @@ -295,6 +327,20 @@ describe('subscription', () => { expect(result.success).toBe(false) }) + test('schema: rejects unsafe number periodCount', () => { + const result = Methods.subscription.schema.request.safeParse({ + amount: '10', + currency: '0x20c0000000000000000000000000000000000001', + decimals: 6, + periodCount: Number.MAX_SAFE_INTEGER + 1, + periodUnit: 'day', + recipient: '0x1234567890abcdef1234567890abcdef12345678', + subscriptionExpires: '2026-01-01T00:00:00Z', + }) + + expect(result.success).toBe(false) + }) + test('schema: rejects calendar-month periods that Tempo cannot represent exactly', () => { const result = Methods.subscription.schema.request.safeParse({ amount: '10', diff --git a/src/tempo/Methods.ts b/src/tempo/Methods.ts index 8d64671e..d6d776fe 100644 --- a/src/tempo/Methods.ts +++ b/src/tempo/Methods.ts @@ -7,6 +7,7 @@ import type { SubscriptionPeriodUnit } from './subscription/Types.js' export const chargeModes = ['push', 'pull'] as const export type ChargeMode = (typeof chargeModes)[number] +export type SubscriptionPeriodCountInput = string | number | bigint const split = z.object({ amount: z.amount(), @@ -37,10 +38,7 @@ const subscriptionMethodDetails = z.object({ }) const subscriptionExpires = z - .pipe( - z.datetime(), - z.transform((value) => new Date(value)), - ) + .datetimeInput('subscriptionExpires must be a valid date') .check( z.refine( (value) => value.getTime() % 1_000 === 0, @@ -51,16 +49,25 @@ const subscriptionExpires = z const subscriptionPeriodUnits = ['day', 'week'] as const satisfies readonly SubscriptionPeriodUnit[] const subscriptionPeriodUnit = z.enum(subscriptionPeriodUnits) -const uint64String = z.string().check( - z.regex(/^[1-9]\d*$/, 'Invalid periodCount'), - z.refine((value) => { - try { - return BigInt(value) <= uint64Max - } catch { - return false - } - }, 'periodCount exceeds uint64'), -) +const uint64String = z + .pipe( + z.union([ + z.string(), + z.bigint(), + z.custom((value) => typeof value === 'number' && Number.isSafeInteger(value)), + ]), + z.transform((value) => value.toString()), + ) + .check( + z.regex(/^[1-9]\d*$/, 'Invalid periodCount'), + z.refine((value) => { + try { + return BigInt(value) <= uint64Max + } catch { + return false + } + }, 'periodCount exceeds uint64'), + ) function positiveParsedAmount(message: string) { return z.refine((value) => { diff --git a/src/tempo/server/Subscription.test.ts b/src/tempo/server/Subscription.test.ts index 2bcd14e0..75d15bea 100644 --- a/src/tempo/server/Subscription.test.ts +++ b/src/tempo/server/Subscription.test.ts @@ -1249,6 +1249,7 @@ describe('tempo.subscription', () => { billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(), lastChargedPeriod: 0, lookupKey: subscriptionKey, + amount: subscriptionAmount, reference: hashStale, subscriptionId: 'sub_background', }), @@ -1275,6 +1276,44 @@ describe('tempo.subscription', () => { expect((await subscriptions.get('sub_background'))?.reference).toBe(hashBackground) }) + test('rejects background renewals that mutate economic fields', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + + await subscriptions.put( + createRecord({ + billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(), + lastChargedPeriod: 0, + lookupKey: subscriptionKey, + amount: subscriptionAmount, + reference: hashStale, + subscriptionId: 'sub_background', + }), + ) + + await expect( + renew({ + renew: async ({ periodIndex, subscription }) => { + const mutated = { + ...subscription, + amount: '999', + lastChargedPeriod: periodIndex, + reference: hashBackground, + } + return { + receipt: createReceipt(subscription.subscriptionId, hashBackground), + subscription: mutated, + } + }, + store, + subscriptionId: 'sub_background', + }), + ).rejects.toThrow('subscription record does not match request') + + expect((await subscriptions.get('sub_background'))?.inFlightReference).toBe(undefined) + expect((await subscriptions.get('sub_background'))?.amount).toBe(subscriptionAmount) + }) + test('does not charge a superseded subscription outside the request path', async () => { const store = Store.memory() const subscriptions = SubscriptionStore.fromStore(store) diff --git a/src/tempo/server/Subscription.ts b/src/tempo/server/Subscription.ts index efa93bea..ee3b089e 100644 --- a/src/tempo/server/Subscription.ts +++ b/src/tempo/server/Subscription.ts @@ -463,6 +463,7 @@ async function settleRenewal(parameters: { expectedLookupKey, expectedPeriodIndex: periodIndex, expectedSubscriptionId: subscription.subscriptionId, + previous: started, request, }) return renewed @@ -538,7 +539,7 @@ function isReusableSubscription( function subscriptionMatchesRequest( subscription: SubscriptionRecord, - request: SubscriptionRequest, + request: SubscriptionRequest | SubscriptionRecord, ): boolean { const actual = comparableSubscriptionBinding(subscription) const expected = comparableSubscriptionBinding(request) @@ -568,6 +569,7 @@ function validateSubscriptionSettlement( expectedLookupKey: string expectedPeriodIndex: number expectedSubscriptionId?: string | undefined + previous?: SubscriptionRecord | undefined request?: SubscriptionRequest | undefined }, ) { @@ -577,6 +579,8 @@ function validateSubscriptionSettlement( if (options.request) { assertSubscriptionRequestMatch(subscription, options.request) + } else if (options.previous) { + assertSubscriptionRequestMatch(subscription, options.previous) } } @@ -639,7 +643,7 @@ function assertSubscriptionRecord( function assertSubscriptionRequestMatch( subscription: SubscriptionRecord, - request: SubscriptionRequest, + request: SubscriptionRequest | SubscriptionRecord, ) { if (!subscriptionMatchesRequest(subscription, request)) { throw new VerificationFailedError({ reason: 'subscription record does not match request' }) @@ -981,8 +985,13 @@ export declare namespace subscription { | undefined } | undefined - periodCount?: string | undefined + periodCount?: Methods.SubscriptionPeriodCountInput | undefined periodUnit?: SubscriptionPeriodUnit | undefined + /** + * Resolves the request identity. This callback must authenticate and + * authorize the caller before returning a key; automatic mode may create + * a server-owned access key for that key while issuing a challenge. + */ resolve: (parameters: { input: Request request: SubscriptionRequest @@ -991,6 +1000,7 @@ export declare namespace subscription { /** Stable idempotency/reconciliation reference persisted before the renewal hook runs. */ inFlightReference: string periodIndex: number + /** Custom renewal hooks must preserve amount, currency, recipient, period, expiry, and lookup key. */ subscription: SubscriptionRecord }) => Promise store?: Store.AtomicStore> | undefined diff --git a/src/tempo/subscription/Store.test.ts b/src/tempo/subscription/Store.test.ts index 06bcacad..f2d94ec4 100644 --- a/src/tempo/subscription/Store.test.ts +++ b/src/tempo/subscription/Store.test.ts @@ -100,6 +100,83 @@ describe('tempo subscription store', () => { expect(await first).toEqual({ status: 'claimMismatch' }) }) + test('does not replace an activation marker that is committing', async () => { + const rawStore = Store.memory() + let store!: ReturnType + let nestedStatus: string | undefined + let sawCommit = false + const wrapped = { + ...rawStore, + async update(key, change) { + const result = await rawStore.update(key, change) + const marker = await rawStore.get(key) + if ( + !sawCommit && + key === 'tempo:subscription:activation:user-1:plan:pro' && + marker && + typeof marker === 'object' && + 'committingAt' in marker + ) { + sawCommit = true + const nested = await store.activate({ + challengeId: 'challenge-2', + create: async () => ({ subscription: createRecord({ subscriptionId: 'sub_2' }) }), + lookupKey: 'user-1:plan:pro', + }) + nestedStatus = nested.status + } + return result + }, + } satisfies Store.AtomicStore> + store = fromStore(wrapped, { activationTimeoutMs: 0 }) + + const activated = await store.activate({ + challengeId: 'challenge-1', + create: async () => ({ subscription: createRecord() }), + lookupKey: 'user-1:plan:pro', + }) + + expect(activated.status).toBe('activated') + expect(nestedStatus).toBe('inFlight') + expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe(subscriptionId) + }) + + test('keeps a superseded activation record for reconciliation', async () => { + const store = fromStore(Store.memory(), { activationTimeoutMs: 0 }) + let finishActivation!: () => void + const pendingActivation = new Promise((resolve) => { + finishActivation = resolve + }) + + const first = store.activate({ + challengeId: 'challenge-1', + create: async () => { + await pendingActivation + return { + subscription: createRecord({ + reference: `0x${'b'.repeat(64)}`, + subscriptionId: 'sub_late', + }), + } + }, + lookupKey: 'user-1:plan:pro', + }) + + const second = await store.activate({ + challengeId: 'challenge-2', + create: async () => ({ + subscription: createRecord({ reference: `0x${'c'.repeat(64)}`, subscriptionId: 'sub_2' }), + }), + lookupKey: 'user-1:plan:pro', + }) + expect(second.status).toBe('activated') + + finishActivation() + expect(await first).toEqual({ status: 'claimMismatch' }) + expect((await store.get('sub_late'))?.canceledAt).toBeTruthy() + expect((await store.getByKey('user-1:plan:pro'))?.subscriptionId).toBe('sub_2') + }) + test('rechecks the lookup key after claiming activation', async () => { const rawStore = Store.memory() const seeded = fromStore(rawStore) @@ -280,6 +357,97 @@ describe('tempo subscription store', () => { expect(await first).toEqual({ status: 'claimMismatch' }) }) + test('stale renewal cannot commit over a newer in-flight attempt', async () => { + const store = fromStore(Store.memory(), { renewalTimeoutMs: 0 }) + await store.put(createRecord()) + let finishFirst!: () => void + let finishSecond!: () => void + const firstPending = new Promise((resolve) => { + finishFirst = resolve + }) + const secondPending = new Promise((resolve) => { + finishSecond = resolve + }) + + const first = store.renew({ + inFlightReference: '0xfirst', + periodIndex: 1, + renew: async ({ subscription }) => { + await firstPending + return { + subscription: { + ...subscription, + reference: `0x${'b'.repeat(64)}`, + }, + } + }, + subscriptionId, + }) + + const second = store.renew({ + inFlightReference: '0xsecond', + periodIndex: 1, + renew: async ({ subscription }) => { + await secondPending + return { + subscription: { + ...subscription, + reference: `0x${'c'.repeat(64)}`, + }, + } + }, + subscriptionId, + }) + + finishFirst() + expect(await first).toEqual({ status: 'claimMismatch' }) + expect((await store.get(subscriptionId))?.inFlightReference).toBe('0xsecond') + + finishSecond() + expect((await second).status).toBe('renewed') + expect((await store.get(subscriptionId))?.reference).toBe(`0x${'c'.repeat(64)}`) + }) + + test('stale renewal failure cannot clear a newer in-flight attempt', async () => { + const store = fromStore(Store.memory(), { renewalTimeoutMs: 0 }) + await store.put(createRecord()) + let rejectFirst!: () => void + let finishSecond!: () => void + const firstPending = new Promise((_resolve, reject) => { + rejectFirst = () => reject(new Error('first failed')) + }) + const secondPending = new Promise((resolve) => { + finishSecond = resolve + }) + + const first = store.renew({ + inFlightReference: '0xfirst', + periodIndex: 1, + renew: async ({ subscription }) => { + await firstPending + return { subscription } + }, + subscriptionId, + }) + + const second = store.renew({ + inFlightReference: '0xsecond', + periodIndex: 1, + renew: async ({ subscription }) => { + await secondPending + return { subscription } + }, + subscriptionId, + }) + + rejectFirst() + await expect(first).rejects.toThrow('first failed') + expect((await store.get(subscriptionId))?.inFlightReference).toBe('0xsecond') + + finishSecond() + expect((await second).status).toBe('renewed') + }) + test('clears an in-flight renewal after failure', async () => { const store = fromStore(Store.memory()) await store.put(createRecord()) diff --git a/src/tempo/subscription/Store.ts b/src/tempo/subscription/Store.ts index 6b5792ef..78c94b64 100644 --- a/src/tempo/subscription/Store.ts +++ b/src/tempo/subscription/Store.ts @@ -69,6 +69,7 @@ export type RenewResult = type ActivationMarker = { challengeId?: string + committingAt?: string startedAt?: string } @@ -113,10 +114,14 @@ export function fromStore( return (await store.get(recordKey(subscriptionId))) as SubscriptionRecord | null } - async function clearRenewalState(subscriptionId: string, periodIndex: number) { + async function clearRenewalState(subscriptionId: string, periodIndex: number, attempt: string) { await store.update(recordKey(subscriptionId), (current) => { const subscription = current as SubscriptionRecord | null - if (!subscription || subscription.inFlightPeriod !== periodIndex) { + if ( + !subscription || + subscription.inFlightPeriod !== periodIndex || + subscription.inFlightAttempt !== attempt + ) { return { op: 'noop', result: undefined } } return { @@ -135,6 +140,11 @@ export function fromStore( }) } + async function ownsActivation(lookupKey: string, challengeId: string): Promise { + const marker = (await store.get(activationKey(lookupKey))) as ActivationMarker | null + return marker?.challengeId === challengeId + } + return { async activate({ challengeId, create, isReusable, lookupKey }) { const claimed = await store.update(credentialKey(challengeId), (current) => { @@ -187,20 +197,39 @@ export function fromStore( if (marker?.challengeId !== challengeId) return { op: 'noop', result: false } return { op: 'set', - value: { ...marker, committingAt: timestamp() }, + value: { + ...marker, + committingAt: timestamp(), + startedAt: timestamp(), + }, result: true, } }) - if (!committed) return { status: 'claimMismatch' } + if (!committed) { + await store.put(recordKey(subscription.subscriptionId), { + ...subscription, + canceledAt: subscription.canceledAt ?? timestamp(), + }) + return { status: 'claimMismatch' } + } const previous = await getByLookupKey(subscription.lookupKey) if (previous && previous.subscriptionId !== subscription.subscriptionId) { + if (!(await ownsActivation(subscription.lookupKey, challengeId))) { + return { status: 'claimMismatch' } + } await store.put(recordKey(previous.subscriptionId), { ...previous, canceledAt: previous.canceledAt ?? timestamp(), }) } + if (!(await ownsActivation(subscription.lookupKey, challengeId))) { + return { status: 'claimMismatch' } + } await store.put(recordKey(subscription.subscriptionId), subscription) + if (!(await ownsActivation(subscription.lookupKey, challengeId))) { + return { status: 'claimMismatch' } + } await store.put(lookupRecordKey(subscription.lookupKey), subscription.subscriptionId) await clearActivationState(subscription.lookupKey, challengeId) return { status: 'activated', result } @@ -219,6 +248,9 @@ export function fromStore( }, async getOrCreateAccessKey(key) { + const existing = (await store.get(accessKeyKey(key))) as SubscriptionAccessKeyRecord | null + if (existing) return existing + const privateKey = Secp256k1.randomPrivateKey() const account = TempoAccount.fromSecp256k1(privateKey) const candidate = { @@ -243,6 +275,7 @@ export function fromStore( }, async renew({ inFlightReference, periodIndex, renew, subscriptionId }) { + const attempt = createAttemptToken() const started = await store.update( recordKey(subscriptionId), ( @@ -274,6 +307,7 @@ export function fromStore( const next = { ...subscription, + inFlightAttempt: attempt, inFlightPeriod: periodIndex, inFlightReference, inFlightStartedAt: timestamp(), @@ -288,7 +322,7 @@ export function fromStore( if (started.status !== 'started') return started const active = await getByLookupKey(started.subscription.lookupKey) if (active?.subscriptionId !== subscriptionId) { - await clearRenewalState(subscriptionId, periodIndex) + await clearRenewalState(subscriptionId, periodIndex, attempt) return { status: 'superseded', subscription: started.subscription } } @@ -297,19 +331,23 @@ export function fromStore( periodIndex, subscription: started.subscription, }).catch(async (error) => { - await clearRenewalState(subscriptionId, periodIndex) + await clearRenewalState(subscriptionId, periodIndex, attempt) throw error }) const activeAfterRenew = await getByLookupKey(result.subscription.lookupKey) if (activeAfterRenew?.subscriptionId !== subscriptionId) { - await clearRenewalState(subscriptionId, periodIndex) + await clearRenewalState(subscriptionId, periodIndex, attempt) return { status: 'superseded', subscription: started.subscription } } const committed = await store.update(recordKey(subscriptionId), (current) => { const existing = current as SubscriptionRecord | null - if (!existing || existing.inFlightPeriod !== periodIndex) { + if ( + !existing || + existing.inFlightPeriod !== periodIndex || + existing.inFlightAttempt !== attempt + ) { return { op: 'noop', result: false } } @@ -364,6 +402,7 @@ export declare namespace fromStore { function isStaleActivation(marker: { startedAt?: string | undefined }, timeoutMs: number) { if (!Number.isFinite(timeoutMs) || timeoutMs < 0) return false + if ('committingAt' in marker && marker.committingAt) return false const startedAt = new Date(marker.startedAt ?? '').getTime() if (!Number.isFinite(startedAt)) return true return Date.now() - startedAt >= timeoutMs @@ -376,6 +415,7 @@ function isStaleRenewal(subscription: SubscriptionRecord, timeoutMs: number) { function clearRenewal(subscription: SubscriptionRecord): SubscriptionRecord { return { ...subscription, + inFlightAttempt: undefined, inFlightPeriod: undefined, inFlightReference: undefined, inFlightStartedAt: undefined, @@ -385,3 +425,7 @@ function clearRenewal(subscription: SubscriptionRecord): SubscriptionRecord { function timestamp() { return new Date().toISOString() } + +function createAttemptToken() { + return globalThis.crypto.randomUUID() +} diff --git a/src/tempo/subscription/Types.ts b/src/tempo/subscription/Types.ts index e1b0b69f..acaf2e0f 100644 --- a/src/tempo/subscription/Types.ts +++ b/src/tempo/subscription/Types.ts @@ -29,6 +29,8 @@ export type SubscriptionRecord = { externalId?: string | undefined accessKey?: SubscriptionAccessKey | undefined inFlightPeriod?: number | undefined + /** Per-attempt ownership token for the renewal currently in progress. */ + inFlightAttempt?: string | undefined /** Stable idempotency/reconciliation reference for a renewal currently in progress. */ inFlightReference?: string | undefined inFlightStartedAt?: string | undefined diff --git a/src/zod.test.ts b/src/zod.test.ts index 108e9b7f..d4bed687 100644 --- a/src/zod.test.ts +++ b/src/zod.test.ts @@ -1,6 +1,16 @@ import { describe, expect, test } from 'vp/test' -import { address, amount, datetime, hash, period, signature, unwrapOptional, z } from './zod.js' +import { + address, + amount, + datetime, + datetimeInput, + hash, + period, + signature, + unwrapOptional, + z, +} from './zod.js' describe('amount', () => { test.each([ @@ -34,6 +44,18 @@ describe('datetime', () => { }) }) +describe('datetimeInput', () => { + test('accepts Date objects', () => { + const result = datetimeInput().parse(new Date('2025-01-06T12:00:00Z')) + + expect(result.toISOString()).toBe('2025-01-06T12:00:00.000Z') + }) + + test('rejects invalid Date objects', () => { + expect(datetimeInput().safeParse(new Date(Number.NaN)).success).toBe(false) + }) +}) + describe('address', () => { test.each([ { input: '0x1234567890abcdef1234567890abcdef12345678', expected: true, desc: 'lowercase hex' }, diff --git a/src/zod.ts b/src/zod.ts index a3ec7a8f..b81a66cc 100644 --- a/src/zod.ts +++ b/src/zod.ts @@ -2,6 +2,8 @@ import { type ZodMiniOptional, type ZodMiniType, z } from 'zod/mini' export * from 'zod/mini' +export type DatetimeInput = string | Date + /** Numeric string amount (e.g., "1", "1.5", "1000000"). */ export function amount() { return z.string().check(z.regex(/^\d+(\.\d+)?$/, 'Invalid amount')) @@ -19,6 +21,28 @@ export function datetime() { ) } +/** ISO 8601 datetime string or Date object, transformed to a Date. */ +export function datetimeInput(message = 'Invalid ISO 8601 datetime') { + return z + .pipe( + z.union([datetime(), z.custom((value) => value instanceof Date)]), + z.transform(toDate), + ) + .check(z.refine((value) => Number.isFinite(value.getTime()), message)) +} + +/** Converts an ISO 8601 datetime string or Date object to a Date. */ +export function toDate(value: DatetimeInput): Date { + return value instanceof Date ? value : new Date(value) +} + +/** Serializes an ISO 8601 datetime string or Date object for wire output. */ +export function toDatetimeString(value: DatetimeInput): string { + if (!(value instanceof Date)) return value + if (!Number.isFinite(value.getTime())) return 'Invalid Date' + return value.toISOString() +} + /** Hex-encoded address string (0x-prefixed, 40 hex chars). */ export function address() { return z.string().check(z.regex(/^0x[0-9a-fA-F]{40}$/, 'Invalid address')) From 5f7c7b511d30e67bc3c729a14484ea96caaa7e78 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Mon, 11 May 2026 13:05:42 -0700 Subject: [PATCH 6/8] fix: clean subscription attribution --- src/proxy/Service.test.ts | 1 + src/proxy/Service.ts | 1 + src/tempo/server/Subscription.test.ts | 3 ++- src/tempo/server/Subscription.ts | 6 +----- 4 files changed, 5 insertions(+), 6 deletions(-) diff --git a/src/proxy/Service.test.ts b/src/proxy/Service.test.ts index bfb26c24..6803f8a9 100644 --- a/src/proxy/Service.test.ts +++ b/src/proxy/Service.test.ts @@ -142,6 +142,7 @@ describe('paymentOf', () => { request: () => ({}), respond: () => undefined, schema: {}, + stableBinding: () => ({}), transport: {}, verify: () => undefined, }, diff --git a/src/proxy/Service.ts b/src/proxy/Service.ts index 518cdd61..123abd00 100644 --- a/src/proxy/Service.ts +++ b/src/proxy/Service.ts @@ -220,6 +220,7 @@ export function paymentOf(endpoint: Endpoint): Record | null { authorize: _a, request: _r, respond: _re, + stableBinding: _st, transport: _t, verify: _v, ...rest diff --git a/src/tempo/server/Subscription.test.ts b/src/tempo/server/Subscription.test.ts index 75d15bea..44c74fc2 100644 --- a/src/tempo/server/Subscription.test.ts +++ b/src/tempo/server/Subscription.test.ts @@ -1404,6 +1404,7 @@ describe('tempo.subscription', () => { expect(result?.receipt.reference).toBe(hashBackground) expect(rpcMethods.filter((method) => method === 'eth_sendRawTransaction')).toHaveLength(2) - expect((await subscriptions.get(record.subscriptionId))?.reference).toBe(hashBackground) + const renewed = await subscriptions.get(record.subscriptionId) + expect(renewed?.reference).toBe(hashBackground) }) }) diff --git a/src/tempo/server/Subscription.ts b/src/tempo/server/Subscription.ts index ee3b089e..604ffaba 100644 --- a/src/tempo/server/Subscription.ts +++ b/src/tempo/server/Subscription.ts @@ -399,7 +399,6 @@ async function activateSubscription(parameters: { source, store: auto.store, waitForConfirmation: auto.waitForConfirmation, - memoServerId: auto.realm, }) const timestamp = new Date().toISOString() const subscription = { @@ -724,7 +723,6 @@ function resolveRenewalHandler(parameters: { accessKey: subscription.accessKey!, getClient, lookupKey: subscription.lookupKey, - memoServerId: subscription.lookupKey, request: subscription, settlementReference: inFlightReference, source: subscription.payer!, @@ -749,7 +747,6 @@ async function submitSubscriptionPayment(parameters: { getClient: (parameters: { chainId?: number | undefined }) => MaybePromise keyAuthorization?: `0x${string}` | undefined lookupKey: string - memoServerId: string request: Pick & { methodDetails?: { chainId?: number | undefined } | undefined } & { currency: Address | string; recipient: Address | string } @@ -763,7 +760,6 @@ async function submitSubscriptionPayment(parameters: { getClient, keyAuthorization, lookupKey, - memoServerId, request, settlementReference, source, @@ -788,7 +784,7 @@ async function submitSubscriptionPayment(parameters: { }) const memo = Attribution.encode({ challengeId: settlementReference, - serverId: memoServerId, + serverId: lookupKey, }) const serializedTransaction = await signTransaction(client, { account, From 3010dd9fab57478d4dcb13cab755076c92677342 Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Mon, 11 May 2026 13:13:21 -0700 Subject: [PATCH 7/8] fix: type receipt response sentinel --- src/middlewares/elysia.ts | 2 +- src/middlewares/express.ts | 6 +----- src/middlewares/hono.ts | 2 +- src/middlewares/nextjs.ts | 2 +- src/proxy/Proxy.ts | 7 ++----- src/server/Mppx.test.ts | 2 +- src/server/Mppx.ts | 20 ++++++++++++++++++-- 7 files changed, 25 insertions(+), 16 deletions(-) diff --git a/src/middlewares/elysia.ts b/src/middlewares/elysia.ts index 393dbafa..0921d744 100644 --- a/src/middlewares/elysia.ts +++ b/src/middlewares/elysia.ts @@ -76,7 +76,7 @@ function getManagementResponse(result: { withReceipt: (response?: Response) => R try { return result.withReceipt() } catch (error) { - if (error instanceof Error && error.message === 'withReceipt() requires a response argument') { + if (Mppx_core.isMissingReceiptResponseError(error)) { return null } throw error diff --git a/src/middlewares/express.ts b/src/middlewares/express.ts index 60e7e6a8..3be41c27 100644 --- a/src/middlewares/express.ts +++ b/src/middlewares/express.ts @@ -80,11 +80,7 @@ export function payment( try { return (result.withReceipt as () => Response)() } catch (error) { - if ( - error instanceof Error && - error.message === 'withReceipt() requires a response argument' - ) - return null + if (Mppx_core.isMissingReceiptResponseError(error)) return null throw error } })() diff --git a/src/middlewares/hono.ts b/src/middlewares/hono.ts index ef2c39ad..154a15e8 100644 --- a/src/middlewares/hono.ts +++ b/src/middlewares/hono.ts @@ -74,7 +74,7 @@ function getManagementResponse(result: { withReceipt: (response?: Response) => R try { return result.withReceipt() } catch (error) { - if (error instanceof Error && error.message === 'withReceipt() requires a response argument') { + if (Mppx_core.isMissingReceiptResponseError(error)) { return null } throw error diff --git a/src/middlewares/nextjs.ts b/src/middlewares/nextjs.ts index 14750a0c..1800155e 100644 --- a/src/middlewares/nextjs.ts +++ b/src/middlewares/nextjs.ts @@ -72,7 +72,7 @@ function getManagementResponse(result: { withReceipt: (response?: Response) => R try { return result.withReceipt() } catch (error) { - if (error instanceof Error && error.message === 'withReceipt() requires a response argument') { + if (Mppx_core.isMissingReceiptResponseError(error)) { return null } throw error diff --git a/src/proxy/Proxy.ts b/src/proxy/Proxy.ts index 0b012569..63a8337c 100644 --- a/src/proxy/Proxy.ts +++ b/src/proxy/Proxy.ts @@ -3,6 +3,7 @@ import type * as http from 'node:http' import * as Credential from '../Credential.js' import { generateProxy } from '../discovery/OpenApi.js' import * as Scope from '../server/internal/scope.js' +import * as Mppx from '../server/Mppx.js' import * as Request from '../server/Request.js' import * as Headers from './internal/Headers.js' import * as Route from './internal/Route.js' @@ -146,11 +147,7 @@ export function create(config: create.Config): Proxy { try { return (result.withReceipt as () => Response)() } catch (error) { - if ( - error instanceof Error && - error.message === 'withReceipt() requires a response argument' - ) - return null + if (Mppx.isMissingReceiptResponseError(error)) return null throw error } })() diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index 3b02b364..c10e8499 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -2636,7 +2636,7 @@ describe('withReceipt', () => { expect(result.status).toBe(200) if (result.status !== 200) throw new Error() - expect(() => result.withReceipt()).toThrow('withReceipt() requires a response argument') + expect(() => result.withReceipt()).toThrow(Mppx.MissingReceiptResponseError) }) test('returns management response when respond hook returns Response', async () => { diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index e64fbcf9..35e28a37 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -546,7 +546,7 @@ function createMethodFn(parameters: createMethodFn.Parameters): createMethodFn.R response: managementResponse as never, }) as response } - if (!response) throw new Error('withReceipt() requires a response argument') + if (!response) throw new MissingReceiptResponseError() return transport.respondReceipt({ challengeId, credential: credentialForReceipt, @@ -821,6 +821,22 @@ const Warnings = { realmFallback: 'realm-fallback', } as const +/** Error thrown when `withReceipt()` needs a response but none was provided. */ +export class MissingReceiptResponseError extends Error { + override name = 'MissingReceiptResponseError' + + constructor() { + super('withReceipt() requires a response argument') + } +} + +/** Returns true when an error is the typed `withReceipt()` no-response sentinel. */ +export function isMissingReceiptResponseError( + error: unknown, +): error is MissingReceiptResponseError { + return error instanceof MissingReceiptResponseError +} + function normalizeExpires(expires: z.DatetimeInput | undefined): string | undefined { return expires === undefined ? undefined : z.toDatetimeString(expires) } @@ -1526,7 +1542,7 @@ function getManagementResponse( try { return (result.withReceipt as () => globalThis.Response)() } catch (error) { - if (error instanceof Error && error.message === 'withReceipt() requires a response argument') { + if (isMissingReceiptResponseError(error)) { return null } throw error From 8272e50a723a214acd9ccb3a55f08af7bc6efa3f Mon Sep 17 00:00:00 2001 From: Brendan Ryan <1572504+brendanjryan@users.noreply.github.com> Date: Tue, 12 May 2026 09:35:48 -0700 Subject: [PATCH 8/8] fix: harden missing receipt response sentinel --- src/server/Mppx.test.ts | 8 ++++++++ src/server/Mppx.ts | 14 +++++++++++--- 2 files changed, 19 insertions(+), 3 deletions(-) diff --git a/src/server/Mppx.test.ts b/src/server/Mppx.test.ts index c10e8499..f9ffc127 100644 --- a/src/server/Mppx.test.ts +++ b/src/server/Mppx.test.ts @@ -2639,6 +2639,14 @@ describe('withReceipt', () => { expect(() => result.withReceipt()).toThrow(Mppx.MissingReceiptResponseError) }) + test('recognizes missing response sentinel across module instances', () => { + const error = new Error('withReceipt() requires a response argument') + error.name = 'MissingReceiptResponseError' + + expect(Mppx.isMissingReceiptResponseError(error)).toBe(true) + expect(Mppx.isMissingReceiptResponseError(new Error(error.message))).toBe(false) + }) + test('returns management response when respond hook returns Response', async () => { const mockMethodWithRespond = Method.toServer(mockCharge, { async verify() { diff --git a/src/server/Mppx.ts b/src/server/Mppx.ts index 35e28a37..ff8e0cb9 100644 --- a/src/server/Mppx.ts +++ b/src/server/Mppx.ts @@ -820,13 +820,15 @@ const defaultRealm = 'MPP Payment' const Warnings = { realmFallback: 'realm-fallback', } as const +const missingReceiptResponseErrorName = 'MissingReceiptResponseError' +const missingReceiptResponseErrorMessage = 'withReceipt() requires a response argument' /** Error thrown when `withReceipt()` needs a response but none was provided. */ export class MissingReceiptResponseError extends Error { - override name = 'MissingReceiptResponseError' + override name = missingReceiptResponseErrorName constructor() { - super('withReceipt() requires a response argument') + super(missingReceiptResponseErrorMessage) } } @@ -834,7 +836,13 @@ export class MissingReceiptResponseError extends Error { export function isMissingReceiptResponseError( error: unknown, ): error is MissingReceiptResponseError { - return error instanceof MissingReceiptResponseError + if (error instanceof MissingReceiptResponseError) return true + if (!error || typeof error !== 'object') return false + const value = error as { message?: unknown; name?: unknown } + return ( + value.name === missingReceiptResponseErrorName && + value.message === missingReceiptResponseErrorMessage + ) } function normalizeExpires(expires: z.DatetimeInput | undefined): string | undefined {