diff --git a/.changeset/require-subscription-credentials.md b/.changeset/require-subscription-credentials.md new file mode 100644 index 00000000..6a999f5b --- /dev/null +++ b/.changeset/require-subscription-credentials.md @@ -0,0 +1,5 @@ +--- +'mppx': patch +--- + +Added credential-required Tempo subscription reuse and signed-source lookup support so active subscriptions could be bound to the recovered payer instead of request metadata alone. diff --git a/examples/subscription/README.md b/examples/subscription/README.md index 8d6774fe..9e5eb68b 100644 --- a/examples/subscription/README.md +++ b/examples/subscription/README.md @@ -1,8 +1,8 @@ # 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. +Recurring access-key subscription for a news app. The server charges `0.10` pathUSD per day by deriving the subscription key from the signed payer identity, returning a 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. +The example opts into credential-required reuse, so access is keyed from the payer signature instead of a user-controlled header. ## Setup @@ -27,14 +27,14 @@ 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. +1. Requests `/api/article` and receives a `402` challenge with a server access key. +2. Signs a `keyAuthorization`; the server derives the subscription key from the recovered payer. +3. Requests `/api/article` again, signs a fresh credential, and reuses the active subscription without another charge. ## 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' +pnpm mppx localhost:5173/api/article ``` diff --git a/examples/subscription/src/client.ts b/examples/subscription/src/client.ts index c28f62fe..52441460 100644 --- a/examples/subscription/src/client.ts +++ b/examples/subscription/src/client.ts @@ -6,7 +6,6 @@ import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' import { Chain } from 'viem/tempo' 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({ @@ -29,9 +28,7 @@ const mppx = Mppx.create({ }) async function readArticle(label: string) { - const response = await mppx.fetch(`${baseUrl}/api/article`, { - headers: { 'X-User-Id': userId }, - }) + const response = await mppx.fetch(`${baseUrl}/api/article`) if (!response.ok) throw new Error(`article request failed: ${response.status}`) const receipt = Receipt.fromResponse(response) @@ -50,6 +47,6 @@ console.log('Run the server with an overdue stored subscription to exercise rene await readArticle('Reused access') -const subscriptionResponse = await fetch(`${baseUrl}/api/subscription?userId=${userId}`) +const subscriptionResponse = await mppx.fetch(`${baseUrl}/api/subscription`) 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 index 37a3dcc2..b723e9a9 100644 --- a/examples/subscription/src/server.ts +++ b/examples/subscription/src/server.ts @@ -1,3 +1,4 @@ +import { Receipt } from 'mppx' import { Mppx, Store, tempo } from 'mppx/server' import { Subscription } from 'mppx/tempo' import { generatePrivateKey, privateKeyToAccount } from 'viem/accounts' @@ -15,12 +16,8 @@ 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') +function subscriptionKey(source: { address: string; chainId: number }) { + return `news:eip155:${source.chainId}:${source.address.toLowerCase()}:${planId}` } const mppx = Mppx.create({ @@ -32,10 +29,10 @@ const mppx = Mppx.create({ periodCount, periodUnit, recipient: account.address, - resolve: async ({ input }) => { - const userId = getUserId(input) - if (!userId) return null - return { key: subscriptionKey(userId) } + requireCredential: true, + resolve: async ({ source }) => { + if (!source) return null + return { key: subscriptionKey(source) } }, store, subscriptionExpires, @@ -57,9 +54,17 @@ export async function handler(request: Request): Promise { 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))) + const result = await mppx.tempo.subscription({ + description: 'News app daily subscription status', + })(request) + if (result.status === 402) return result.challenge + + // The verified credential receipt identifies the payer-bound subscription. + const receipt = Receipt.fromResponse(result.withReceipt(new Response(null))) + if (!receipt.subscriptionId) { + return Response.json({ error: 'missing subscription receipt' }, { status: 500 }) + } + return result.withReceipt(Response.json(await subscriptions.get(receipt.subscriptionId))) } if (url.pathname === '/api/article') { diff --git a/src/tempo/server/Subscription.test.ts b/src/tempo/server/Subscription.test.ts index da0277ba..7620a752 100644 --- a/src/tempo/server/Subscription.test.ts +++ b/src/tempo/server/Subscription.test.ts @@ -32,6 +32,9 @@ const subscriptionRecipient = '0x1234567890abcdef1234567890abcdef12345678' const rootAccount = privateKeyToAccount( '0x0000000000000000000000000000000000000000000000000000000000000001', ) +const otherRootAccount = privateKeyToAccount( + '0x0000000000000000000000000000000000000000000000000000000000000004', +) const accessAccount = privateKeyToAccount( '0x0000000000000000000000000000000000000000000000000000000000000002', ) @@ -81,10 +84,11 @@ async function createCredential( challenge: Challenge.Challenge, source = rootAccount.address, key: SubscriptionAccessKey = accessKey, + account = rootAccount, ) { const keyAuthorization = await signSubscriptionKeyAuthorization({ accessKey: key, - account: rootAccount, + account, chainId, request: challenge.request as ReturnType, }) @@ -271,6 +275,211 @@ describe('tempo.subscription', () => { expect(rpcMethods.filter((method) => method === 'eth_sendRawTransaction')).toHaveLength(1) }) + test('requires a fresh credential for active subscription reuse when configured', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + await subscriptions.put( + createRecord({ + accessKey, + payer: { address: rootAccount.address, chainId }, + }), + ) + const method = subscription({ + activate: async () => { + throw new Error('active subscription reuse should not activate') + }, + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + requireCredential: true, + resolve: async () => ({ accessKey, key: subscriptionKey }), + store, + subscriptionExpires: activeSubscriptionExpires, + }) + + const mppx = Mppx.create({ methods: [method], realm, secretKey }) + const unauthenticated = await mppx.tempo.subscription({})( + new Request('https://example.com/resource'), + ) + + expect(unauthenticated.status).toBe(402) + if (unauthenticated.status !== 402) throw new Error('expected reuse challenge') + + const challenge = Challenge.fromResponse(unauthenticated.challenge) + const credential = await createCredential(challenge) + const reused = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(reused.status).toBe(200) + }) + + test('can derive the subscription lookup key only from the signed credential source', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + const { client, rpcMethods } = createBillingClient([hashActivate]) + const sourceKey = (source: { address: string; chainId: number }) => + `payer:${source.chainId}:${source.address.toLowerCase()}:pro` + const method = subscription({ + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + getClient: async () => client, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + requireCredential: true, + resolve: async ({ source }) => { + if (!source) return null + return { key: sourceKey(source) } + }, + 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 source challenge') + + const challenge = Challenge.fromResponse(challengeResult.challenge) + const challengeAccessKey = ( + challenge.request as ReturnType + ).methodDetails?.accessKey + if (!challengeAccessKey) throw new Error('expected challenge access key') + + const credential = await createCredential(challenge, rootAccount.address, challengeAccessKey) + const activated = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(activated.status).toBe(200) + const lookupKey = sourceKey({ address: rootAccount.address, chainId }) + const record = await subscriptions.getByKey(lookupKey) + expect(record?.lookupKey).toBe(lookupKey) + expect(record?.payer?.address.toLowerCase()).toBe(rootAccount.address.toLowerCase()) + expect(record?.accessKey).toEqual(challengeAccessKey) + expect(rpcMethods.filter((method) => method === 'eth_sendRawTransaction')).toHaveLength(1) + }) + + test('rejects credential reuse when the signer does not match the stored payer', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + await subscriptions.put( + createRecord({ + accessKey, + payer: { address: rootAccount.address, chainId }, + }), + ) + const method = subscription({ + activate: async () => { + throw new Error('active subscription reuse should not activate') + }, + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + requireCredential: true, + resolve: async () => ({ accessKey, 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 reuse challenge') + + const challenge = Challenge.fromResponse(challengeResult.challenge) + const credential = await createCredential( + challenge, + otherRootAccount.address, + accessKey, + otherRootAccount, + ) + const rejected = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(rejected.status).toBe(402) + if (rejected.status !== 402) throw new Error('expected payer mismatch challenge') + const body = await rejected.challenge.json() + expect(body.detail).toBe('Payment verification failed: subscription payer mismatch.') + }) + + test('renews an overdue active subscription after credential-required payer proof', async () => { + const store = Store.memory() + const subscriptions = SubscriptionStore.fromStore(store) + await subscriptions.put( + createRecord({ + accessKey, + billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(), + lastChargedPeriod: 0, + payer: { address: rootAccount.address, chainId }, + reference: hashStale, + }), + ) + const method = subscription({ + activate: async () => { + throw new Error('active subscription renewal should not activate') + }, + amount: subscriptionAmount, + chainId, + currency: subscriptionCurrency, + periodCount: subscriptionPeriodCount, + periodUnit: subscriptionPeriodUnit, + recipient: subscriptionRecipient, + renew: async ({ periodIndex, subscription }) => ({ + receipt: createReceipt(subscription.subscriptionId, hashRenewed), + subscription: { + ...subscription, + lastChargedPeriod: periodIndex, + reference: hashRenewed, + }, + }), + requireCredential: true, + resolve: async () => ({ accessKey, 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 reuse challenge') + + const challenge = Challenge.fromResponse(challengeResult.challenge) + const credential = await createCredential(challenge) + const renewed = await mppx.tempo.subscription({})( + new Request('https://example.com/resource', { + headers: { Authorization: Credential.serialize(credential) }, + }), + ) + + expect(renewed.status).toBe(200) + if (renewed.status !== 200) throw new Error('expected credential renewal') + const receipt = Receipt.fromResponse(renewed.withReceipt(new Response('OK'))) + expect(receipt.reference).toBe(hashRenewed) + expect((await subscriptions.getByKey(subscriptionKey))?.lastChargedPeriod).toBeGreaterThan(0) + }) + test('verifyCredential activates a subscription credential with a canonical challenge request', async () => { const store = Store.memory() const { client } = createBillingClient([hashActivate]) diff --git a/src/tempo/server/Subscription.ts b/src/tempo/server/Subscription.ts index d0d7b9cb..005e54c8 100644 --- a/src/tempo/server/Subscription.ts +++ b/src/tempo/server/Subscription.ts @@ -113,6 +113,8 @@ export function subscription( }, async authorize({ input, request }) { + if (parameters.requireCredential) return undefined + const resolved = await parameters.resolve({ input, request }) if (!resolved) return undefined @@ -182,8 +184,8 @@ export function subscription( const input = requestFromCaptured(capturedRequest) const resolved = await parameters.resolve({ input, request: parsedRequest }) const existing = resolved ? await store.getByKey(resolved.key) : null - const accessKey = - resolved && !credential + const accessKey = !credential + ? resolved ? await resolveChallengeAccessKey({ existing, input, @@ -192,7 +194,10 @@ export function subscription( resolved, store, }) - : (credentialRequest?.methodDetails?.accessKey ?? parsedRequest.methodDetails?.accessKey) + : parameters.requireCredential && !parameters.activate + ? await createUnboundChallengeAccessKey({ store }) + : undefined + : (credentialRequest?.methodDetails?.accessKey ?? parsedRequest.methodDetails?.accessKey) if (!accessKey) { throw new VerificationFailedError({ reason: 'subscription accessKey is missing' }) } @@ -218,16 +223,17 @@ export function subscription( 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 })) + let resolved: subscription.ResolvedSubscription | null = null + let accessKey = + challengeRequest.methodDetails?.accessKey ?? parsedRequest.methodDetails?.accessKey + if (!accessKey) { + resolved = await parameters.resolve({ input, request: parsedRequest }) + if (!resolved) { + throw new VerificationFailedError({ reason: 'subscription could not be resolved' }) + } + accessKey = await resolveAccessKey({ input, parameters, request: parsedRequest, resolved }) + } if (!accessKey) { throw new VerificationFailedError({ reason: 'subscription accessKey is missing' }) } @@ -247,10 +253,20 @@ export function subscription( reason: 'credential source does not match signature', }) } + resolved = + (await parameters.resolve({ input, request: parsedRequest, source: verified.source })) ?? + resolved + + if (!resolved) { + throw new VerificationFailedError({ reason: 'subscription could not be resolved' }) + } const activation = await store.activate({ challengeId: credential.challenge.id, - isReusable: (subscription) => isReusableSubscription(subscription, parsedRequest), + isReusable: (subscription) => + parameters.requireCredential + ? isActiveSubscriptionForRequest(subscription, parsedRequest) + : isReusableSubscription(subscription, parsedRequest), lookupKey: resolved.key, async create() { const activation = withSubscriptionAccessKey( @@ -299,7 +315,50 @@ export function subscription( throw new VerificationFailedError({ reason: 'subscription activation claim mismatch' }) } if (activation.status === 'existing') { - return SubscriptionReceipt.fromRecord(activation.subscription) + const subscription = activation.subscription + assertSubscriptionPayer(subscription, verified.source, { + required: parameters.requireCredential, + }) + + const periodIndex = getPeriodIndex(subscription) + if (periodIndex > subscription.lastChargedPeriod) { + const renew = resolveRenewalHandler({ + feePayer, + feePayerPolicy, + getClient, + parameters, + store, + subscription, + waitForConfirmation, + }) + if (!renew) { + throw new VerificationFailedError({ reason: 'subscription renewal is required' }) + } + + const renewal = await settleRenewal({ + expectedLookupKey: resolved.key, + periodIndex, + renew, + request: parsedRequest, + store, + subscription, + }) + if (!renewal) { + throw new VerificationFailedError({ reason: 'subscription renewal failed' }) + } + if (renewal.status === 'charged' || renewal.status === 'inFlight') { + return renewal.receipt + } + + await parameters.hooks?.renewed?.({ + periodIndex, + receipt: renewal.result.receipt, + subscription: renewal.result.subscription, + }) + return renewal.result.receipt + } + + return SubscriptionReceipt.fromRecord(subscription) } await parameters.hooks?.activated?.({ @@ -380,6 +439,18 @@ async function resolveChallengeAccessKey(parameters: { ) } +async function createUnboundChallengeAccessKey(parameters: { + store: SubscriptionStore.SubscriptionStore +}) { + const accessKey = await parameters.store.getOrCreateAccessKey( + `challenge:${createSubscriptionId()}`, + ) + return { + accessKeyAddress: accessKey.accessKeyAddress, + keyType: accessKey.keyType, + } satisfies SubscriptionAccessKey +} + async function activateSubscription(parameters: { accessKey: SubscriptionAccessKey auto: { @@ -567,14 +638,20 @@ function isActive(subscription: SubscriptionRecord): boolean { return new Date(subscription.subscriptionExpires).getTime() > Date.now() } +function isActiveSubscriptionForRequest( + subscription: SubscriptionRecord, + request: SubscriptionRequest, +): boolean { + return isActive(subscription) && subscriptionMatchesRequest(subscription, request) +} + function isReusableSubscription( subscription: SubscriptionRecord, request: SubscriptionRequest, ): boolean { return ( - isActive(subscription) && - getPeriodIndex(subscription) <= subscription.lastChargedPeriod && - subscriptionMatchesRequest(subscription, request) + isActiveSubscriptionForRequest(subscription, request) && + getPeriodIndex(subscription) <= subscription.lastChargedPeriod ) } @@ -589,6 +666,25 @@ function subscriptionMatchesRequest( ) } +function assertSubscriptionPayer( + subscription: SubscriptionRecord, + source: { address: Address; chainId: number }, + options?: { required?: boolean | undefined }, +) { + if (!subscription.payer) { + if (options?.required) { + throw new VerificationFailedError({ reason: 'subscription payer is missing' }) + } + return + } + if ( + subscription.payer.chainId !== source.chainId || + !isAddressEqual(subscription.payer.address, source.address) + ) { + throw new VerificationFailedError({ reason: 'subscription payer mismatch' }) + } +} + function comparableSubscriptionBinding(value: SubscriptionRecord | SubscriptionRequest) { const chainId = 'chainId' in value ? value.chainId : (value as SubscriptionRequest).methodDetails?.chainId @@ -804,7 +900,9 @@ async function submitSubscriptionPayment(parameters: { store, waitForConfirmation, } = parameters - const stored = await store.getAccessKey(lookupKey) + const stored = + (await store.getAccessKey(lookupKey)) ?? + (await store.getAccessKeyByAddress(accessKey.accessKeyAddress)) if (!stored) { throw new VerificationFailedError({ reason: 'subscription access key is missing' }) } @@ -1054,6 +1152,11 @@ export declare namespace subscription { * Keeps concurrent renewal safe while allowing recovery from abandoned attempts. */ renewalTimeoutMs?: number | undefined + /** + * Requires a fresh subscription Credential even when a subscription is active. + * This binds access reuse to the stored payer instead of trusting request metadata alone. + */ + requireCredential?: boolean | undefined /** * Override the fee-payer policy for sponsored subscription payments. * Useful when the access key + key authorization tx requires more gas @@ -1101,6 +1204,8 @@ export declare namespace subscription { resolve: (parameters: { input: Request request: SubscriptionRequest + /** Verified payer identity recovered from a signed subscription credential. */ + source?: { address: Address; chainId: number } | undefined }) => MaybePromise renew?: (parameters: { /** Stable idempotency/reconciliation reference persisted before the renewal hook runs. */ diff --git a/src/tempo/subscription/Store.ts b/src/tempo/subscription/Store.ts index 78c94b64..8caba96e 100644 --- a/src/tempo/subscription/Store.ts +++ b/src/tempo/subscription/Store.ts @@ -22,6 +22,8 @@ export type SubscriptionStore = { get(subscriptionId: string): Promise /** Looks up a generated access key for a resolved request key. */ getAccessKey(key: string): Promise + /** Looks up a generated access key by its public access key address. */ + getAccessKeyByAddress(address: 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. */ @@ -104,6 +106,10 @@ export function fromStore( return `${accessKeyPrefix}${key}` } + function accessKeyAddressKey(address: string): string { + return `${accessKeyPrefix}address:${address.toLowerCase()}` + } + function lookupRecordKey(key: string): string { return `${keyPrefix}${key}` } @@ -243,6 +249,10 @@ export function fromStore( return (await store.get(accessKeyKey(key))) as SubscriptionAccessKeyRecord | null }, + async getAccessKeyByAddress(address) { + return (await store.get(accessKeyAddressKey(address))) as SubscriptionAccessKeyRecord | null + }, + async getByKey(key) { return getByLookupKey(key) }, @@ -258,15 +268,23 @@ export function fromStore( 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 } - }, - ) + return store + .update( + accessKeyKey(key), + (current): Store.Change => { + if (current) { + return { op: 'noop', result: current as SubscriptionAccessKeyRecord } + } + return { op: 'set', value: candidate, result: candidate } + }, + ) + .then(async (record) => { + await store.update(accessKeyAddressKey(record.accessKeyAddress), (current) => { + if (current) return { op: 'noop', result: undefined } + return { op: 'set', value: record, result: undefined } + }) + return record + }) }, async put(record) {