mppx 0.6.19 → 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.
Files changed (148) hide show
  1. package/CHANGELOG.md +7 -0
  2. package/dist/Challenge.d.ts +2 -2
  3. package/dist/Challenge.d.ts.map +1 -1
  4. package/dist/Challenge.js +1 -1
  5. package/dist/Challenge.js.map +1 -1
  6. package/dist/Method.d.ts +34 -0
  7. package/dist/Method.d.ts.map +1 -1
  8. package/dist/Method.js +3 -1
  9. package/dist/Method.js.map +1 -1
  10. package/dist/Receipt.d.ts +1 -0
  11. package/dist/Receipt.d.ts.map +1 -1
  12. package/dist/Receipt.js +2 -0
  13. package/dist/Receipt.js.map +1 -1
  14. package/dist/client/Methods.d.ts +1 -0
  15. package/dist/client/Methods.d.ts.map +1 -1
  16. package/dist/client/Methods.js +1 -0
  17. package/dist/client/Methods.js.map +1 -1
  18. package/dist/middlewares/elysia.d.ts.map +1 -1
  19. package/dist/middlewares/elysia.js +14 -0
  20. package/dist/middlewares/elysia.js.map +1 -1
  21. package/dist/middlewares/express.d.ts.map +1 -1
  22. package/dist/middlewares/express.js +1 -2
  23. package/dist/middlewares/express.js.map +1 -1
  24. package/dist/middlewares/hono.d.ts.map +1 -1
  25. package/dist/middlewares/hono.js +14 -0
  26. package/dist/middlewares/hono.js.map +1 -1
  27. package/dist/middlewares/nextjs.d.ts.map +1 -1
  28. package/dist/middlewares/nextjs.js +14 -0
  29. package/dist/middlewares/nextjs.js.map +1 -1
  30. package/dist/proxy/Proxy.d.ts.map +1 -1
  31. package/dist/proxy/Proxy.js +2 -2
  32. package/dist/proxy/Proxy.js.map +1 -1
  33. package/dist/proxy/Service.d.ts.map +1 -1
  34. package/dist/proxy/Service.js +1 -1
  35. package/dist/proxy/Service.js.map +1 -1
  36. package/dist/server/Mppx.d.ts +15 -3
  37. package/dist/server/Mppx.d.ts.map +1 -1
  38. package/dist/server/Mppx.js +190 -40
  39. package/dist/server/Mppx.js.map +1 -1
  40. package/dist/stripe/server/internal/html.gen.d.ts +1 -1
  41. package/dist/stripe/server/internal/html.gen.d.ts.map +1 -1
  42. package/dist/stripe/server/internal/html.gen.js +1 -1
  43. package/dist/stripe/server/internal/html.gen.js.map +1 -1
  44. package/dist/tempo/Methods.d.ts +96 -0
  45. package/dist/tempo/Methods.d.ts.map +1 -1
  46. package/dist/tempo/Methods.js +97 -0
  47. package/dist/tempo/Methods.js.map +1 -1
  48. package/dist/tempo/client/Methods.d.ts +3 -0
  49. package/dist/tempo/client/Methods.d.ts.map +1 -1
  50. package/dist/tempo/client/Methods.js +3 -0
  51. package/dist/tempo/client/Methods.js.map +1 -1
  52. package/dist/tempo/client/Subscription.d.ts +114 -0
  53. package/dist/tempo/client/Subscription.d.ts.map +1 -0
  54. package/dist/tempo/client/Subscription.js +100 -0
  55. package/dist/tempo/client/Subscription.js.map +1 -0
  56. package/dist/tempo/client/index.d.ts +1 -0
  57. package/dist/tempo/client/index.d.ts.map +1 -1
  58. package/dist/tempo/client/index.js +1 -0
  59. package/dist/tempo/client/index.js.map +1 -1
  60. package/dist/tempo/index.d.ts +1 -0
  61. package/dist/tempo/index.d.ts.map +1 -1
  62. package/dist/tempo/index.js +1 -0
  63. package/dist/tempo/index.js.map +1 -1
  64. package/dist/tempo/server/Methods.d.ts +5 -0
  65. package/dist/tempo/server/Methods.d.ts.map +1 -1
  66. package/dist/tempo/server/Methods.js +5 -0
  67. package/dist/tempo/server/Methods.js.map +1 -1
  68. package/dist/tempo/server/Subscription.d.ts +221 -0
  69. package/dist/tempo/server/Subscription.d.ts.map +1 -0
  70. package/dist/tempo/server/Subscription.js +637 -0
  71. package/dist/tempo/server/Subscription.js.map +1 -0
  72. package/dist/tempo/server/index.d.ts +1 -0
  73. package/dist/tempo/server/index.d.ts.map +1 -1
  74. package/dist/tempo/server/index.js +1 -0
  75. package/dist/tempo/server/index.js.map +1 -1
  76. package/dist/tempo/server/internal/html.gen.d.ts +1 -1
  77. package/dist/tempo/server/internal/html.gen.d.ts.map +1 -1
  78. package/dist/tempo/server/internal/html.gen.js +1 -1
  79. package/dist/tempo/server/internal/html.gen.js.map +1 -1
  80. package/dist/tempo/subscription/KeyAuthorization.d.ts +282 -0
  81. package/dist/tempo/subscription/KeyAuthorization.d.ts.map +1 -0
  82. package/dist/tempo/subscription/KeyAuthorization.js +297 -0
  83. package/dist/tempo/subscription/KeyAuthorization.js.map +1 -0
  84. package/dist/tempo/subscription/Receipt.d.ts +10 -0
  85. package/dist/tempo/subscription/Receipt.d.ts.map +1 -0
  86. package/dist/tempo/subscription/Receipt.js +16 -0
  87. package/dist/tempo/subscription/Receipt.js.map +1 -0
  88. package/dist/tempo/subscription/Store.d.ts +99 -0
  89. package/dist/tempo/subscription/Store.d.ts.map +1 -0
  90. package/dist/tempo/subscription/Store.js +292 -0
  91. package/dist/tempo/subscription/Store.js.map +1 -0
  92. package/dist/tempo/subscription/Types.d.ts +65 -0
  93. package/dist/tempo/subscription/Types.d.ts.map +1 -0
  94. package/dist/tempo/subscription/Types.js +2 -0
  95. package/dist/tempo/subscription/Types.js.map +1 -0
  96. package/dist/tempo/subscription/index.d.ts +6 -0
  97. package/dist/tempo/subscription/index.d.ts.map +1 -0
  98. package/dist/tempo/subscription/index.js +4 -0
  99. package/dist/tempo/subscription/index.js.map +1 -0
  100. package/dist/zod.d.ts +7 -0
  101. package/dist/zod.d.ts.map +1 -1
  102. package/dist/zod.js +18 -0
  103. package/dist/zod.js.map +1 -1
  104. package/package.json +3 -3
  105. package/src/Challenge.test.ts +13 -0
  106. package/src/Challenge.ts +3 -3
  107. package/src/Method.ts +46 -1
  108. package/src/Receipt.ts +2 -0
  109. package/src/client/Methods.ts +1 -0
  110. package/src/middlewares/elysia.test.ts +31 -1
  111. package/src/middlewares/elysia.ts +13 -0
  112. package/src/middlewares/express.ts +1 -5
  113. package/src/middlewares/hono.test.ts +30 -1
  114. package/src/middlewares/hono.ts +13 -0
  115. package/src/middlewares/nextjs.test.ts +28 -1
  116. package/src/middlewares/nextjs.ts +13 -0
  117. package/src/proxy/Proxy.ts +2 -5
  118. package/src/proxy/Service.test.ts +34 -0
  119. package/src/proxy/Service.ts +7 -0
  120. package/src/server/Mppx.authorize.test.ts +210 -0
  121. package/src/server/Mppx.test-d.ts +23 -1
  122. package/src/server/Mppx.test.ts +73 -3
  123. package/src/server/Mppx.ts +291 -58
  124. package/src/stripe/server/internal/html/package.json +1 -1
  125. package/src/stripe/server/internal/html.gen.ts +1 -1
  126. package/src/tempo/Methods.test.ts +131 -0
  127. package/src/tempo/Methods.ts +136 -0
  128. package/src/tempo/Subscription.integration.test.ts +591 -0
  129. package/src/tempo/client/Methods.ts +3 -0
  130. package/src/tempo/client/Subscription.test.ts +131 -0
  131. package/src/tempo/client/Subscription.ts +155 -0
  132. package/src/tempo/client/index.ts +1 -0
  133. package/src/tempo/index.ts +1 -0
  134. package/src/tempo/server/Methods.ts +5 -0
  135. package/src/tempo/server/Subscription.test.ts +1410 -0
  136. package/src/tempo/server/Subscription.ts +1014 -0
  137. package/src/tempo/server/index.ts +1 -0
  138. package/src/tempo/server/internal/html/package.json +1 -1
  139. package/src/tempo/server/internal/html.gen.ts +1 -1
  140. package/src/tempo/subscription/KeyAuthorization.test.ts +204 -0
  141. package/src/tempo/subscription/KeyAuthorization.ts +394 -0
  142. package/src/tempo/subscription/Receipt.ts +28 -0
  143. package/src/tempo/subscription/Store.test.ts +554 -0
  144. package/src/tempo/subscription/Store.ts +431 -0
  145. package/src/tempo/subscription/Types.ts +68 -0
  146. package/src/tempo/subscription/index.ts +23 -0
  147. package/src/zod.test.ts +23 -1
  148. package/src/zod.ts +24 -0
