Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/friendly-subscription-inputs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added Date challenge expirations and numeric Tempo subscription period counts.
5 changes: 5 additions & 0 deletions .changeset/harden-tempo-subscriptions.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'mppx': patch
---

Added Tempo subscription key authorization, subscription receipt identifiers, activation replay protection, renewal idempotency, and dynamic access key handling.
1 change: 1 addition & 0 deletions examples/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
40 changes: 40 additions & 0 deletions examples/subscription/README.md
Original file line number Diff line number Diff line change
@@ -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'
```
20 changes: 20 additions & 0 deletions examples/subscription/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
55 changes: 55 additions & 0 deletions examples/subscription/src/client.ts
Original file line number Diff line number Diff line change
@@ -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}`)
81 changes: 81 additions & 0 deletions examples/subscription/src/server.ts
Original file line number Diff line number Diff line change
@@ -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<Response | null> {
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
}
13 changes: 13 additions & 0 deletions examples/subscription/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
{
"compilerOptions": {
"target": "ESNext",
"module": "ESNext",
"moduleResolution": "bundler",
"strict": true,
"skipLibCheck": true,
"lib": ["ESNext", "DOM"],
"types": ["node"],
"noEmit": true
},
"include": ["src/**/*"]
}
45 changes: 45 additions & 0 deletions examples/subscription/vite.config.ts
Original file line number Diff line number Diff line change
@@ -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
}
Loading
Loading