mppx 0.6.18 → 0.6.20
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/dist/Challenge.d.ts +2 -2
- package/dist/Challenge.d.ts.map +1 -1
- package/dist/Challenge.js +1 -1
- package/dist/Challenge.js.map +1 -1
- package/dist/Method.d.ts +34 -0
- package/dist/Method.d.ts.map +1 -1
- package/dist/Method.js +3 -1
- package/dist/Method.js.map +1 -1
- package/dist/Receipt.d.ts +1 -0
- package/dist/Receipt.d.ts.map +1 -1
- package/dist/Receipt.js +2 -0
- package/dist/Receipt.js.map +1 -1
- package/dist/client/Methods.d.ts +1 -0
- package/dist/client/Methods.d.ts.map +1 -1
- package/dist/client/Methods.js +1 -0
- package/dist/client/Methods.js.map +1 -1
- package/dist/middlewares/elysia.d.ts.map +1 -1
- package/dist/middlewares/elysia.js +14 -0
- package/dist/middlewares/elysia.js.map +1 -1
- package/dist/middlewares/express.d.ts.map +1 -1
- package/dist/middlewares/express.js +1 -2
- package/dist/middlewares/express.js.map +1 -1
- package/dist/middlewares/hono.d.ts.map +1 -1
- package/dist/middlewares/hono.js +14 -0
- package/dist/middlewares/hono.js.map +1 -1
- package/dist/middlewares/nextjs.d.ts.map +1 -1
- package/dist/middlewares/nextjs.js +14 -0
- package/dist/middlewares/nextjs.js.map +1 -1
- package/dist/proxy/Proxy.d.ts.map +1 -1
- package/dist/proxy/Proxy.js +2 -2
- package/dist/proxy/Proxy.js.map +1 -1
- package/dist/proxy/Service.d.ts.map +1 -1
- package/dist/proxy/Service.js +1 -1
- package/dist/proxy/Service.js.map +1 -1
- package/dist/server/Mppx.d.ts +15 -3
- package/dist/server/Mppx.d.ts.map +1 -1
- package/dist/server/Mppx.js +190 -40
- package/dist/server/Mppx.js.map +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts +1 -1
- package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/stripe/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/Methods.d.ts +96 -0
- package/dist/tempo/Methods.d.ts.map +1 -1
- package/dist/tempo/Methods.js +97 -0
- package/dist/tempo/Methods.js.map +1 -1
- package/dist/tempo/client/Methods.d.ts +3 -0
- package/dist/tempo/client/Methods.d.ts.map +1 -1
- package/dist/tempo/client/Methods.js +3 -0
- package/dist/tempo/client/Methods.js.map +1 -1
- package/dist/tempo/client/Subscription.d.ts +114 -0
- package/dist/tempo/client/Subscription.d.ts.map +1 -0
- package/dist/tempo/client/Subscription.js +100 -0
- package/dist/tempo/client/Subscription.js.map +1 -0
- package/dist/tempo/client/index.d.ts +1 -0
- package/dist/tempo/client/index.d.ts.map +1 -1
- package/dist/tempo/client/index.js +1 -0
- package/dist/tempo/client/index.js.map +1 -1
- package/dist/tempo/index.d.ts +1 -0
- package/dist/tempo/index.d.ts.map +1 -1
- package/dist/tempo/index.js +1 -0
- package/dist/tempo/index.js.map +1 -1
- package/dist/tempo/server/Charge.js +2 -2
- package/dist/tempo/server/Charge.js.map +1 -1
- package/dist/tempo/server/Methods.d.ts +5 -0
- package/dist/tempo/server/Methods.d.ts.map +1 -1
- package/dist/tempo/server/Methods.js +5 -0
- package/dist/tempo/server/Methods.js.map +1 -1
- package/dist/tempo/server/Subscription.d.ts +221 -0
- package/dist/tempo/server/Subscription.d.ts.map +1 -0
- package/dist/tempo/server/Subscription.js +637 -0
- package/dist/tempo/server/Subscription.js.map +1 -0
- package/dist/tempo/server/index.d.ts +1 -0
- package/dist/tempo/server/index.d.ts.map +1 -1
- package/dist/tempo/server/index.js +1 -0
- package/dist/tempo/server/index.js.map +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts +1 -1
- package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/html.gen.js.map +1 -1
- package/dist/tempo/session/Chain.d.ts.map +1 -1
- package/dist/tempo/session/Chain.js +3 -4
- package/dist/tempo/session/Chain.js.map +1 -1
- package/dist/tempo/subscription/KeyAuthorization.d.ts +282 -0
- package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -0
- package/dist/tempo/subscription/KeyAuthorization.js +297 -0
- package/dist/tempo/subscription/KeyAuthorization.js.map +1 -0
- package/dist/tempo/subscription/Receipt.d.ts +10 -0
- package/dist/tempo/subscription/Receipt.d.ts.map +1 -0
- package/dist/tempo/subscription/Receipt.js +16 -0
- package/dist/tempo/subscription/Receipt.js.map +1 -0
- package/dist/tempo/subscription/Store.d.ts +99 -0
- package/dist/tempo/subscription/Store.d.ts.map +1 -0
- package/dist/tempo/subscription/Store.js +292 -0
- package/dist/tempo/subscription/Store.js.map +1 -0
- package/dist/tempo/subscription/Types.d.ts +65 -0
- package/dist/tempo/subscription/Types.d.ts.map +1 -0
- package/dist/tempo/subscription/Types.js +2 -0
- package/dist/tempo/subscription/Types.js.map +1 -0
- package/dist/tempo/subscription/index.d.ts +6 -0
- package/dist/tempo/subscription/index.d.ts.map +1 -0
- package/dist/tempo/subscription/index.js +4 -0
- package/dist/tempo/subscription/index.js.map +1 -0
- package/dist/zod.d.ts +7 -0
- package/dist/zod.d.ts.map +1 -1
- package/dist/zod.js +18 -0
- package/dist/zod.js.map +1 -1
- package/package.json +3 -3
- package/src/Challenge.test.ts +13 -0
- package/src/Challenge.ts +3 -3
- package/src/Method.ts +46 -1
- package/src/Receipt.ts +2 -0
- package/src/client/Methods.ts +1 -0
- package/src/middlewares/elysia.test.ts +31 -1
- package/src/middlewares/elysia.ts +13 -0
- package/src/middlewares/express.ts +1 -5
- package/src/middlewares/hono.test.ts +30 -1
- package/src/middlewares/hono.ts +13 -0
- package/src/middlewares/nextjs.test.ts +28 -1
- package/src/middlewares/nextjs.ts +13 -0
- package/src/proxy/Proxy.ts +2 -5
- package/src/proxy/Service.test.ts +34 -0
- package/src/proxy/Service.ts +7 -0
- package/src/server/Mppx.authorize.test.ts +210 -0
- package/src/server/Mppx.test-d.ts +23 -1
- package/src/server/Mppx.test.ts +73 -3
- package/src/server/Mppx.ts +291 -58
- package/src/stripe/server/internal/html/package.json +1 -1
- package/src/stripe/server/internal/html.gen.ts +1 -1
- package/src/tempo/Methods.test.ts +131 -0
- package/src/tempo/Methods.ts +136 -0
- package/src/tempo/Subscription.integration.test.ts +591 -0
- package/src/tempo/client/Methods.ts +3 -0
- package/src/tempo/client/Subscription.test.ts +131 -0
- package/src/tempo/client/Subscription.ts +155 -0
- package/src/tempo/client/index.ts +1 -0
- package/src/tempo/index.ts +1 -0
- package/src/tempo/server/Charge.ts +2 -2
- package/src/tempo/server/Methods.ts +5 -0
- package/src/tempo/server/Subscription.test.ts +1410 -0
- package/src/tempo/server/Subscription.ts +1014 -0
- package/src/tempo/server/index.ts +1 -0
- package/src/tempo/server/internal/html/package.json +1 -1
- package/src/tempo/server/internal/html.gen.ts +1 -1
- package/src/tempo/session/Chain.ts +3 -5
- package/src/tempo/subscription/KeyAuthorization.test.ts +204 -0
- package/src/tempo/subscription/KeyAuthorization.ts +394 -0
- package/src/tempo/subscription/Receipt.ts +28 -0
- package/src/tempo/subscription/Store.test.ts +554 -0
- package/src/tempo/subscription/Store.ts +431 -0
- package/src/tempo/subscription/Types.ts +68 -0
- package/src/tempo/subscription/index.ts +23 -0
- package/src/zod.test.ts +23 -1
- package/src/zod.ts +24 -0
|
@@ -0,0 +1,1410 @@
|
|
|
1
|
+
import { Challenge, Credential, Receipt } from 'mppx'
|
|
2
|
+
import { Mppx } from 'mppx/server'
|
|
3
|
+
import { KeyAuthorization } from 'ox/tempo'
|
|
4
|
+
import { createClient, custom } from 'viem'
|
|
5
|
+
import { privateKeyToAccount } from 'viem/accounts'
|
|
6
|
+
import { tempo as tempo_chain } from 'viem/chains'
|
|
7
|
+
import { describe, expect, test } from 'vp/test'
|
|
8
|
+
|
|
9
|
+
import * as Store from '../../Store.js'
|
|
10
|
+
import * as Methods from '../Methods.js'
|
|
11
|
+
import { signSubscriptionKeyAuthorization } from '../subscription/KeyAuthorization.js'
|
|
12
|
+
import * as SubscriptionStore from '../subscription/Store.js'
|
|
13
|
+
import type { SubscriptionAccessKey } from '../subscription/Types.js'
|
|
14
|
+
import type { SubscriptionRecord } from '../subscription/Types.js'
|
|
15
|
+
import { renew, subscription } from './Subscription.js'
|
|
16
|
+
|
|
17
|
+
const realm = 'api.example.com'
|
|
18
|
+
const secretKey = 'test-secret-key'
|
|
19
|
+
const activeBillingAnchor = new Date(Math.floor(Date.now() / 1_000) * 1_000).toISOString()
|
|
20
|
+
const activeSubscriptionExpires = new Date(
|
|
21
|
+
Math.ceil((Date.now() + 365 * 24 * 60 * 60 * 1_000) / 1_000) * 1_000,
|
|
22
|
+
).toISOString()
|
|
23
|
+
const chainId = 4217
|
|
24
|
+
const subscriptionDefaultChainId = 42431
|
|
25
|
+
const subscriptionAmount = '10'
|
|
26
|
+
const subscriptionCurrency = '0x20c0000000000000000000000000000000000001'
|
|
27
|
+
const subscriptionKey = 'user-1:plan:pro'
|
|
28
|
+
const subscriptionPeriodCount = '1'
|
|
29
|
+
const subscriptionPeriodUnit = 'day'
|
|
30
|
+
const subscriptionPeriodMilliseconds = 86_400_000
|
|
31
|
+
const subscriptionRecipient = '0x1234567890abcdef1234567890abcdef12345678'
|
|
32
|
+
const rootAccount = privateKeyToAccount(
|
|
33
|
+
'0x0000000000000000000000000000000000000000000000000000000000000001',
|
|
34
|
+
)
|
|
35
|
+
const accessAccount = privateKeyToAccount(
|
|
36
|
+
'0x0000000000000000000000000000000000000000000000000000000000000002',
|
|
37
|
+
)
|
|
38
|
+
const otherAccessAccount = privateKeyToAccount(
|
|
39
|
+
'0x0000000000000000000000000000000000000000000000000000000000000003',
|
|
40
|
+
)
|
|
41
|
+
const accessKey = {
|
|
42
|
+
accessKeyAddress: accessAccount.address,
|
|
43
|
+
keyType: 'secp256k1',
|
|
44
|
+
} as const satisfies SubscriptionAccessKey
|
|
45
|
+
const hashActivate = `0x${'a'.repeat(64)}`
|
|
46
|
+
const hashRenewed = `0x${'b'.repeat(64)}`
|
|
47
|
+
const hashStale = `0x${'c'.repeat(64)}`
|
|
48
|
+
const hashBackground = `0x${'d'.repeat(64)}`
|
|
49
|
+
const hashOld = `0x${'e'.repeat(64)}`
|
|
50
|
+
|
|
51
|
+
function createReceipt(subscriptionId: string, reference = hashActivate) {
|
|
52
|
+
return {
|
|
53
|
+
method: 'tempo',
|
|
54
|
+
reference,
|
|
55
|
+
status: 'success',
|
|
56
|
+
subscriptionId,
|
|
57
|
+
timestamp: '2025-01-01T00:00:00.000Z',
|
|
58
|
+
} as const
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function createRecord(overrides: Partial<SubscriptionRecord> = {}): SubscriptionRecord {
|
|
62
|
+
return {
|
|
63
|
+
amount: '10000000',
|
|
64
|
+
billingAnchor: activeBillingAnchor,
|
|
65
|
+
chainId,
|
|
66
|
+
currency: subscriptionCurrency,
|
|
67
|
+
lastChargedPeriod: 0,
|
|
68
|
+
lookupKey: subscriptionKey,
|
|
69
|
+
periodCount: subscriptionPeriodCount,
|
|
70
|
+
periodUnit: subscriptionPeriodUnit,
|
|
71
|
+
recipient: subscriptionRecipient,
|
|
72
|
+
reference: hashActivate,
|
|
73
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
74
|
+
subscriptionId: 'sub_123',
|
|
75
|
+
timestamp: '2025-01-01T00:00:00.000Z',
|
|
76
|
+
...overrides,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function createCredential(
|
|
81
|
+
challenge: Challenge.Challenge,
|
|
82
|
+
source = rootAccount.address,
|
|
83
|
+
key: SubscriptionAccessKey = accessKey,
|
|
84
|
+
) {
|
|
85
|
+
const keyAuthorization = await signSubscriptionKeyAuthorization({
|
|
86
|
+
accessKey: key,
|
|
87
|
+
account: rootAccount,
|
|
88
|
+
chainId,
|
|
89
|
+
request: challenge.request as ReturnType<typeof Methods.subscription.schema.request.parse>,
|
|
90
|
+
})
|
|
91
|
+
if (!keyAuthorization) throw new Error('expected key authorization')
|
|
92
|
+
return Credential.from({
|
|
93
|
+
challenge,
|
|
94
|
+
payload: {
|
|
95
|
+
signature: KeyAuthorization.serialize(keyAuthorization),
|
|
96
|
+
type: 'keyAuthorization',
|
|
97
|
+
},
|
|
98
|
+
source: `did:pkh:eip155:${chainId}:${source.toLowerCase()}`,
|
|
99
|
+
})
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function createBillingClient(hashes: readonly string[]) {
|
|
103
|
+
const rpcMethods: string[] = []
|
|
104
|
+
let nextHash = 0
|
|
105
|
+
const client = createClient({
|
|
106
|
+
chain: { ...tempo_chain, id: chainId },
|
|
107
|
+
transport: custom({
|
|
108
|
+
async request({ method }) {
|
|
109
|
+
rpcMethods.push(method)
|
|
110
|
+
if (method === 'eth_chainId') return `0x${chainId.toString(16)}`
|
|
111
|
+
if (method === 'eth_call') return '0x'
|
|
112
|
+
if (method === 'eth_sendRawTransaction') return hashes[nextHash++] ?? hashActivate
|
|
113
|
+
throw new Error(`unexpected rpc method: ${method}`)
|
|
114
|
+
},
|
|
115
|
+
}),
|
|
116
|
+
})
|
|
117
|
+
return { client, rpcMethods }
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
describe('tempo.subscription', () => {
|
|
121
|
+
test('stores an activated subscription and reuses it on later requests', async () => {
|
|
122
|
+
const store = Store.memory()
|
|
123
|
+
let activationCount = 0
|
|
124
|
+
const method = subscription({
|
|
125
|
+
activate: async ({ request, resolved }) => {
|
|
126
|
+
activationCount += 1
|
|
127
|
+
return {
|
|
128
|
+
receipt: createReceipt('sub_123', hashActivate),
|
|
129
|
+
subscription: createRecord({
|
|
130
|
+
amount: request.amount,
|
|
131
|
+
chainId: request.methodDetails?.chainId,
|
|
132
|
+
currency: request.currency,
|
|
133
|
+
lookupKey: resolved.key,
|
|
134
|
+
periodCount: request.periodCount,
|
|
135
|
+
periodUnit: request.periodUnit,
|
|
136
|
+
recipient: request.recipient,
|
|
137
|
+
reference: hashActivate,
|
|
138
|
+
subscriptionExpires: request.subscriptionExpires,
|
|
139
|
+
}),
|
|
140
|
+
}
|
|
141
|
+
},
|
|
142
|
+
amount: subscriptionAmount,
|
|
143
|
+
chainId,
|
|
144
|
+
currency: subscriptionCurrency,
|
|
145
|
+
periodCount: subscriptionPeriodCount,
|
|
146
|
+
periodUnit: subscriptionPeriodUnit,
|
|
147
|
+
recipient: subscriptionRecipient,
|
|
148
|
+
resolve: async ({ input }) => {
|
|
149
|
+
const key = input.headers.get('X-Subscription-Key')
|
|
150
|
+
return key ? { accessKey, key } : null
|
|
151
|
+
},
|
|
152
|
+
store,
|
|
153
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
157
|
+
const challengeResult = await mppx.tempo.subscription({})(
|
|
158
|
+
new Request('https://example.com/resource', {
|
|
159
|
+
headers: { 'X-Subscription-Key': subscriptionKey },
|
|
160
|
+
}),
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
expect(challengeResult.status).toBe(402)
|
|
164
|
+
if (challengeResult.status !== 402) throw new Error('expected activation challenge')
|
|
165
|
+
|
|
166
|
+
const challenge = Challenge.fromResponse(challengeResult.challenge)
|
|
167
|
+
const challengeRequest = challenge.request as ReturnType<
|
|
168
|
+
typeof Methods.subscription.schema.request.parse
|
|
169
|
+
>
|
|
170
|
+
expect(challengeRequest.methodDetails?.accessKey).toEqual({
|
|
171
|
+
...accessKey,
|
|
172
|
+
accessKeyAddress: accessKey.accessKeyAddress.toLowerCase(),
|
|
173
|
+
})
|
|
174
|
+
const credential = await createCredential(challenge)
|
|
175
|
+
|
|
176
|
+
const activated = await mppx.tempo.subscription({})(
|
|
177
|
+
new Request('https://example.com/resource', {
|
|
178
|
+
headers: {
|
|
179
|
+
Authorization: Credential.serialize(credential),
|
|
180
|
+
'X-Subscription-Key': subscriptionKey,
|
|
181
|
+
},
|
|
182
|
+
}),
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
expect(activated.status).toBe(200)
|
|
186
|
+
expect(activationCount).toBe(1)
|
|
187
|
+
|
|
188
|
+
const replayed = await mppx.tempo.subscription({})(
|
|
189
|
+
new Request('https://example.com/resource', {
|
|
190
|
+
headers: {
|
|
191
|
+
Authorization: Credential.serialize(credential),
|
|
192
|
+
'X-Subscription-Key': subscriptionKey,
|
|
193
|
+
},
|
|
194
|
+
}),
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
expect(replayed.status).toBe(402)
|
|
198
|
+
expect(activationCount).toBe(1)
|
|
199
|
+
|
|
200
|
+
const reused = await mppx.tempo.subscription({})(
|
|
201
|
+
new Request('https://example.com/resource', {
|
|
202
|
+
headers: {
|
|
203
|
+
'X-Subscription-Key': subscriptionKey,
|
|
204
|
+
},
|
|
205
|
+
}),
|
|
206
|
+
)
|
|
207
|
+
|
|
208
|
+
expect(reused.status).toBe(200)
|
|
209
|
+
if (reused.status !== 200) throw new Error('expected authorize reuse')
|
|
210
|
+
|
|
211
|
+
const response = reused.withReceipt(new Response('OK'))
|
|
212
|
+
const receipt = response.headers.get('Payment-Receipt')
|
|
213
|
+
expect(receipt).toBeTruthy()
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
test('automatically creates an access key and submits the activation payment', async () => {
|
|
217
|
+
const store = Store.memory()
|
|
218
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
219
|
+
const { client, rpcMethods } = createBillingClient([hashActivate])
|
|
220
|
+
const method = subscription({
|
|
221
|
+
amount: subscriptionAmount,
|
|
222
|
+
chainId,
|
|
223
|
+
currency: subscriptionCurrency,
|
|
224
|
+
getClient: async () => client,
|
|
225
|
+
periodCount: subscriptionPeriodCount,
|
|
226
|
+
periodUnit: subscriptionPeriodUnit,
|
|
227
|
+
recipient: subscriptionRecipient,
|
|
228
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
229
|
+
store,
|
|
230
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
231
|
+
waitForConfirmation: false,
|
|
232
|
+
})
|
|
233
|
+
|
|
234
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
235
|
+
const challengeResult = await mppx.tempo.subscription({})(
|
|
236
|
+
new Request('https://example.com/resource'),
|
|
237
|
+
)
|
|
238
|
+
expect(challengeResult.status).toBe(402)
|
|
239
|
+
if (challengeResult.status !== 402) throw new Error('expected activation challenge')
|
|
240
|
+
|
|
241
|
+
const challenge = Challenge.fromResponse(challengeResult.challenge)
|
|
242
|
+
const challengeRequest = challenge.request as ReturnType<
|
|
243
|
+
typeof Methods.subscription.schema.request.parse
|
|
244
|
+
>
|
|
245
|
+
const generatedAccessKey = challengeRequest.methodDetails?.accessKey
|
|
246
|
+
expect(generatedAccessKey?.keyType).toBe('secp256k1')
|
|
247
|
+
if (!generatedAccessKey) throw new Error('expected generated access key')
|
|
248
|
+
|
|
249
|
+
const credential = await createCredential(challenge, rootAccount.address, generatedAccessKey)
|
|
250
|
+
const activated = await mppx.tempo.subscription({})(
|
|
251
|
+
new Request('https://example.com/resource', {
|
|
252
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
253
|
+
}),
|
|
254
|
+
)
|
|
255
|
+
|
|
256
|
+
expect(activated.status).toBe(200)
|
|
257
|
+
if (activated.status !== 200) throw new Error('expected activation')
|
|
258
|
+
const receipt = Receipt.fromResponse(activated.withReceipt(new Response('OK')))
|
|
259
|
+
expect(receipt.reference).toBe(hashActivate)
|
|
260
|
+
expect(receipt.subscriptionId).toMatch(/^[A-Za-z0-9_-]+$/)
|
|
261
|
+
expect(rpcMethods.filter((method) => method === 'eth_sendRawTransaction')).toHaveLength(1)
|
|
262
|
+
|
|
263
|
+
const record = await subscriptions.getByKey(subscriptionKey)
|
|
264
|
+
expect(record?.accessKey).toEqual(generatedAccessKey)
|
|
265
|
+
expect(record?.keyAuthorization).toBe(credential.payload.signature)
|
|
266
|
+
expect(record?.payer?.address.toLowerCase()).toBe(rootAccount.address.toLowerCase())
|
|
267
|
+
expect(record?.lastChargedPeriod).toBe(0)
|
|
268
|
+
|
|
269
|
+
const reused = await mppx.tempo.subscription({})(new Request('https://example.com/resource'))
|
|
270
|
+
expect(reused.status).toBe(200)
|
|
271
|
+
expect(rpcMethods.filter((method) => method === 'eth_sendRawTransaction')).toHaveLength(1)
|
|
272
|
+
})
|
|
273
|
+
|
|
274
|
+
test('verifyCredential activates a subscription credential with a canonical challenge request', async () => {
|
|
275
|
+
const store = Store.memory()
|
|
276
|
+
const { client } = createBillingClient([hashActivate])
|
|
277
|
+
const method = subscription({
|
|
278
|
+
amount: subscriptionAmount,
|
|
279
|
+
chainId,
|
|
280
|
+
currency: subscriptionCurrency,
|
|
281
|
+
getClient: async () => client,
|
|
282
|
+
periodCount: subscriptionPeriodCount,
|
|
283
|
+
periodUnit: subscriptionPeriodUnit,
|
|
284
|
+
recipient: subscriptionRecipient,
|
|
285
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
286
|
+
store,
|
|
287
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
288
|
+
waitForConfirmation: false,
|
|
289
|
+
})
|
|
290
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
291
|
+
const challenge = await mppx.challenge.tempo.subscription({})
|
|
292
|
+
const generatedAccessKey = (
|
|
293
|
+
challenge.request as ReturnType<typeof Methods.subscription.schema.request.parse>
|
|
294
|
+
).methodDetails?.accessKey
|
|
295
|
+
if (!generatedAccessKey) throw new Error('expected generated access key')
|
|
296
|
+
const credential = await createCredential(challenge, rootAccount.address, generatedAccessKey)
|
|
297
|
+
|
|
298
|
+
const receipt = await mppx.verifyCredential(credential)
|
|
299
|
+
|
|
300
|
+
expect(receipt.status).toBe('success')
|
|
301
|
+
expect(receipt.reference).toBe(hashActivate)
|
|
302
|
+
})
|
|
303
|
+
|
|
304
|
+
test('automatically renews overdue subscriptions on the request path', async () => {
|
|
305
|
+
const store = Store.memory()
|
|
306
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
307
|
+
const { client, rpcMethods } = createBillingClient([hashActivate, hashRenewed])
|
|
308
|
+
const method = subscription({
|
|
309
|
+
amount: subscriptionAmount,
|
|
310
|
+
chainId,
|
|
311
|
+
currency: subscriptionCurrency,
|
|
312
|
+
getClient: async () => client,
|
|
313
|
+
periodCount: subscriptionPeriodCount,
|
|
314
|
+
periodUnit: subscriptionPeriodUnit,
|
|
315
|
+
recipient: subscriptionRecipient,
|
|
316
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
317
|
+
store,
|
|
318
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
319
|
+
waitForConfirmation: false,
|
|
320
|
+
})
|
|
321
|
+
|
|
322
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
323
|
+
const challengeResult = await mppx.tempo.subscription({})(
|
|
324
|
+
new Request('https://example.com/resource'),
|
|
325
|
+
)
|
|
326
|
+
if (challengeResult.status !== 402) throw new Error('expected activation challenge')
|
|
327
|
+
const challenge = Challenge.fromResponse(challengeResult.challenge)
|
|
328
|
+
const accessKey = (
|
|
329
|
+
challenge.request as ReturnType<typeof Methods.subscription.schema.request.parse>
|
|
330
|
+
).methodDetails?.accessKey
|
|
331
|
+
if (!accessKey) throw new Error('expected generated access key')
|
|
332
|
+
const credential = await createCredential(challenge, rootAccount.address, accessKey)
|
|
333
|
+
const activated = await mppx.tempo.subscription({})(
|
|
334
|
+
new Request('https://example.com/resource', {
|
|
335
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
336
|
+
}),
|
|
337
|
+
)
|
|
338
|
+
expect(activated.status).toBe(200)
|
|
339
|
+
|
|
340
|
+
const record = await subscriptions.getByKey(subscriptionKey)
|
|
341
|
+
if (!record) throw new Error('expected subscription record')
|
|
342
|
+
await subscriptions.put({
|
|
343
|
+
...record,
|
|
344
|
+
billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(),
|
|
345
|
+
keyAuthorization: undefined,
|
|
346
|
+
lastChargedPeriod: 0,
|
|
347
|
+
reference: hashStale,
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
const renewed = await mppx.tempo.subscription({})(new Request('https://example.com/resource'))
|
|
351
|
+
expect(renewed.status).toBe(200)
|
|
352
|
+
if (renewed.status !== 200) throw new Error('expected renewal')
|
|
353
|
+
|
|
354
|
+
const receipt = Receipt.fromResponse(renewed.withReceipt(new Response('OK')))
|
|
355
|
+
expect(receipt.reference).toBe(hashRenewed)
|
|
356
|
+
expect(rpcMethods.filter((method) => method === 'eth_sendRawTransaction')).toHaveLength(2)
|
|
357
|
+
expect((await subscriptions.get(record.subscriptionId))?.lastChargedPeriod).toBeGreaterThan(0)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
test('returns a management response while renewal is already in flight', async () => {
|
|
361
|
+
const store = Store.memory()
|
|
362
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
363
|
+
const method = subscription({
|
|
364
|
+
accessKey: async () => accessKey,
|
|
365
|
+
activate: async () => ({
|
|
366
|
+
receipt: createReceipt('unused'),
|
|
367
|
+
subscription: createRecord({ subscriptionId: 'unused' }),
|
|
368
|
+
}),
|
|
369
|
+
amount: subscriptionAmount,
|
|
370
|
+
chainId,
|
|
371
|
+
currency: subscriptionCurrency,
|
|
372
|
+
periodCount: subscriptionPeriodCount,
|
|
373
|
+
periodUnit: subscriptionPeriodUnit,
|
|
374
|
+
recipient: subscriptionRecipient,
|
|
375
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
376
|
+
renew: async () => {
|
|
377
|
+
throw new Error('renew should not run')
|
|
378
|
+
},
|
|
379
|
+
store,
|
|
380
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
381
|
+
})
|
|
382
|
+
await subscriptions.put(
|
|
383
|
+
createRecord({
|
|
384
|
+
billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(),
|
|
385
|
+
inFlightPeriod: 1,
|
|
386
|
+
inFlightStartedAt: new Date().toISOString(),
|
|
387
|
+
lastChargedPeriod: 0,
|
|
388
|
+
subscriptionId: 'sub_due',
|
|
389
|
+
}),
|
|
390
|
+
)
|
|
391
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
392
|
+
|
|
393
|
+
const result = await mppx.tempo.subscription({})(new Request('https://example.com/resource'))
|
|
394
|
+
|
|
395
|
+
expect(result.status).toBe(200)
|
|
396
|
+
if (result.status !== 200) throw new Error('expected management response')
|
|
397
|
+
const response = (result.withReceipt as () => Response)()
|
|
398
|
+
expect(response.status).toBe(409)
|
|
399
|
+
expect(response.headers.get('Retry-After')).toBe('1')
|
|
400
|
+
})
|
|
401
|
+
|
|
402
|
+
test('does not authorize an active subscription whose request binding differs', async () => {
|
|
403
|
+
const store = Store.memory()
|
|
404
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
405
|
+
await subscriptions.put(
|
|
406
|
+
createRecord({
|
|
407
|
+
accessKey,
|
|
408
|
+
amount: '1000000',
|
|
409
|
+
lookupKey: subscriptionKey,
|
|
410
|
+
subscriptionId: 'sub_basic',
|
|
411
|
+
}),
|
|
412
|
+
)
|
|
413
|
+
const method = subscription({
|
|
414
|
+
accessKey: async () => accessKey,
|
|
415
|
+
activate: async () => ({
|
|
416
|
+
receipt: createReceipt('sub_unused'),
|
|
417
|
+
subscription: createRecord({ subscriptionId: 'sub_unused' }),
|
|
418
|
+
}),
|
|
419
|
+
amount: subscriptionAmount,
|
|
420
|
+
chainId,
|
|
421
|
+
currency: subscriptionCurrency,
|
|
422
|
+
periodCount: subscriptionPeriodCount,
|
|
423
|
+
periodUnit: subscriptionPeriodUnit,
|
|
424
|
+
recipient: subscriptionRecipient,
|
|
425
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
426
|
+
store,
|
|
427
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
428
|
+
})
|
|
429
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
430
|
+
|
|
431
|
+
const result = await mppx.tempo.subscription({})(new Request('https://example.com/resource'))
|
|
432
|
+
|
|
433
|
+
expect(result.status).toBe(402)
|
|
434
|
+
})
|
|
435
|
+
|
|
436
|
+
test('requires an access key before issuing a subscription challenge', async () => {
|
|
437
|
+
const method = subscription({
|
|
438
|
+
activate: async () => ({
|
|
439
|
+
receipt: createReceipt('unused'),
|
|
440
|
+
subscription: createRecord({ subscriptionId: 'unused' }),
|
|
441
|
+
}),
|
|
442
|
+
amount: subscriptionAmount,
|
|
443
|
+
chainId,
|
|
444
|
+
currency: subscriptionCurrency,
|
|
445
|
+
periodCount: subscriptionPeriodCount,
|
|
446
|
+
periodUnit: subscriptionPeriodUnit,
|
|
447
|
+
recipient: subscriptionRecipient,
|
|
448
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
449
|
+
store: Store.memory(),
|
|
450
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
451
|
+
})
|
|
452
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
453
|
+
|
|
454
|
+
const result = await mppx.tempo.subscription({})(new Request('https://example.com/resource'))
|
|
455
|
+
expect(result.status).toBe(402)
|
|
456
|
+
if (result.status !== 402) throw new Error('expected challenge')
|
|
457
|
+
const body = (await result.challenge.json()) as { detail?: string }
|
|
458
|
+
expect(body.detail).toBe('Payment verification failed: subscription accessKey is missing.')
|
|
459
|
+
})
|
|
460
|
+
|
|
461
|
+
test('defaults omitted subscription chainId to Tempo testnet', async () => {
|
|
462
|
+
const method = subscription({
|
|
463
|
+
accessKey: async () => accessKey,
|
|
464
|
+
activate: async () => ({
|
|
465
|
+
receipt: createReceipt('unused'),
|
|
466
|
+
subscription: createRecord({ subscriptionId: 'unused' }),
|
|
467
|
+
}),
|
|
468
|
+
amount: subscriptionAmount,
|
|
469
|
+
currency: subscriptionCurrency,
|
|
470
|
+
periodCount: subscriptionPeriodCount,
|
|
471
|
+
periodUnit: subscriptionPeriodUnit,
|
|
472
|
+
recipient: subscriptionRecipient,
|
|
473
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
474
|
+
store: Store.memory(),
|
|
475
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
476
|
+
})
|
|
477
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
478
|
+
const challengeResult = await mppx.tempo.subscription({})(
|
|
479
|
+
new Request('https://example.com/resource'),
|
|
480
|
+
)
|
|
481
|
+
if (challengeResult.status !== 402) throw new Error('expected activation challenge')
|
|
482
|
+
|
|
483
|
+
const challenge = Challenge.fromResponse(challengeResult.challenge)
|
|
484
|
+
const challengeRequest = challenge.request as ReturnType<
|
|
485
|
+
typeof Methods.subscription.schema.request.parse
|
|
486
|
+
>
|
|
487
|
+
expect(challengeRequest.methodDetails?.chainId).toBe(subscriptionDefaultChainId)
|
|
488
|
+
})
|
|
489
|
+
|
|
490
|
+
test('reuses a stored active subscription access key without a resolver callback', async () => {
|
|
491
|
+
const store = Store.memory()
|
|
492
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
493
|
+
await subscriptions.put(createRecord({ accessKey, lookupKey: subscriptionKey }))
|
|
494
|
+
const method = subscription({
|
|
495
|
+
activate: async () => ({
|
|
496
|
+
receipt: createReceipt('unused'),
|
|
497
|
+
subscription: createRecord({ subscriptionId: 'unused' }),
|
|
498
|
+
}),
|
|
499
|
+
amount: subscriptionAmount,
|
|
500
|
+
chainId,
|
|
501
|
+
currency: subscriptionCurrency,
|
|
502
|
+
periodCount: subscriptionPeriodCount,
|
|
503
|
+
periodUnit: subscriptionPeriodUnit,
|
|
504
|
+
recipient: subscriptionRecipient,
|
|
505
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
506
|
+
store,
|
|
507
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
508
|
+
})
|
|
509
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
510
|
+
|
|
511
|
+
const reused = await mppx.tempo.subscription({})(new Request('https://example.com/resource'))
|
|
512
|
+
|
|
513
|
+
expect(reused.status).toBe(200)
|
|
514
|
+
})
|
|
515
|
+
|
|
516
|
+
test('serializes concurrent fresh activations for the same lookup key', async () => {
|
|
517
|
+
const store = Store.memory()
|
|
518
|
+
let activationCount = 0
|
|
519
|
+
let releaseActivation!: () => void
|
|
520
|
+
let markActivationStarted!: () => void
|
|
521
|
+
const activationStarted = new Promise<void>((resolve) => {
|
|
522
|
+
markActivationStarted = resolve
|
|
523
|
+
})
|
|
524
|
+
const activationReleased = new Promise<void>((resolve) => {
|
|
525
|
+
releaseActivation = resolve
|
|
526
|
+
})
|
|
527
|
+
const method = subscription({
|
|
528
|
+
accessKey: async () => accessKey,
|
|
529
|
+
activate: async ({ request, resolved }) => {
|
|
530
|
+
activationCount += 1
|
|
531
|
+
markActivationStarted()
|
|
532
|
+
await activationReleased
|
|
533
|
+
return {
|
|
534
|
+
receipt: createReceipt('sub_123', hashActivate),
|
|
535
|
+
subscription: createRecord({
|
|
536
|
+
amount: request.amount,
|
|
537
|
+
chainId: request.methodDetails?.chainId,
|
|
538
|
+
currency: request.currency,
|
|
539
|
+
lookupKey: resolved.key,
|
|
540
|
+
periodCount: request.periodCount,
|
|
541
|
+
periodUnit: request.periodUnit,
|
|
542
|
+
recipient: request.recipient,
|
|
543
|
+
reference: hashActivate,
|
|
544
|
+
subscriptionExpires: request.subscriptionExpires,
|
|
545
|
+
}),
|
|
546
|
+
}
|
|
547
|
+
},
|
|
548
|
+
amount: subscriptionAmount,
|
|
549
|
+
chainId,
|
|
550
|
+
currency: subscriptionCurrency,
|
|
551
|
+
periodCount: subscriptionPeriodCount,
|
|
552
|
+
periodUnit: subscriptionPeriodUnit,
|
|
553
|
+
recipient: subscriptionRecipient,
|
|
554
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
555
|
+
store,
|
|
556
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
557
|
+
})
|
|
558
|
+
|
|
559
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
560
|
+
const firstChallengeResult = await mppx.tempo.subscription({
|
|
561
|
+
expires: '2027-01-01T00:01:00.000Z',
|
|
562
|
+
})(new Request('https://example.com/resource'))
|
|
563
|
+
const secondChallengeResult = await mppx.tempo.subscription({
|
|
564
|
+
expires: '2027-01-01T00:02:00.000Z',
|
|
565
|
+
})(new Request('https://example.com/resource'))
|
|
566
|
+
if (firstChallengeResult.status !== 402 || secondChallengeResult.status !== 402) {
|
|
567
|
+
throw new Error('expected activation challenges')
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
const firstChallenge = Challenge.fromResponse(firstChallengeResult.challenge)
|
|
571
|
+
const secondChallenge = Challenge.fromResponse(secondChallengeResult.challenge)
|
|
572
|
+
expect(firstChallenge.id).not.toBe(secondChallenge.id)
|
|
573
|
+
|
|
574
|
+
const firstCredential = await createCredential(firstChallenge)
|
|
575
|
+
const secondCredential = await createCredential(secondChallenge)
|
|
576
|
+
const firstActivation = mppx.tempo.subscription({})(
|
|
577
|
+
new Request('https://example.com/resource', {
|
|
578
|
+
headers: { Authorization: Credential.serialize(firstCredential) },
|
|
579
|
+
}),
|
|
580
|
+
)
|
|
581
|
+
await activationStarted
|
|
582
|
+
|
|
583
|
+
const secondActivation = await mppx.tempo.subscription({})(
|
|
584
|
+
new Request('https://example.com/resource', {
|
|
585
|
+
headers: { Authorization: Credential.serialize(secondCredential) },
|
|
586
|
+
}),
|
|
587
|
+
)
|
|
588
|
+
releaseActivation()
|
|
589
|
+
const activated = await firstActivation
|
|
590
|
+
|
|
591
|
+
expect(activated.status).toBe(200)
|
|
592
|
+
expect(secondActivation.status).toBe(402)
|
|
593
|
+
expect(activationCount).toBe(1)
|
|
594
|
+
})
|
|
595
|
+
|
|
596
|
+
test('allows retry after a stale failed activation attempt', async () => {
|
|
597
|
+
const store = Store.memory()
|
|
598
|
+
let activationCount = 0
|
|
599
|
+
const method = subscription({
|
|
600
|
+
accessKey: async () => accessKey,
|
|
601
|
+
activationTimeoutMs: 0,
|
|
602
|
+
activate: async ({ request, resolved }) => {
|
|
603
|
+
activationCount += 1
|
|
604
|
+
if (activationCount === 1) throw new Error('activation failed before charge')
|
|
605
|
+
return {
|
|
606
|
+
receipt: createReceipt('sub_123', hashActivate),
|
|
607
|
+
subscription: createRecord({
|
|
608
|
+
amount: request.amount,
|
|
609
|
+
chainId: request.methodDetails?.chainId,
|
|
610
|
+
currency: request.currency,
|
|
611
|
+
lookupKey: resolved.key,
|
|
612
|
+
periodCount: request.periodCount,
|
|
613
|
+
periodUnit: request.periodUnit,
|
|
614
|
+
recipient: request.recipient,
|
|
615
|
+
reference: hashActivate,
|
|
616
|
+
subscriptionExpires: request.subscriptionExpires,
|
|
617
|
+
}),
|
|
618
|
+
}
|
|
619
|
+
},
|
|
620
|
+
amount: subscriptionAmount,
|
|
621
|
+
chainId,
|
|
622
|
+
currency: subscriptionCurrency,
|
|
623
|
+
periodCount: subscriptionPeriodCount,
|
|
624
|
+
periodUnit: subscriptionPeriodUnit,
|
|
625
|
+
recipient: subscriptionRecipient,
|
|
626
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
627
|
+
store,
|
|
628
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
629
|
+
})
|
|
630
|
+
|
|
631
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
632
|
+
const firstChallengeResult = await mppx.tempo.subscription({
|
|
633
|
+
expires: '2027-01-01T00:03:00.000Z',
|
|
634
|
+
})(new Request('https://example.com/resource'))
|
|
635
|
+
const secondChallengeResult = await mppx.tempo.subscription({
|
|
636
|
+
expires: '2027-01-01T00:04:00.000Z',
|
|
637
|
+
})(new Request('https://example.com/resource'))
|
|
638
|
+
if (firstChallengeResult.status !== 402 || secondChallengeResult.status !== 402) {
|
|
639
|
+
throw new Error('expected activation challenges')
|
|
640
|
+
}
|
|
641
|
+
|
|
642
|
+
const firstCredential = await createCredential(
|
|
643
|
+
Challenge.fromResponse(firstChallengeResult.challenge),
|
|
644
|
+
)
|
|
645
|
+
const firstRejected = await mppx.tempo.subscription({})(
|
|
646
|
+
new Request('https://example.com/resource', {
|
|
647
|
+
headers: { Authorization: Credential.serialize(firstCredential) },
|
|
648
|
+
}),
|
|
649
|
+
)
|
|
650
|
+
expect(firstRejected.status).toBe(402)
|
|
651
|
+
|
|
652
|
+
const secondCredential = await createCredential(
|
|
653
|
+
Challenge.fromResponse(secondChallengeResult.challenge),
|
|
654
|
+
)
|
|
655
|
+
const retried = await mppx.tempo.subscription({})(
|
|
656
|
+
new Request('https://example.com/resource', {
|
|
657
|
+
headers: { Authorization: Credential.serialize(secondCredential) },
|
|
658
|
+
}),
|
|
659
|
+
)
|
|
660
|
+
|
|
661
|
+
expect(retried.status).toBe(200)
|
|
662
|
+
expect(activationCount).toBe(2)
|
|
663
|
+
})
|
|
664
|
+
|
|
665
|
+
test('new activation replaces the previous subscription for the same lookup key', async () => {
|
|
666
|
+
const store = Store.memory()
|
|
667
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
668
|
+
|
|
669
|
+
// Seed an expired subscription so authorize() falls through to a new challenge.
|
|
670
|
+
const expiredDate = new Date(Date.now() - 1_000).toISOString()
|
|
671
|
+
await subscriptions.put(
|
|
672
|
+
createRecord({
|
|
673
|
+
lookupKey: subscriptionKey,
|
|
674
|
+
subscriptionId: 'sub_old',
|
|
675
|
+
reference: hashOld,
|
|
676
|
+
subscriptionExpires: expiredDate,
|
|
677
|
+
}),
|
|
678
|
+
)
|
|
679
|
+
|
|
680
|
+
const method = subscription({
|
|
681
|
+
accessKey: async () => accessKey,
|
|
682
|
+
activate: async ({ request, resolved }) => ({
|
|
683
|
+
receipt: createReceipt('sub_new', hashActivate),
|
|
684
|
+
subscription: createRecord({
|
|
685
|
+
amount: request.amount,
|
|
686
|
+
chainId: request.methodDetails?.chainId,
|
|
687
|
+
currency: request.currency,
|
|
688
|
+
lookupKey: resolved.key,
|
|
689
|
+
periodCount: request.periodCount,
|
|
690
|
+
periodUnit: request.periodUnit,
|
|
691
|
+
recipient: request.recipient,
|
|
692
|
+
reference: hashActivate,
|
|
693
|
+
subscriptionExpires: request.subscriptionExpires,
|
|
694
|
+
subscriptionId: 'sub_new',
|
|
695
|
+
}),
|
|
696
|
+
}),
|
|
697
|
+
amount: subscriptionAmount,
|
|
698
|
+
chainId,
|
|
699
|
+
currency: subscriptionCurrency,
|
|
700
|
+
periodCount: subscriptionPeriodCount,
|
|
701
|
+
periodUnit: subscriptionPeriodUnit,
|
|
702
|
+
recipient: subscriptionRecipient,
|
|
703
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
704
|
+
store,
|
|
705
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
706
|
+
})
|
|
707
|
+
|
|
708
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
709
|
+
|
|
710
|
+
const challengeResult = await mppx.tempo.subscription({})(
|
|
711
|
+
new Request('https://example.com/resource'),
|
|
712
|
+
)
|
|
713
|
+
expect(challengeResult.status).toBe(402)
|
|
714
|
+
if (challengeResult.status !== 402) throw new Error('expected challenge')
|
|
715
|
+
|
|
716
|
+
const challenge = Challenge.fromResponse(challengeResult.challenge)
|
|
717
|
+
const credential = await createCredential(challenge)
|
|
718
|
+
|
|
719
|
+
const activated = await mppx.tempo.subscription({})(
|
|
720
|
+
new Request('https://example.com/resource', {
|
|
721
|
+
headers: {
|
|
722
|
+
Authorization: Credential.serialize(credential),
|
|
723
|
+
'X-Subscription-Key': subscriptionKey,
|
|
724
|
+
},
|
|
725
|
+
}),
|
|
726
|
+
)
|
|
727
|
+
expect(activated.status).toBe(200)
|
|
728
|
+
if (activated.status !== 200) throw new Error('expected activation')
|
|
729
|
+
|
|
730
|
+
const receipt = Receipt.fromResponse(activated.withReceipt(new Response('OK')))
|
|
731
|
+
expect(receipt.subscriptionId).toBe('sub_new')
|
|
732
|
+
|
|
733
|
+
const current = await subscriptions.getByKey(subscriptionKey)
|
|
734
|
+
expect(current?.subscriptionId).toBe('sub_new')
|
|
735
|
+
})
|
|
736
|
+
|
|
737
|
+
test('does not reuse an active subscription whose request binding differs during verify', async () => {
|
|
738
|
+
const store = Store.memory()
|
|
739
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
740
|
+
let activationCount = 0
|
|
741
|
+
await subscriptions.put(
|
|
742
|
+
createRecord({
|
|
743
|
+
accessKey,
|
|
744
|
+
amount: '1000000',
|
|
745
|
+
lookupKey: subscriptionKey,
|
|
746
|
+
subscriptionId: 'sub_basic',
|
|
747
|
+
}),
|
|
748
|
+
)
|
|
749
|
+
const method = subscription({
|
|
750
|
+
accessKey: async () => accessKey,
|
|
751
|
+
activate: async ({ request, resolved }) => {
|
|
752
|
+
activationCount += 1
|
|
753
|
+
return {
|
|
754
|
+
receipt: createReceipt('sub_premium'),
|
|
755
|
+
subscription: createRecord({
|
|
756
|
+
amount: request.amount,
|
|
757
|
+
chainId: request.methodDetails?.chainId,
|
|
758
|
+
currency: request.currency,
|
|
759
|
+
lookupKey: resolved.key,
|
|
760
|
+
periodCount: request.periodCount,
|
|
761
|
+
periodUnit: request.periodUnit,
|
|
762
|
+
recipient: request.recipient,
|
|
763
|
+
subscriptionExpires: request.subscriptionExpires,
|
|
764
|
+
subscriptionId: 'sub_premium',
|
|
765
|
+
}),
|
|
766
|
+
}
|
|
767
|
+
},
|
|
768
|
+
amount: subscriptionAmount,
|
|
769
|
+
chainId,
|
|
770
|
+
currency: subscriptionCurrency,
|
|
771
|
+
periodCount: subscriptionPeriodCount,
|
|
772
|
+
periodUnit: subscriptionPeriodUnit,
|
|
773
|
+
recipient: subscriptionRecipient,
|
|
774
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
775
|
+
store,
|
|
776
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
777
|
+
})
|
|
778
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
779
|
+
const challengeResult = await mppx.tempo.subscription({})(
|
|
780
|
+
new Request('https://example.com/resource'),
|
|
781
|
+
)
|
|
782
|
+
if (challengeResult.status !== 402) throw new Error('expected activation challenge')
|
|
783
|
+
|
|
784
|
+
const credential = await createCredential(Challenge.fromResponse(challengeResult.challenge))
|
|
785
|
+
const activated = await mppx.tempo.subscription({})(
|
|
786
|
+
new Request('https://example.com/resource', {
|
|
787
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
788
|
+
}),
|
|
789
|
+
)
|
|
790
|
+
|
|
791
|
+
expect(activated.status).toBe(200)
|
|
792
|
+
expect(activationCount).toBe(1)
|
|
793
|
+
if (activated.status !== 200) throw new Error('expected activation')
|
|
794
|
+
const receipt = Receipt.fromResponse(activated.withReceipt(new Response('OK')))
|
|
795
|
+
expect(receipt.subscriptionId).toBe('sub_premium')
|
|
796
|
+
})
|
|
797
|
+
|
|
798
|
+
test('does not reuse an overdue subscription during verify', async () => {
|
|
799
|
+
const store = Store.memory()
|
|
800
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
801
|
+
let activationCount = 0
|
|
802
|
+
await subscriptions.put(
|
|
803
|
+
createRecord({
|
|
804
|
+
accessKey,
|
|
805
|
+
billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(),
|
|
806
|
+
lastChargedPeriod: 0,
|
|
807
|
+
lookupKey: subscriptionKey,
|
|
808
|
+
reference: hashStale,
|
|
809
|
+
subscriptionId: 'sub_due',
|
|
810
|
+
}),
|
|
811
|
+
)
|
|
812
|
+
const method = subscription({
|
|
813
|
+
accessKey: async () => accessKey,
|
|
814
|
+
activate: async () => {
|
|
815
|
+
activationCount += 1
|
|
816
|
+
throw new Error('overdue subscription must renew')
|
|
817
|
+
},
|
|
818
|
+
amount: subscriptionAmount,
|
|
819
|
+
chainId,
|
|
820
|
+
currency: subscriptionCurrency,
|
|
821
|
+
periodCount: subscriptionPeriodCount,
|
|
822
|
+
periodUnit: subscriptionPeriodUnit,
|
|
823
|
+
recipient: subscriptionRecipient,
|
|
824
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
825
|
+
store,
|
|
826
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
827
|
+
})
|
|
828
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
829
|
+
const challengeResult = await mppx.tempo.subscription({})(
|
|
830
|
+
new Request('https://example.com/resource'),
|
|
831
|
+
)
|
|
832
|
+
if (challengeResult.status !== 402) throw new Error('expected activation challenge')
|
|
833
|
+
|
|
834
|
+
const credential = await createCredential(Challenge.fromResponse(challengeResult.challenge))
|
|
835
|
+
const rejected = await mppx.tempo.subscription({})(
|
|
836
|
+
new Request('https://example.com/resource', {
|
|
837
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
838
|
+
}),
|
|
839
|
+
)
|
|
840
|
+
|
|
841
|
+
expect(rejected.status).toBe(402)
|
|
842
|
+
expect(activationCount).toBe(1)
|
|
843
|
+
})
|
|
844
|
+
|
|
845
|
+
test('rejects activation when the dynamic access key does not match the credential', async () => {
|
|
846
|
+
const store = Store.memory()
|
|
847
|
+
const activateCalls: unknown[] = []
|
|
848
|
+
const method = subscription({
|
|
849
|
+
accessKey: async () => ({
|
|
850
|
+
accessKeyAddress: accessAccount.address,
|
|
851
|
+
keyType: 'p256',
|
|
852
|
+
}),
|
|
853
|
+
activate: async (parameters) => {
|
|
854
|
+
activateCalls.push(parameters)
|
|
855
|
+
return {
|
|
856
|
+
receipt: createReceipt('sub_unused'),
|
|
857
|
+
subscription: createRecord({ subscriptionId: 'sub_unused' }),
|
|
858
|
+
}
|
|
859
|
+
},
|
|
860
|
+
amount: subscriptionAmount,
|
|
861
|
+
chainId,
|
|
862
|
+
currency: subscriptionCurrency,
|
|
863
|
+
periodCount: subscriptionPeriodCount,
|
|
864
|
+
periodUnit: subscriptionPeriodUnit,
|
|
865
|
+
recipient: subscriptionRecipient,
|
|
866
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
867
|
+
store,
|
|
868
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
869
|
+
})
|
|
870
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
871
|
+
const challengeResult = await mppx.tempo.subscription({})(
|
|
872
|
+
new Request('https://example.com/resource'),
|
|
873
|
+
)
|
|
874
|
+
if (challengeResult.status !== 402) throw new Error('expected activation challenge')
|
|
875
|
+
|
|
876
|
+
const challenge = Challenge.fromResponse(challengeResult.challenge)
|
|
877
|
+
const credential = await createCredential(challenge)
|
|
878
|
+
const rejected = await mppx.tempo.subscription({})(
|
|
879
|
+
new Request('https://example.com/resource', {
|
|
880
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
881
|
+
}),
|
|
882
|
+
)
|
|
883
|
+
|
|
884
|
+
expect(rejected.status).toBe(402)
|
|
885
|
+
expect(activateCalls.length).toBe(0)
|
|
886
|
+
})
|
|
887
|
+
|
|
888
|
+
test('rejects activation settlements that do not match the challenged request', async () => {
|
|
889
|
+
const store = Store.memory()
|
|
890
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
891
|
+
const method = subscription({
|
|
892
|
+
accessKey: async () => accessKey,
|
|
893
|
+
activate: async ({ request, resolved }) => ({
|
|
894
|
+
receipt: createReceipt('sub_bad', hashActivate),
|
|
895
|
+
subscription: createRecord({
|
|
896
|
+
amount: String(BigInt(request.amount) + 1n),
|
|
897
|
+
chainId: request.methodDetails?.chainId,
|
|
898
|
+
currency: request.currency,
|
|
899
|
+
lookupKey: resolved.key,
|
|
900
|
+
periodCount: request.periodCount,
|
|
901
|
+
periodUnit: request.periodUnit,
|
|
902
|
+
recipient: request.recipient,
|
|
903
|
+
reference: hashActivate,
|
|
904
|
+
subscriptionExpires: request.subscriptionExpires,
|
|
905
|
+
subscriptionId: 'sub_bad',
|
|
906
|
+
}),
|
|
907
|
+
}),
|
|
908
|
+
amount: subscriptionAmount,
|
|
909
|
+
chainId,
|
|
910
|
+
currency: subscriptionCurrency,
|
|
911
|
+
periodCount: subscriptionPeriodCount,
|
|
912
|
+
periodUnit: subscriptionPeriodUnit,
|
|
913
|
+
recipient: subscriptionRecipient,
|
|
914
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
915
|
+
store,
|
|
916
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
917
|
+
})
|
|
918
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
919
|
+
const challengeResult = await mppx.tempo.subscription({})(
|
|
920
|
+
new Request('https://example.com/resource'),
|
|
921
|
+
)
|
|
922
|
+
if (challengeResult.status !== 402) throw new Error('expected activation challenge')
|
|
923
|
+
|
|
924
|
+
const challenge = Challenge.fromResponse(challengeResult.challenge)
|
|
925
|
+
const credential = await createCredential(challenge)
|
|
926
|
+
const rejected = await mppx.tempo.subscription({})(
|
|
927
|
+
new Request('https://example.com/resource', {
|
|
928
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
929
|
+
}),
|
|
930
|
+
)
|
|
931
|
+
|
|
932
|
+
expect(rejected.status).toBe(402)
|
|
933
|
+
expect(await subscriptions.getByKey(subscriptionKey)).toBe(null)
|
|
934
|
+
})
|
|
935
|
+
|
|
936
|
+
test('rejects activation settlements with a mismatched chainId', async () => {
|
|
937
|
+
const store = Store.memory()
|
|
938
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
939
|
+
const method = subscription({
|
|
940
|
+
accessKey: async () => accessKey,
|
|
941
|
+
activate: async ({ request, resolved }) => ({
|
|
942
|
+
receipt: createReceipt('sub_bad', hashActivate),
|
|
943
|
+
subscription: createRecord({
|
|
944
|
+
amount: request.amount,
|
|
945
|
+
chainId: chainId + 1,
|
|
946
|
+
currency: request.currency,
|
|
947
|
+
lookupKey: resolved.key,
|
|
948
|
+
periodCount: request.periodCount,
|
|
949
|
+
periodUnit: request.periodUnit,
|
|
950
|
+
recipient: request.recipient,
|
|
951
|
+
reference: hashActivate,
|
|
952
|
+
subscriptionExpires: request.subscriptionExpires,
|
|
953
|
+
subscriptionId: 'sub_bad',
|
|
954
|
+
}),
|
|
955
|
+
}),
|
|
956
|
+
amount: subscriptionAmount,
|
|
957
|
+
chainId,
|
|
958
|
+
currency: subscriptionCurrency,
|
|
959
|
+
periodCount: subscriptionPeriodCount,
|
|
960
|
+
periodUnit: subscriptionPeriodUnit,
|
|
961
|
+
recipient: subscriptionRecipient,
|
|
962
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
963
|
+
store,
|
|
964
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
965
|
+
})
|
|
966
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
967
|
+
const challengeResult = await mppx.tempo.subscription({})(
|
|
968
|
+
new Request('https://example.com/resource'),
|
|
969
|
+
)
|
|
970
|
+
if (challengeResult.status !== 402) throw new Error('expected activation challenge')
|
|
971
|
+
|
|
972
|
+
const challenge = Challenge.fromResponse(challengeResult.challenge)
|
|
973
|
+
const credential = await createCredential(challenge)
|
|
974
|
+
const rejected = await mppx.tempo.subscription({})(
|
|
975
|
+
new Request('https://example.com/resource', {
|
|
976
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
977
|
+
}),
|
|
978
|
+
)
|
|
979
|
+
|
|
980
|
+
expect(rejected.status).toBe(402)
|
|
981
|
+
expect(await subscriptions.getByKey(subscriptionKey)).toBe(null)
|
|
982
|
+
})
|
|
983
|
+
|
|
984
|
+
test('rejects activation settlements with a mismatched externalId', async () => {
|
|
985
|
+
const store = Store.memory()
|
|
986
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
987
|
+
const method = subscription({
|
|
988
|
+
accessKey: async () => accessKey,
|
|
989
|
+
activate: async ({ request, resolved }) => ({
|
|
990
|
+
receipt: createReceipt('sub_bad', hashActivate),
|
|
991
|
+
subscription: createRecord({
|
|
992
|
+
amount: request.amount,
|
|
993
|
+
chainId: request.methodDetails?.chainId,
|
|
994
|
+
currency: request.currency,
|
|
995
|
+
externalId: 'external_2',
|
|
996
|
+
lookupKey: resolved.key,
|
|
997
|
+
periodCount: request.periodCount,
|
|
998
|
+
periodUnit: request.periodUnit,
|
|
999
|
+
recipient: request.recipient,
|
|
1000
|
+
reference: hashActivate,
|
|
1001
|
+
subscriptionExpires: request.subscriptionExpires,
|
|
1002
|
+
subscriptionId: 'sub_bad',
|
|
1003
|
+
}),
|
|
1004
|
+
}),
|
|
1005
|
+
amount: subscriptionAmount,
|
|
1006
|
+
chainId,
|
|
1007
|
+
currency: subscriptionCurrency,
|
|
1008
|
+
externalId: 'external_1',
|
|
1009
|
+
periodCount: subscriptionPeriodCount,
|
|
1010
|
+
periodUnit: subscriptionPeriodUnit,
|
|
1011
|
+
recipient: subscriptionRecipient,
|
|
1012
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
1013
|
+
store,
|
|
1014
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
1015
|
+
})
|
|
1016
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
1017
|
+
const challengeResult = await mppx.tempo.subscription({})(
|
|
1018
|
+
new Request('https://example.com/resource'),
|
|
1019
|
+
)
|
|
1020
|
+
if (challengeResult.status !== 402) throw new Error('expected activation challenge')
|
|
1021
|
+
|
|
1022
|
+
const challenge = Challenge.fromResponse(challengeResult.challenge)
|
|
1023
|
+
const credential = await createCredential(challenge)
|
|
1024
|
+
const rejected = await mppx.tempo.subscription({})(
|
|
1025
|
+
new Request('https://example.com/resource', {
|
|
1026
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1027
|
+
}),
|
|
1028
|
+
)
|
|
1029
|
+
|
|
1030
|
+
expect(rejected.status).toBe(402)
|
|
1031
|
+
expect(await subscriptions.getByKey(subscriptionKey)).toBe(null)
|
|
1032
|
+
})
|
|
1033
|
+
|
|
1034
|
+
test('rejects credentials when the current request externalId differs from the challenge', async () => {
|
|
1035
|
+
const store = Store.memory()
|
|
1036
|
+
let activationCount = 0
|
|
1037
|
+
const method = subscription({
|
|
1038
|
+
accessKey: async () => accessKey,
|
|
1039
|
+
activate: async ({ request, resolved }) => {
|
|
1040
|
+
activationCount += 1
|
|
1041
|
+
return {
|
|
1042
|
+
receipt: createReceipt('sub_unused'),
|
|
1043
|
+
subscription: createRecord({
|
|
1044
|
+
amount: request.amount,
|
|
1045
|
+
chainId: request.methodDetails?.chainId,
|
|
1046
|
+
currency: request.currency,
|
|
1047
|
+
externalId: request.externalId,
|
|
1048
|
+
lookupKey: resolved.key,
|
|
1049
|
+
periodCount: request.periodCount,
|
|
1050
|
+
periodUnit: request.periodUnit,
|
|
1051
|
+
recipient: request.recipient,
|
|
1052
|
+
subscriptionExpires: request.subscriptionExpires,
|
|
1053
|
+
subscriptionId: 'sub_unused',
|
|
1054
|
+
}),
|
|
1055
|
+
}
|
|
1056
|
+
},
|
|
1057
|
+
amount: subscriptionAmount,
|
|
1058
|
+
chainId,
|
|
1059
|
+
currency: subscriptionCurrency,
|
|
1060
|
+
periodCount: subscriptionPeriodCount,
|
|
1061
|
+
periodUnit: subscriptionPeriodUnit,
|
|
1062
|
+
recipient: subscriptionRecipient,
|
|
1063
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
1064
|
+
store,
|
|
1065
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
1066
|
+
})
|
|
1067
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
1068
|
+
const challengeResult = await mppx.tempo.subscription({ externalId: 'external_1' })(
|
|
1069
|
+
new Request('https://example.com/resource'),
|
|
1070
|
+
)
|
|
1071
|
+
if (challengeResult.status !== 402) throw new Error('expected activation challenge')
|
|
1072
|
+
|
|
1073
|
+
const credential = await createCredential(Challenge.fromResponse(challengeResult.challenge))
|
|
1074
|
+
const rejected = await mppx.tempo.subscription({ externalId: 'external_2' })(
|
|
1075
|
+
new Request('https://example.com/resource', {
|
|
1076
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1077
|
+
}),
|
|
1078
|
+
)
|
|
1079
|
+
|
|
1080
|
+
expect(rejected.status).toBe(402)
|
|
1081
|
+
expect(activationCount).toBe(0)
|
|
1082
|
+
})
|
|
1083
|
+
|
|
1084
|
+
test('rejects credentials whose declared source does not match the key authorization signer', async () => {
|
|
1085
|
+
const store = Store.memory()
|
|
1086
|
+
const activateCalls: unknown[] = []
|
|
1087
|
+
const method = subscription({
|
|
1088
|
+
accessKey: async () => accessKey,
|
|
1089
|
+
activate: async (parameters) => {
|
|
1090
|
+
activateCalls.push(parameters)
|
|
1091
|
+
return {
|
|
1092
|
+
receipt: createReceipt('sub_unused'),
|
|
1093
|
+
subscription: createRecord({ subscriptionId: 'sub_unused' }),
|
|
1094
|
+
}
|
|
1095
|
+
},
|
|
1096
|
+
amount: subscriptionAmount,
|
|
1097
|
+
chainId,
|
|
1098
|
+
currency: subscriptionCurrency,
|
|
1099
|
+
periodCount: subscriptionPeriodCount,
|
|
1100
|
+
periodUnit: subscriptionPeriodUnit,
|
|
1101
|
+
recipient: subscriptionRecipient,
|
|
1102
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
1103
|
+
store,
|
|
1104
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
1105
|
+
})
|
|
1106
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
1107
|
+
const challengeResult = await mppx.tempo.subscription({})(
|
|
1108
|
+
new Request('https://example.com/resource'),
|
|
1109
|
+
)
|
|
1110
|
+
if (challengeResult.status !== 402) throw new Error('expected activation challenge')
|
|
1111
|
+
|
|
1112
|
+
const challenge = Challenge.fromResponse(challengeResult.challenge)
|
|
1113
|
+
const credential = await createCredential(challenge, otherAccessAccount.address)
|
|
1114
|
+
const rejected = await mppx.tempo.subscription({})(
|
|
1115
|
+
new Request('https://example.com/resource', {
|
|
1116
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1117
|
+
}),
|
|
1118
|
+
)
|
|
1119
|
+
|
|
1120
|
+
expect(rejected.status).toBe(402)
|
|
1121
|
+
expect(activateCalls.length).toBe(0)
|
|
1122
|
+
})
|
|
1123
|
+
|
|
1124
|
+
test('renews an overdue matching subscription before falling back to 402', async () => {
|
|
1125
|
+
const store = Store.memory()
|
|
1126
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
1127
|
+
const renewCalls: number[] = []
|
|
1128
|
+
const renewalReferences: string[] = []
|
|
1129
|
+
const method = subscription({
|
|
1130
|
+
accessKey: async () => accessKey,
|
|
1131
|
+
activate: async () => ({
|
|
1132
|
+
receipt: createReceipt('unused'),
|
|
1133
|
+
subscription: createRecord({ subscriptionId: 'unused' }),
|
|
1134
|
+
}),
|
|
1135
|
+
amount: subscriptionAmount,
|
|
1136
|
+
chainId,
|
|
1137
|
+
currency: subscriptionCurrency,
|
|
1138
|
+
periodCount: subscriptionPeriodCount,
|
|
1139
|
+
periodUnit: subscriptionPeriodUnit,
|
|
1140
|
+
recipient: subscriptionRecipient,
|
|
1141
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
1142
|
+
renew: async ({ inFlightReference, periodIndex, subscription }) => {
|
|
1143
|
+
renewCalls.push(periodIndex)
|
|
1144
|
+
renewalReferences.push(inFlightReference)
|
|
1145
|
+
expect(subscription.inFlightReference).toBe(inFlightReference)
|
|
1146
|
+
return {
|
|
1147
|
+
receipt: createReceipt(subscription.subscriptionId, hashRenewed),
|
|
1148
|
+
subscription: {
|
|
1149
|
+
...subscription,
|
|
1150
|
+
lastChargedPeriod: periodIndex,
|
|
1151
|
+
reference: hashRenewed,
|
|
1152
|
+
},
|
|
1153
|
+
}
|
|
1154
|
+
},
|
|
1155
|
+
store,
|
|
1156
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
1157
|
+
})
|
|
1158
|
+
|
|
1159
|
+
await subscriptions.put(
|
|
1160
|
+
createRecord({
|
|
1161
|
+
billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(),
|
|
1162
|
+
lastChargedPeriod: 0,
|
|
1163
|
+
lookupKey: subscriptionKey,
|
|
1164
|
+
reference: hashStale,
|
|
1165
|
+
subscriptionId: 'sub_due',
|
|
1166
|
+
}),
|
|
1167
|
+
)
|
|
1168
|
+
|
|
1169
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
1170
|
+
const result = await mppx.tempo.subscription({})(
|
|
1171
|
+
new Request('https://example.com/resource', {
|
|
1172
|
+
headers: { 'X-Subscription-Key': subscriptionKey },
|
|
1173
|
+
}),
|
|
1174
|
+
)
|
|
1175
|
+
|
|
1176
|
+
expect(result.status).toBe(200)
|
|
1177
|
+
expect(renewCalls.length).toBe(1)
|
|
1178
|
+
expect(renewCalls[0]).toBeGreaterThan(0)
|
|
1179
|
+
expect(renewalReferences[0]).toBe(`renewal:sub_due:${renewCalls[0]}`)
|
|
1180
|
+
if (result.status !== 200) throw new Error('expected renewal success')
|
|
1181
|
+
|
|
1182
|
+
const receipt = Receipt.fromResponse(result.withReceipt(new Response('OK')))
|
|
1183
|
+
expect(receipt.reference).toBe(hashRenewed)
|
|
1184
|
+
expect(receipt.subscriptionId).toBe('sub_due')
|
|
1185
|
+
expect((await subscriptions.get('sub_due'))?.inFlightReference).toBe(undefined)
|
|
1186
|
+
})
|
|
1187
|
+
|
|
1188
|
+
test('rejects renewals that change the active subscriptionId', async () => {
|
|
1189
|
+
const store = Store.memory()
|
|
1190
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
1191
|
+
const method = subscription({
|
|
1192
|
+
accessKey: async () => accessKey,
|
|
1193
|
+
activate: async () => ({
|
|
1194
|
+
receipt: createReceipt('unused'),
|
|
1195
|
+
subscription: createRecord({ subscriptionId: 'unused' }),
|
|
1196
|
+
}),
|
|
1197
|
+
amount: subscriptionAmount,
|
|
1198
|
+
chainId,
|
|
1199
|
+
currency: subscriptionCurrency,
|
|
1200
|
+
periodCount: subscriptionPeriodCount,
|
|
1201
|
+
periodUnit: subscriptionPeriodUnit,
|
|
1202
|
+
recipient: subscriptionRecipient,
|
|
1203
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
1204
|
+
renew: async ({ periodIndex, subscription }) => {
|
|
1205
|
+
const record = {
|
|
1206
|
+
...subscription,
|
|
1207
|
+
lastChargedPeriod: periodIndex,
|
|
1208
|
+
reference: hashRenewed,
|
|
1209
|
+
subscriptionId: 'sub_other',
|
|
1210
|
+
}
|
|
1211
|
+
return {
|
|
1212
|
+
receipt: createReceipt(record.subscriptionId, hashRenewed),
|
|
1213
|
+
subscription: record,
|
|
1214
|
+
}
|
|
1215
|
+
},
|
|
1216
|
+
store,
|
|
1217
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
1218
|
+
})
|
|
1219
|
+
|
|
1220
|
+
await subscriptions.put(
|
|
1221
|
+
createRecord({
|
|
1222
|
+
billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(),
|
|
1223
|
+
lastChargedPeriod: 0,
|
|
1224
|
+
lookupKey: subscriptionKey,
|
|
1225
|
+
reference: hashStale,
|
|
1226
|
+
subscriptionId: 'sub_due',
|
|
1227
|
+
}),
|
|
1228
|
+
)
|
|
1229
|
+
|
|
1230
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
1231
|
+
const rejected = await mppx.tempo.subscription({})(
|
|
1232
|
+
new Request('https://example.com/resource', {
|
|
1233
|
+
headers: { 'X-Subscription-Key': subscriptionKey },
|
|
1234
|
+
}),
|
|
1235
|
+
)
|
|
1236
|
+
|
|
1237
|
+
expect(rejected.status).toBe(402)
|
|
1238
|
+
expect((await subscriptions.getByKey(subscriptionKey))?.subscriptionId).toBe('sub_due')
|
|
1239
|
+
expect((await subscriptions.get('sub_due'))?.lastChargedPeriod).toBe(0)
|
|
1240
|
+
})
|
|
1241
|
+
|
|
1242
|
+
test('charges an overdue subscription outside the request path', async () => {
|
|
1243
|
+
const store = Store.memory()
|
|
1244
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
1245
|
+
const renewCalls: number[] = []
|
|
1246
|
+
|
|
1247
|
+
await subscriptions.put(
|
|
1248
|
+
createRecord({
|
|
1249
|
+
billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(),
|
|
1250
|
+
lastChargedPeriod: 0,
|
|
1251
|
+
lookupKey: subscriptionKey,
|
|
1252
|
+
amount: subscriptionAmount,
|
|
1253
|
+
reference: hashStale,
|
|
1254
|
+
subscriptionId: 'sub_background',
|
|
1255
|
+
}),
|
|
1256
|
+
)
|
|
1257
|
+
|
|
1258
|
+
const result = await renew({
|
|
1259
|
+
renew: async ({ periodIndex, subscription }) => {
|
|
1260
|
+
renewCalls.push(periodIndex)
|
|
1261
|
+
return {
|
|
1262
|
+
receipt: createReceipt(subscription.subscriptionId, hashBackground),
|
|
1263
|
+
subscription: {
|
|
1264
|
+
...subscription,
|
|
1265
|
+
lastChargedPeriod: periodIndex,
|
|
1266
|
+
reference: hashBackground,
|
|
1267
|
+
},
|
|
1268
|
+
}
|
|
1269
|
+
},
|
|
1270
|
+
store,
|
|
1271
|
+
subscriptionId: 'sub_background',
|
|
1272
|
+
})
|
|
1273
|
+
|
|
1274
|
+
expect(result?.receipt.reference).toBe(hashBackground)
|
|
1275
|
+
expect(renewCalls.length).toBe(1)
|
|
1276
|
+
expect((await subscriptions.get('sub_background'))?.reference).toBe(hashBackground)
|
|
1277
|
+
})
|
|
1278
|
+
|
|
1279
|
+
test('rejects background renewals that mutate economic fields', async () => {
|
|
1280
|
+
const store = Store.memory()
|
|
1281
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
1282
|
+
|
|
1283
|
+
await subscriptions.put(
|
|
1284
|
+
createRecord({
|
|
1285
|
+
billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(),
|
|
1286
|
+
lastChargedPeriod: 0,
|
|
1287
|
+
lookupKey: subscriptionKey,
|
|
1288
|
+
amount: subscriptionAmount,
|
|
1289
|
+
reference: hashStale,
|
|
1290
|
+
subscriptionId: 'sub_background',
|
|
1291
|
+
}),
|
|
1292
|
+
)
|
|
1293
|
+
|
|
1294
|
+
await expect(
|
|
1295
|
+
renew({
|
|
1296
|
+
renew: async ({ periodIndex, subscription }) => {
|
|
1297
|
+
const mutated = {
|
|
1298
|
+
...subscription,
|
|
1299
|
+
amount: '999',
|
|
1300
|
+
lastChargedPeriod: periodIndex,
|
|
1301
|
+
reference: hashBackground,
|
|
1302
|
+
}
|
|
1303
|
+
return {
|
|
1304
|
+
receipt: createReceipt(subscription.subscriptionId, hashBackground),
|
|
1305
|
+
subscription: mutated,
|
|
1306
|
+
}
|
|
1307
|
+
},
|
|
1308
|
+
store,
|
|
1309
|
+
subscriptionId: 'sub_background',
|
|
1310
|
+
}),
|
|
1311
|
+
).rejects.toThrow('subscription record does not match request')
|
|
1312
|
+
|
|
1313
|
+
expect((await subscriptions.get('sub_background'))?.inFlightReference).toBe(undefined)
|
|
1314
|
+
expect((await subscriptions.get('sub_background'))?.amount).toBe(subscriptionAmount)
|
|
1315
|
+
})
|
|
1316
|
+
|
|
1317
|
+
test('does not charge a superseded subscription outside the request path', async () => {
|
|
1318
|
+
const store = Store.memory()
|
|
1319
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
1320
|
+
let renewCalls = 0
|
|
1321
|
+
|
|
1322
|
+
await subscriptions.put(
|
|
1323
|
+
createRecord({
|
|
1324
|
+
billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(),
|
|
1325
|
+
lastChargedPeriod: 0,
|
|
1326
|
+
reference: hashStale,
|
|
1327
|
+
subscriptionId: 'sub_old',
|
|
1328
|
+
}),
|
|
1329
|
+
)
|
|
1330
|
+
await subscriptions.put(
|
|
1331
|
+
createRecord({
|
|
1332
|
+
reference: hashBackground,
|
|
1333
|
+
subscriptionId: 'sub_new',
|
|
1334
|
+
}),
|
|
1335
|
+
)
|
|
1336
|
+
|
|
1337
|
+
const result = await renew({
|
|
1338
|
+
renew: async ({ subscription }) => {
|
|
1339
|
+
renewCalls += 1
|
|
1340
|
+
return {
|
|
1341
|
+
receipt: createReceipt(subscription.subscriptionId, hashBackground),
|
|
1342
|
+
subscription,
|
|
1343
|
+
}
|
|
1344
|
+
},
|
|
1345
|
+
store,
|
|
1346
|
+
subscriptionId: 'sub_old',
|
|
1347
|
+
})
|
|
1348
|
+
|
|
1349
|
+
expect(result).toBe(null)
|
|
1350
|
+
expect(renewCalls).toBe(0)
|
|
1351
|
+
expect((await subscriptions.getByKey(subscriptionKey))?.subscriptionId).toBe('sub_new')
|
|
1352
|
+
})
|
|
1353
|
+
|
|
1354
|
+
test('automatically renews an overdue subscription outside the request path', async () => {
|
|
1355
|
+
const store = Store.memory()
|
|
1356
|
+
const subscriptions = SubscriptionStore.fromStore(store)
|
|
1357
|
+
const { client, rpcMethods } = createBillingClient([hashActivate, hashBackground])
|
|
1358
|
+
const method = subscription({
|
|
1359
|
+
amount: subscriptionAmount,
|
|
1360
|
+
chainId,
|
|
1361
|
+
currency: subscriptionCurrency,
|
|
1362
|
+
getClient: async () => client,
|
|
1363
|
+
periodCount: subscriptionPeriodCount,
|
|
1364
|
+
periodUnit: subscriptionPeriodUnit,
|
|
1365
|
+
recipient: subscriptionRecipient,
|
|
1366
|
+
resolve: async () => ({ key: subscriptionKey }),
|
|
1367
|
+
store,
|
|
1368
|
+
subscriptionExpires: activeSubscriptionExpires,
|
|
1369
|
+
waitForConfirmation: false,
|
|
1370
|
+
})
|
|
1371
|
+
const mppx = Mppx.create({ methods: [method], realm, secretKey })
|
|
1372
|
+
const challengeResult = await mppx.tempo.subscription({})(
|
|
1373
|
+
new Request('https://example.com/resource'),
|
|
1374
|
+
)
|
|
1375
|
+
if (challengeResult.status !== 402) throw new Error('expected activation challenge')
|
|
1376
|
+
const challenge = Challenge.fromResponse(challengeResult.challenge)
|
|
1377
|
+
const accessKey = (
|
|
1378
|
+
challenge.request as ReturnType<typeof Methods.subscription.schema.request.parse>
|
|
1379
|
+
).methodDetails?.accessKey
|
|
1380
|
+
if (!accessKey) throw new Error('expected generated access key')
|
|
1381
|
+
const credential = await createCredential(challenge, rootAccount.address, accessKey)
|
|
1382
|
+
const activated = await mppx.tempo.subscription({})(
|
|
1383
|
+
new Request('https://example.com/resource', {
|
|
1384
|
+
headers: { Authorization: Credential.serialize(credential) },
|
|
1385
|
+
}),
|
|
1386
|
+
)
|
|
1387
|
+
expect(activated.status).toBe(200)
|
|
1388
|
+
|
|
1389
|
+
const record = await subscriptions.getByKey(subscriptionKey)
|
|
1390
|
+
if (!record) throw new Error('expected subscription record')
|
|
1391
|
+
await subscriptions.put({
|
|
1392
|
+
...record,
|
|
1393
|
+
billingAnchor: new Date(Date.now() - 3 * subscriptionPeriodMilliseconds).toISOString(),
|
|
1394
|
+
lastChargedPeriod: 0,
|
|
1395
|
+
reference: hashStale,
|
|
1396
|
+
})
|
|
1397
|
+
|
|
1398
|
+
const result = await renew({
|
|
1399
|
+
getClient: async () => client,
|
|
1400
|
+
store,
|
|
1401
|
+
subscriptionId: record.subscriptionId,
|
|
1402
|
+
waitForConfirmation: false,
|
|
1403
|
+
})
|
|
1404
|
+
|
|
1405
|
+
expect(result?.receipt.reference).toBe(hashBackground)
|
|
1406
|
+
expect(rpcMethods.filter((method) => method === 'eth_sendRawTransaction')).toHaveLength(2)
|
|
1407
|
+
const renewed = await subscriptions.get(record.subscriptionId)
|
|
1408
|
+
expect(renewed?.reference).toBe(hashBackground)
|
|
1409
|
+
})
|
|
1410
|
+
})
|