@@ -0,0 +1,591 @@
1
+ import { Receipt } from 'mppx'
2
+ import { Mppx as Mppx_client, tempo as tempo_client } from 'mppx/client'
3
+ import { Mppx as Mppx_server, tempo as tempo_server } from 'mppx/server'
4
+ import { privateKeyToAccount } from 'viem/accounts'
5
+ import { describe, expect, test, vi } from 'vp/test'
6
+
7
+ import * as Store from '../Store.js'
8
+ import { createSubscriptionReceipt } from './subscription/Receipt.js'
9
+ import * as SubscriptionStore from './subscription/Store.js'
10
+ import type { SubscriptionAccessKey, SubscriptionRecord } from './subscription/Types.js'
11
+
12
+ const realm = 'news.example.com'
13
+ const secretKey = 'subscription-lifecycle-secret'
14
+ const currency = '0x20c0000000000000000000000000000000000001'
15
+ const recipient = '0x1234567890abcdef1234567890abcdef12345678'
16
+ const periodCount = '30'
17
+ const periodUnit = 'day'
18
+ const periodSeconds = String(30 * 86_400)
19
+ const subscriptionExpires = new Date(
20
+ Math.ceil((Date.now() + 365 * 24 * 60 * 60 * 1_000) / 1_000) * 1_000,
21
+ ).toISOString()
22
+ const userId = 'user-1'
23
+ const planId = 'monthly'
24
+ const subscriptionKey = `news:${userId}:${planId}`
25
+ const rootAccount = privateKeyToAccount(
26
+ '0x0000000000000000000000000000000000000000000000000000000000000001',
27
+ )
28
+ const accessAccount = privateKeyToAccount(
29
+ '0x0000000000000000000000000000000000000000000000000000000000000002',
30
+ )
31
+ const accessKey = {
32
+ accessKeyAddress: accessAccount.address,
33
+ keyType: 'secp256k1',
34
+ } as const satisfies SubscriptionAccessKey
35
+
36
+ function txHash(index: number) {
37
+ return `0x${index.toString(16).padStart(64, '0')}` as const
38
+ }
39
+
40
+ function timestamp(index: number) {
41
+ return new Date(Date.UTC(2025, 0, 1, 0, 0, index)).toISOString()
42
+ }
43
+
44
+ function receiptFor(record: SubscriptionRecord) {
45
+ return createSubscriptionReceipt(record)
46
+ }
47
+
48
+ describe('tempo subscription lifecycle integration', () => {
49
+ test('runs a news app subscription from activation through reuse, renewal, cancellation, and reactivation', async () => {
50
+ const store = Store.memory()
51
+ const subscriptions = SubscriptionStore.fromStore(store)
52
+ const events: string[] = []
53
+ const resolvedKeys: string[] = []
54
+ const renewalReferences: string[] = []
55
+ let activationCount = 0
56
+ let renewalCount = 0
57
+
58
+ const server = Mppx_server.create({
59
+ methods: [
60
+ tempo_server.subscription({
61
+ activate: async ({ request, resolved, source }) => {
62
+ activationCount += 1
63
+ const record = {
64
+ amount: request.amount,
65
+ billingAnchor: new Date().toISOString(),
66
+ chainId: request.methodDetails?.chainId,
67
+ currency: request.currency,
68
+ lastChargedPeriod: 0,
69
+ lookupKey: resolved.key,
70
+ periodCount: request.periodCount,
71
+ periodUnit: request.periodUnit,
72
+ recipient: request.recipient,
73
+ reference: txHash(activationCount),
74
+ subscriptionExpires: request.subscriptionExpires,
75
+ subscriptionId: `sub_${activationCount}`,
76
+ timestamp: timestamp(activationCount),
77
+ } satisfies SubscriptionRecord
78
+
79
+ events.push(`activated:${record.subscriptionId}:${source?.address.toLowerCase()}`)
80
+ return {
81
+ receipt: receiptFor(record),
82
+ subscription: record,
83
+ }
84
+ },
85
+ amount: '1',
86
+ chainId: 4217,
87
+ currency,
88
+ periodCount,
89
+ periodUnit,
90
+ recipient,
91
+ resolve: async ({ input }) => {
92
+ const requestedUserId = input.headers.get('X-User-Id')
93
+ if (!requestedUserId) return null
94
+ const key = `news:${requestedUserId}:${planId}`
95
+ resolvedKeys.push(key)
96
+ if (key !== subscriptionKey) throw new Error('unknown subscription key')
97
+ return { accessKey, key }
98
+ },
99
+ renew: async ({ inFlightReference, periodIndex, subscription }) => {
100
+ renewalCount += 1
101
+ renewalReferences.push(inFlightReference)
102
+ const record = {
103
+ ...subscription,
104
+ lastChargedPeriod: periodIndex,
105
+ reference: txHash(100 + renewalCount),
106
+ timestamp: timestamp(100 + renewalCount),
107
+ }
108
+
109
+ events.push(`renewed:${record.subscriptionId}:${periodIndex}`)
110
+ return {
111
+ receipt: receiptFor(record),
112
+ subscription: record,
113
+ }
114
+ },
115
+ store,
116
+ subscriptionExpires,
117
+ hooks: {
118
+ activated: async ({ subscription }) => {
119
+ events.push(`hook:activated:${subscription.subscriptionId}`)
120
+ },
121
+ renewed: async ({ periodIndex, subscription }) => {
122
+ events.push(`hook:renewed:${subscription.subscriptionId}:${periodIndex}`)
123
+ },
124
+ },
125
+ }),
126
+ ],
127
+ realm,
128
+ secretKey,
129
+ })
130
+
131
+ const appFetch = async (input: RequestInfo | URL, init?: RequestInit) => {
132
+ const request = new Request(input, init)
133
+ const result = await server.tempo.subscription({})(request)
134
+ if (result.status === 402) return result.challenge
135
+ return result.withReceipt(
136
+ Response.json({
137
+ article: 'paid article',
138
+ userId: request.headers.get('X-User-Id'),
139
+ }),
140
+ )
141
+ }
142
+
143
+ const client = Mppx_client.create({
144
+ fetch: appFetch,
145
+ methods: [
146
+ tempo_client.subscription({
147
+ account: rootAccount,
148
+ getClient: async () =>
149
+ ({
150
+ request: async () => {
151
+ throw new Error('wallet_authorizeAccessKey should not be called for local account')
152
+ },
153
+ }) as never,
154
+ validateRequest: (request) => {
155
+ expect(request.amount).toBe('1000000')
156
+ expect(request.currency).toBe(currency)
157
+ expect(request.periodCount).toBe(periodCount)
158
+ expect(request.periodUnit).toBe(periodUnit)
159
+ expect(request.recipient).toBe(recipient)
160
+ },
161
+ }),
162
+ ],
163
+ polyfill: false,
164
+ })
165
+
166
+ const request = {
167
+ headers: { 'X-User-Id': userId },
168
+ } as const
169
+
170
+ const activated = await client.fetch('https://news.example.com/articles/tempo', request)
171
+ expect(activated.status).toBe(200)
172
+ expect((await activated.clone().json()).article).toBe('paid article')
173
+ expect(Receipt.fromResponse(activated).subscriptionId).toBe('sub_1')
174
+ expect(activationCount).toBe(1)
175
+ expect(resolvedKeys.at(0)).toBe(subscriptionKey)
176
+ expect(await subscriptions.getByKey(subscriptionKey)).toMatchObject({
177
+ accessKey: {
178
+ accessKeyAddress: accessKey.accessKeyAddress.toLowerCase(),
179
+ keyType: accessKey.keyType,
180
+ },
181
+ amount: '1000000',
182
+ lastChargedPeriod: 0,
183
+ lookupKey: subscriptionKey,
184
+ periodCount,
185
+ periodUnit,
186
+ subscriptionId: 'sub_1',
187
+ })
188
+
189
+ const reused = await client.fetch('https://news.example.com/articles/tempo', request)
190
+ expect(reused.status).toBe(200)
191
+ expect(Receipt.fromResponse(reused).subscriptionId).toBe('sub_1')
192
+ expect(activationCount).toBe(1)
193
+ expect(renewalCount).toBe(0)
194
+
195
+ const active = await subscriptions.get('sub_1')
196
+ if (!active) throw new Error('expected active subscription')
197
+ await subscriptions.put({
198
+ ...active,
199
+ billingAnchor: new Date(Date.now() - 3 * Number(periodSeconds) * 1_000).toISOString(),
200
+ lastChargedPeriod: 0,
201
+ reference: txHash(99),
202
+ timestamp: timestamp(99),
203
+ })
204
+
205
+ const renewed = await client.fetch('https://news.example.com/articles/tempo', request)
206
+ expect(renewed.status).toBe(200)
207
+ expect(Receipt.fromResponse(renewed).subscriptionId).toBe('sub_1')
208
+ expect(renewalCount).toBe(1)
209
+ const afterRequestRenewal = await subscriptions.get('sub_1')
210
+ expect(afterRequestRenewal?.lastChargedPeriod).toBeGreaterThan(0)
211
+ expect(afterRequestRenewal?.inFlightPeriod).toBe(undefined)
212
+
213
+ if (!afterRequestRenewal) throw new Error('expected renewed subscription')
214
+ await subscriptions.put({
215
+ ...afterRequestRenewal,
216
+ billingAnchor: new Date(Date.now() - 5 * Number(periodSeconds) * 1_000).toISOString(),
217
+ lastChargedPeriod: 1,
218
+ reference: txHash(199),
219
+ timestamp: timestamp(199),
220
+ })
221
+ const backgroundRenewal = await tempo_server.renewSubscription({
222
+ renew: async ({ inFlightReference, periodIndex, subscription }) => {
223
+ renewalCount += 1
224
+ renewalReferences.push(inFlightReference)
225
+ const record = {
226
+ ...subscription,
227
+ lastChargedPeriod: periodIndex,
228
+ reference: txHash(100 + renewalCount),
229
+ timestamp: timestamp(100 + renewalCount),
230
+ }
231
+ events.push(`background:${record.subscriptionId}:${periodIndex}`)
232
+ return {
233
+ receipt: receiptFor(record),
234
+ subscription: record,
235
+ }
236
+ },
237
+ store,
238
+ subscriptionId: 'sub_1',
239
+ })
240
+ expect(backgroundRenewal?.subscription.subscriptionId).toBe('sub_1')
241
+ expect(
242
+ await tempo_server.renewSubscription({
243
+ renew: async () => {
244
+ throw new Error('already renewed period should not be charged again')
245
+ },
246
+ store,
247
+ subscriptionId: 'sub_1',
248
+ }),
249
+ ).toBe(null)
250
+
251
+ const current = await subscriptions.get('sub_1')
252
+ if (!current) throw new Error('expected subscription before cancellation')
253
+ await subscriptions.put({
254
+ ...current,
255
+ canceledAt: timestamp(240),
256
+ })
257
+
258
+ const canceledProbe = await appFetch('https://news.example.com/articles/tempo', {
259
+ headers: { 'X-User-Id': userId },
260
+ })
261
+ expect(canceledProbe.status).toBe(402)
262
+
263
+ const reactivated = await client.fetch('https://news.example.com/articles/tempo', request)
264
+ expect(reactivated.status).toBe(200)
265
+ expect(Receipt.fromResponse(reactivated).subscriptionId).toBe('sub_2')
266
+ expect(activationCount).toBe(2)
267
+ expect((await subscriptions.getByKey(subscriptionKey))?.subscriptionId).toBe('sub_2')
268
+
269
+ expect(resolvedKeys.every((key) => key === subscriptionKey)).toBe(true)
270
+ expect(renewalReferences).toEqual(
271
+ expect.arrayContaining([expect.stringMatching(/^renewal:sub_1:\d+$/)]),
272
+ )
273
+ expect(events).toEqual(
274
+ expect.arrayContaining([
275
+ `activated:sub_1:${rootAccount.address.toLowerCase()}`,
276
+ 'hook:activated:sub_1',
277
+ expect.stringMatching(/^renewed:sub_1:\d+$/),
278
+ expect.stringMatching(/^hook:renewed:sub_1:\d+$/),
279
+ expect.stringMatching(/^background:sub_1:\d+$/),
280
+ `activated:sub_2:${rootAccount.address.toLowerCase()}`,
281
+ 'hook:activated:sub_2',
282
+ ]),
283
+ )
284
+ })
285
+
286
+ test('renews 30-day elapsed periods across calendar-month boundaries', async () => {
287
+ vi.useFakeTimers()
288
+ try {
289
+ const store = Store.memory()
290
+ const subscriptions = SubscriptionStore.fromStore(store)
291
+ const renewals: number[] = []
292
+ await subscriptions.put({
293
+ accessKey,
294
+ amount: '1000000',
295
+ billingAnchor: '2026-01-31T12:03:10.000Z',
296
+ chainId: 4217,
297
+ currency,
298
+ lastChargedPeriod: 0,
299
+ lookupKey: subscriptionKey,
300
+ periodCount: '30',
301
+ periodUnit: 'day',
302
+ recipient,
303
+ reference: txHash(300),
304
+ subscriptionExpires: '2027-01-31T12:03:10.000Z',
305
+ subscriptionId: 'sub_elapsed',
306
+ timestamp: timestamp(300),
307
+ })
308
+
309
+ const server = Mppx_server.create({
310
+ methods: [
311
+ tempo_server.subscription({
312
+ accessKey: async () => accessKey,
313
+ activate: async () => {
314
+ throw new Error('existing subscription should be reused')
315
+ },
316
+ amount: '1',
317
+ chainId: 4217,
318
+ currency,
319
+ periodCount: '30',
320
+ periodUnit: 'day',
321
+ recipient,
322
+ resolve: async () => ({ accessKey, key: subscriptionKey }),
323
+ renew: async ({ periodIndex, subscription }) => {
324
+ renewals.push(periodIndex)
325
+ return {
326
+ receipt: receiptFor({
327
+ ...subscription,
328
+ lastChargedPeriod: periodIndex,
329
+ reference: txHash(301),
330
+ timestamp: timestamp(301),
331
+ }),
332
+ subscription: {
333
+ ...subscription,
334
+ lastChargedPeriod: periodIndex,
335
+ reference: txHash(301),
336
+ timestamp: timestamp(301),
337
+ },
338
+ }
339
+ },
340
+ store,
341
+ subscriptionExpires: '2027-01-31T12:03:10.000Z',
342
+ }),
343
+ ],
344
+ realm,
345
+ secretKey,
346
+ })
347
+
348
+ vi.setSystemTime(new Date('2026-02-28T12:03:10.000Z'))
349
+ const beforeElapsedBoundary = await server.tempo.subscription({})(
350
+ new Request('https://news.example.com/articles/tempo'),
351
+ )
352
+ expect(beforeElapsedBoundary.status).toBe(200)
353
+ expect(renewals).toEqual([])
354
+
355
+ vi.setSystemTime(new Date('2026-03-02T12:03:10.000Z'))
356
+ const afterElapsedBoundary = await server.tempo.subscription({})(
357
+ new Request('https://news.example.com/articles/tempo'),
358
+ )
359
+ expect(afterElapsedBoundary.status).toBe(200)
360
+ expect(renewals).toEqual([1])
361
+
362
+ const duplicate = await server.tempo.subscription({})(
363
+ new Request('https://news.example.com/articles/tempo'),
364
+ )
365
+ expect(duplicate.status).toBe(200)
366
+ expect(renewals).toEqual([1])
367
+ expect((await subscriptions.get('sub_elapsed'))?.lastChargedPeriod).toBe(1)
368
+ } finally {
369
+ vi.useRealTimers()
370
+ }
371
+ })
372
+
373
+ test('renews only the latest elapsed week period when multiple periods passed', async () => {
374
+ vi.useFakeTimers()
375
+ try {
376
+ const store = Store.memory()
377
+ const subscriptions = SubscriptionStore.fromStore(store)
378
+ const renewals: number[] = []
379
+ await subscriptions.put({
380
+ amount: '1000000',
381
+ billingAnchor: '2026-01-01T00:00:00.000Z',
382
+ chainId: 4217,
383
+ currency,
384
+ lastChargedPeriod: 0,
385
+ lookupKey: subscriptionKey,
386
+ periodCount: '2',
387
+ periodUnit: 'week',
388
+ recipient,
389
+ reference: txHash(400),
390
+ subscriptionExpires: '2027-01-01T00:00:00.000Z',
391
+ subscriptionId: 'sub_weekly',
392
+ timestamp: timestamp(400),
393
+ })
394
+
395
+ const server = Mppx_server.create({
396
+ methods: [
397
+ tempo_server.subscription({
398
+ accessKey: async () => accessKey,
399
+ activate: async () => {
400
+ throw new Error('existing subscription should be reused')
401
+ },
402
+ amount: '1',
403
+ chainId: 4217,
404
+ currency,
405
+ periodCount: '2',
406
+ periodUnit: 'week',
407
+ recipient,
408
+ resolve: async () => ({ key: subscriptionKey }),
409
+ renew: async ({ periodIndex, subscription }) => {
410
+ renewals.push(periodIndex)
411
+ return {
412
+ receipt: receiptFor({
413
+ ...subscription,
414
+ lastChargedPeriod: periodIndex,
415
+ reference: txHash(401),
416
+ timestamp: timestamp(401),
417
+ }),
418
+ subscription: {
419
+ ...subscription,
420
+ lastChargedPeriod: periodIndex,
421
+ reference: txHash(401),
422
+ timestamp: timestamp(401),
423
+ },
424
+ }
425
+ },
426
+ store,
427
+ subscriptionExpires: '2027-01-01T00:00:00.000Z',
428
+ }),
429
+ ],
430
+ realm,
431
+ secretKey,
432
+ })
433
+
434
+ vi.setSystemTime(new Date('2026-01-29T00:00:00.000Z'))
435
+ const result = await server.tempo.subscription({})(
436
+ new Request('https://news.example.com/articles/tempo'),
437
+ )
438
+ expect(result.status).toBe(200)
439
+ expect(renewals).toEqual([2])
440
+
441
+ const duplicate = await server.tempo.subscription({})(
442
+ new Request('https://news.example.com/articles/tempo'),
443
+ )
444
+ expect(duplicate.status).toBe(200)
445
+ expect(renewals).toEqual([2])
446
+ expect((await subscriptions.get('sub_weekly'))?.lastChargedPeriod).toBe(2)
447
+ } finally {
448
+ vi.useRealTimers()
449
+ }
450
+ })
451
+
452
+ test('rejects calendar-month subscription periods for Tempo', async () => {
453
+ const server = Mppx_server.create({
454
+ methods: [
455
+ tempo_server.subscription({
456
+ activate: async () => {
457
+ throw new Error('month period should not activate')
458
+ },
459
+ amount: '1',
460
+ chainId: 4217,
461
+ currency,
462
+ periodCount: '1',
463
+ periodUnit: 'month' as never,
464
+ recipient,
465
+ resolve: async () => ({ accessKey, key: subscriptionKey }),
466
+ store: Store.memory(),
467
+ subscriptionExpires,
468
+ }),
469
+ ],
470
+ realm,
471
+ secretKey,
472
+ })
473
+
474
+ expect(() => server.tempo.subscription({})).toThrow()
475
+ })
476
+
477
+ test('falls back to activation when an existing subscription is expired or revoked', async () => {
478
+ for (const state of ['expired', 'revoked'] as const) {
479
+ const store = Store.memory()
480
+ const subscriptions = SubscriptionStore.fromStore(store)
481
+ await subscriptions.put({
482
+ accessKey,
483
+ amount: '1000000',
484
+ billingAnchor: '2026-01-01T00:00:00.000Z',
485
+ chainId: 4217,
486
+ currency,
487
+ lastChargedPeriod: 0,
488
+ lookupKey: subscriptionKey,
489
+ periodCount,
490
+ periodUnit,
491
+ recipient,
492
+ reference: txHash(500),
493
+ subscriptionExpires:
494
+ state === 'expired' ? '2020-01-01T00:00:00.000Z' : '2027-01-01T00:00:00.000Z',
495
+ subscriptionId: `sub_${state}`,
496
+ timestamp: timestamp(500),
497
+ ...(state === 'revoked' ? { revokedAt: timestamp(501) } : {}),
498
+ })
499
+
500
+ const server = Mppx_server.create({
501
+ methods: [
502
+ tempo_server.subscription({
503
+ activate: async () => {
504
+ throw new Error('expired and revoked subscriptions should require a new credential')
505
+ },
506
+ amount: '1',
507
+ chainId: 4217,
508
+ currency,
509
+ periodCount,
510
+ periodUnit,
511
+ recipient,
512
+ resolve: async () => ({ accessKey, key: subscriptionKey }),
513
+ renew: async () => {
514
+ throw new Error('inactive subscriptions should not renew')
515
+ },
516
+ store,
517
+ subscriptionExpires,
518
+ }),
519
+ ],
520
+ realm,
521
+ secretKey,
522
+ })
523
+
524
+ const result = await server.tempo.subscription({})(
525
+ new Request('https://news.example.com/articles/tempo'),
526
+ )
527
+ expect(result.status).toBe(402)
528
+ expect((await subscriptions.getByKey(subscriptionKey))?.subscriptionId).toBe(`sub_${state}`)
529
+ }
530
+ })
531
+
532
+ test('clears in-flight renewal state after a failed renewal hook', async () => {
533
+ vi.useFakeTimers()
534
+ try {
535
+ const store = Store.memory()
536
+ const subscriptions = SubscriptionStore.fromStore(store)
537
+ await subscriptions.put({
538
+ amount: '1000000',
539
+ billingAnchor: '2026-01-01T00:00:00.000Z',
540
+ chainId: 4217,
541
+ currency,
542
+ lastChargedPeriod: 0,
543
+ lookupKey: subscriptionKey,
544
+ periodCount: '1',
545
+ periodUnit: 'week',
546
+ recipient,
547
+ reference: txHash(600),
548
+ subscriptionExpires: '2027-01-01T00:00:00.000Z',
549
+ subscriptionId: 'sub_failed_renewal',
550
+ timestamp: timestamp(600),
551
+ })
552
+
553
+ const server = Mppx_server.create({
554
+ methods: [
555
+ tempo_server.subscription({
556
+ activate: async () => {
557
+ throw new Error('existing subscription should be reused')
558
+ },
559
+ amount: '1',
560
+ chainId: 4217,
561
+ currency,
562
+ periodCount: '1',
563
+ periodUnit: 'week',
564
+ recipient,
565
+ resolve: async () => ({ accessKey, key: subscriptionKey }),
566
+ renew: async () => {
567
+ throw new Error('renewal failed')
568
+ },
569
+ store,
570
+ subscriptionExpires: '2027-01-01T00:00:00.000Z',
571
+ }),
572
+ ],
573
+ realm,
574
+ secretKey,
575
+ })
576
+
577
+ vi.setSystemTime(new Date('2026-01-15T00:00:00.000Z'))
578
+ const result = await server.tempo.subscription({})(
579
+ new Request('https://news.example.com/articles/tempo'),
580
+ )
581
+ expect(result.status).toBe(402)
582
+
583
+ const failed = await subscriptions.get('sub_failed_renewal')
584
+ expect(failed?.inFlightPeriod).toBe(undefined)
585
+ expect(failed?.inFlightReference).toBe(undefined)
586
+ expect(failed?.lastChargedPeriod).toBe(0)
587
+ } finally {
588
+ vi.useRealTimers()
589
+ }
590
+ })
591
+ })
@@ -1,6 +1,7 @@
1
1
  import { charge as charge_ } from './Charge.js'
2
2
  import { session as sessionIntent_ } from './Session.js'
3
3
  import { sessionManager as session_ } from './SessionManager.js'
4
+ import { subscription as subscription_ } from './Subscription.js'
4
5
 
5
6
  /**
6
7
  * Creates both Tempo `charge` and `session` client methods from shared parameters.
@@ -25,4 +26,6 @@ export namespace tempo {
25
26
  export const charge = charge_
26
27
  /** Creates a client-side streaming session for managing payment channels. */
27
28
  export const session = session_
29
+ /** Creates a Tempo `subscription` client method for recurring TIP-20 payments. */
30
+ export const subscription = subscription_
28
31
  }