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,431 @@
1
+ import { Secp256k1 } from 'ox'
2
+ import { Account as TempoAccount } from 'viem/tempo'
3
+
4
+ import * as Store from '../../Store.js'
5
+ import type { SubscriptionAccessKeyRecord, SubscriptionRecord } from './Types.js'
6
+
7
+ const defaultRecordPrefix = 'tempo:subscription:record:'
8
+ const defaultKeyPrefix = 'tempo:subscription:key:'
9
+ const defaultActivationPrefix = 'tempo:subscription:activation:'
10
+ const defaultAccessKeyPrefix = 'tempo:subscription:access-key:'
11
+ const defaultCredentialPrefix = 'tempo:subscription:credential:'
12
+ const defaultActivationTimeoutMs = 15 * 60 * 1_000
13
+ const defaultRenewalTimeoutMs = 15 * 60 * 1_000
14
+
15
+ /** Subscription-aware wrapper around a generic key-value store. */
16
+ export type SubscriptionStore = {
17
+ /** Runs activation once for a challenge and resolved lookup key. */
18
+ activate<result extends { subscription: SubscriptionRecord }>(
19
+ parameters: ActivateParameters<result>,
20
+ ): Promise<ActivateResult<result>>
21
+ /** Looks up a subscription by subscription ID. */
22
+ get(subscriptionId: string): Promise<SubscriptionRecord | null>
23
+ /** Looks up a generated access key for a resolved request key. */
24
+ getAccessKey(key: string): Promise<SubscriptionAccessKeyRecord | null>
25
+ /** Looks up the active subscription for a resolved request key. */
26
+ getByKey(key: string): Promise<SubscriptionRecord | null>
27
+ /** Gets or creates the server-owned access key for a resolved request key. */
28
+ getOrCreateAccessKey(key: string): Promise<SubscriptionAccessKeyRecord>
29
+ /** Upserts a subscription record and marks it as active for its lookup key. */
30
+ put(record: SubscriptionRecord): Promise<void>
31
+ /** Runs renewal once for a subscription period. */
32
+ renew<result extends { subscription: SubscriptionRecord }>(
33
+ parameters: RenewParameters<result>,
34
+ ): Promise<RenewResult<result>>
35
+ }
36
+
37
+ type ActivateParameters<result extends { subscription: SubscriptionRecord }> = {
38
+ challengeId: string
39
+ create: () => Promise<result>
40
+ isReusable?: ((subscription: SubscriptionRecord) => boolean) | undefined
41
+ lookupKey: string
42
+ }
43
+
44
+ export type ActivateResult<result extends { subscription: SubscriptionRecord }> =
45
+ | { status: 'activated'; result: result }
46
+ | { status: 'claimMismatch' }
47
+ | { status: 'existing'; subscription: SubscriptionRecord }
48
+ | { status: 'inFlight' }
49
+ | { status: 'replayed' }
50
+
51
+ type RenewParameters<result extends { subscription: SubscriptionRecord }> = {
52
+ inFlightReference: string
53
+ periodIndex: number
54
+ renew: (parameters: {
55
+ inFlightReference: string
56
+ periodIndex: number
57
+ subscription: SubscriptionRecord
58
+ }) => Promise<result>
59
+ subscriptionId: string
60
+ }
61
+
62
+ export type RenewResult<result extends { subscription: SubscriptionRecord }> =
63
+ | { status: 'charged'; subscription: SubscriptionRecord }
64
+ | { status: 'inFlight'; subscription: SubscriptionRecord }
65
+ | { status: 'missing' }
66
+ | { status: 'renewed'; result: result }
67
+ | { status: 'superseded'; subscription: SubscriptionRecord }
68
+ | { status: 'claimMismatch' }
69
+
70
+ type ActivationMarker = {
71
+ challengeId?: string
72
+ committingAt?: string
73
+ startedAt?: string
74
+ }
75
+
76
+ /** Wraps a generic key-value {@link Store.Store} with subscription-specific accessors. */
77
+ export function fromStore(
78
+ store: Store.AtomicStore<Record<string, unknown>>,
79
+ options?: fromStore.Options,
80
+ ): SubscriptionStore {
81
+ const {
82
+ accessKeyPrefix = defaultAccessKeyPrefix,
83
+ activationPrefix = defaultActivationPrefix,
84
+ activationTimeoutMs = defaultActivationTimeoutMs,
85
+ credentialPrefix = defaultCredentialPrefix,
86
+ keyPrefix = defaultKeyPrefix,
87
+ recordPrefix = defaultRecordPrefix,
88
+ renewalTimeoutMs = defaultRenewalTimeoutMs,
89
+ } = options ?? {}
90
+
91
+ function recordKey(subscriptionId: string): string {
92
+ return `${recordPrefix}${subscriptionId}`
93
+ }
94
+
95
+ function activationKey(key: string): string {
96
+ return `${activationPrefix}${key}`
97
+ }
98
+
99
+ function credentialKey(challengeId: string): string {
100
+ return `${credentialPrefix}${challengeId}`
101
+ }
102
+
103
+ function accessKeyKey(key: string): string {
104
+ return `${accessKeyPrefix}${key}`
105
+ }
106
+
107
+ function lookupRecordKey(key: string): string {
108
+ return `${keyPrefix}${key}`
109
+ }
110
+
111
+ async function getByLookupKey(key: string): Promise<SubscriptionRecord | null> {
112
+ const subscriptionId = (await store.get(lookupRecordKey(key))) as string | null
113
+ if (!subscriptionId) return null
114
+ return (await store.get(recordKey(subscriptionId))) as SubscriptionRecord | null
115
+ }
116
+
117
+ async function clearRenewalState(subscriptionId: string, periodIndex: number, attempt: string) {
118
+ await store.update(recordKey(subscriptionId), (current) => {
119
+ const subscription = current as SubscriptionRecord | null
120
+ if (
121
+ !subscription ||
122
+ subscription.inFlightPeriod !== periodIndex ||
123
+ subscription.inFlightAttempt !== attempt
124
+ ) {
125
+ return { op: 'noop', result: undefined }
126
+ }
127
+ return {
128
+ op: 'set',
129
+ value: clearRenewal(subscription),
130
+ result: undefined,
131
+ }
132
+ })
133
+ }
134
+
135
+ async function clearActivationState(lookupKey: string, challengeId: string) {
136
+ await store.update(activationKey(lookupKey), (current) => {
137
+ const marker = current as ActivationMarker | null
138
+ if (marker?.challengeId !== challengeId) return { op: 'noop', result: undefined }
139
+ return { op: 'delete', result: undefined }
140
+ })
141
+ }
142
+
143
+ async function ownsActivation(lookupKey: string, challengeId: string): Promise<boolean> {
144
+ const marker = (await store.get(activationKey(lookupKey))) as ActivationMarker | null
145
+ return marker?.challengeId === challengeId
146
+ }
147
+
148
+ return {
149
+ async activate({ challengeId, create, isReusable, lookupKey }) {
150
+ const claimed = await store.update(credentialKey(challengeId), (current) => {
151
+ if (current) return { op: 'noop', result: false }
152
+ return {
153
+ op: 'set',
154
+ value: { claimedAt: timestamp() },
155
+ result: true,
156
+ }
157
+ })
158
+ if (!claimed) return { status: 'replayed' }
159
+
160
+ const existing = await getByLookupKey(lookupKey)
161
+ if (existing && isReusable?.(existing)) {
162
+ return { status: 'existing', subscription: existing }
163
+ }
164
+
165
+ const started = await store.update(
166
+ activationKey(lookupKey),
167
+ (current): Store.Change<unknown, { status: 'started' } | { status: 'inFlight' }> => {
168
+ const marker = current as ActivationMarker | null
169
+ if (marker && !isStaleActivation(marker, activationTimeoutMs)) {
170
+ return { op: 'noop', result: { status: 'inFlight' as const } }
171
+ }
172
+ return {
173
+ op: 'set',
174
+ value: {
175
+ challengeId,
176
+ startedAt: timestamp(),
177
+ },
178
+ result: { status: 'started' as const },
179
+ }
180
+ },
181
+ )
182
+ if (started.status !== 'started') return { status: 'inFlight' }
183
+
184
+ const claimedExisting = await getByLookupKey(lookupKey)
185
+ if (claimedExisting && isReusable?.(claimedExisting)) {
186
+ await clearActivationState(lookupKey, challengeId)
187
+ return { status: 'existing', subscription: claimedExisting }
188
+ }
189
+
190
+ const result = await create().catch(async (error) => {
191
+ await clearActivationState(lookupKey, challengeId)
192
+ throw error
193
+ })
194
+ const { subscription } = result
195
+ const committed = await store.update(activationKey(subscription.lookupKey), (current) => {
196
+ const marker = current as ActivationMarker | null
197
+ if (marker?.challengeId !== challengeId) return { op: 'noop', result: false }
198
+ return {
199
+ op: 'set',
200
+ value: {
201
+ ...marker,
202
+ committingAt: timestamp(),
203
+ startedAt: timestamp(),
204
+ },
205
+ result: true,
206
+ }
207
+ })
208
+ if (!committed) {
209
+ await store.put(recordKey(subscription.subscriptionId), {
210
+ ...subscription,
211
+ canceledAt: subscription.canceledAt ?? timestamp(),
212
+ })
213
+ return { status: 'claimMismatch' }
214
+ }
215
+
216
+ const previous = await getByLookupKey(subscription.lookupKey)
217
+ if (previous && previous.subscriptionId !== subscription.subscriptionId) {
218
+ if (!(await ownsActivation(subscription.lookupKey, challengeId))) {
219
+ return { status: 'claimMismatch' }
220
+ }
221
+ await store.put(recordKey(previous.subscriptionId), {
222
+ ...previous,
223
+ canceledAt: previous.canceledAt ?? timestamp(),
224
+ })
225
+ }
226
+ if (!(await ownsActivation(subscription.lookupKey, challengeId))) {
227
+ return { status: 'claimMismatch' }
228
+ }
229
+ await store.put(recordKey(subscription.subscriptionId), subscription)
230
+ if (!(await ownsActivation(subscription.lookupKey, challengeId))) {
231
+ return { status: 'claimMismatch' }
232
+ }
233
+ await store.put(lookupRecordKey(subscription.lookupKey), subscription.subscriptionId)
234
+ await clearActivationState(subscription.lookupKey, challengeId)
235
+ return { status: 'activated', result }
236
+ },
237
+
238
+ async get(subscriptionId) {
239
+ return (await store.get(recordKey(subscriptionId))) as SubscriptionRecord | null
240
+ },
241
+
242
+ async getAccessKey(key) {
243
+ return (await store.get(accessKeyKey(key))) as SubscriptionAccessKeyRecord | null
244
+ },
245
+
246
+ async getByKey(key) {
247
+ return getByLookupKey(key)
248
+ },
249
+
250
+ async getOrCreateAccessKey(key) {
251
+ const existing = (await store.get(accessKeyKey(key))) as SubscriptionAccessKeyRecord | null
252
+ if (existing) return existing
253
+
254
+ const privateKey = Secp256k1.randomPrivateKey()
255
+ const account = TempoAccount.fromSecp256k1(privateKey)
256
+ const candidate = {
257
+ accessKeyAddress: account.address.toLowerCase() as `0x${string}`,
258
+ keyType: account.keyType,
259
+ privateKey,
260
+ } satisfies SubscriptionAccessKeyRecord
261
+ return store.update(
262
+ accessKeyKey(key),
263
+ (current): Store.Change<unknown, SubscriptionAccessKeyRecord> => {
264
+ if (current) {
265
+ return { op: 'noop', result: current as SubscriptionAccessKeyRecord }
266
+ }
267
+ return { op: 'set', value: candidate, result: candidate }
268
+ },
269
+ )
270
+ },
271
+
272
+ async put(record) {
273
+ await store.put(recordKey(record.subscriptionId), record)
274
+ await store.put(lookupRecordKey(record.lookupKey), record.subscriptionId)
275
+ },
276
+
277
+ async renew({ inFlightReference, periodIndex, renew, subscriptionId }) {
278
+ const attempt = createAttemptToken()
279
+ const started = await store.update(
280
+ recordKey(subscriptionId),
281
+ (
282
+ current,
283
+ ): Store.Change<
284
+ unknown,
285
+ | { status: 'started'; subscription: SubscriptionRecord }
286
+ | { status: 'charged'; subscription: SubscriptionRecord }
287
+ | { status: 'inFlight'; subscription: SubscriptionRecord }
288
+ | { status: 'missing' }
289
+ > => {
290
+ const subscription = current as SubscriptionRecord | null
291
+ if (!subscription) return { op: 'noop', result: { status: 'missing' as const } }
292
+ if (subscription.lastChargedPeriod >= periodIndex) {
293
+ return {
294
+ op: 'noop',
295
+ result: { status: 'charged' as const, subscription },
296
+ }
297
+ }
298
+ if (
299
+ subscription.inFlightPeriod !== undefined &&
300
+ !isStaleRenewal(subscription, renewalTimeoutMs)
301
+ ) {
302
+ return {
303
+ op: 'noop',
304
+ result: { status: 'inFlight' as const, subscription },
305
+ }
306
+ }
307
+
308
+ const next = {
309
+ ...subscription,
310
+ inFlightAttempt: attempt,
311
+ inFlightPeriod: periodIndex,
312
+ inFlightReference,
313
+ inFlightStartedAt: timestamp(),
314
+ }
315
+ return {
316
+ op: 'set',
317
+ value: next,
318
+ result: { status: 'started' as const, subscription: next },
319
+ }
320
+ },
321
+ )
322
+ if (started.status !== 'started') return started
323
+ const active = await getByLookupKey(started.subscription.lookupKey)
324
+ if (active?.subscriptionId !== subscriptionId) {
325
+ await clearRenewalState(subscriptionId, periodIndex, attempt)
326
+ return { status: 'superseded', subscription: started.subscription }
327
+ }
328
+
329
+ const result = await renew({
330
+ inFlightReference,
331
+ periodIndex,
332
+ subscription: started.subscription,
333
+ }).catch(async (error) => {
334
+ await clearRenewalState(subscriptionId, periodIndex, attempt)
335
+ throw error
336
+ })
337
+
338
+ const activeAfterRenew = await getByLookupKey(result.subscription.lookupKey)
339
+ if (activeAfterRenew?.subscriptionId !== subscriptionId) {
340
+ await clearRenewalState(subscriptionId, periodIndex, attempt)
341
+ return { status: 'superseded', subscription: started.subscription }
342
+ }
343
+
344
+ const committed = await store.update(recordKey(subscriptionId), (current) => {
345
+ const existing = current as SubscriptionRecord | null
346
+ if (
347
+ !existing ||
348
+ existing.inFlightPeriod !== periodIndex ||
349
+ existing.inFlightAttempt !== attempt
350
+ ) {
351
+ return { op: 'noop', result: false }
352
+ }
353
+
354
+ const terminal = {
355
+ ...(existing.canceledAt ? { canceledAt: existing.canceledAt } : {}),
356
+ ...(existing.revokedAt ? { revokedAt: existing.revokedAt } : {}),
357
+ }
358
+ return {
359
+ op: 'set',
360
+ value: clearRenewal({
361
+ ...result.subscription,
362
+ ...terminal,
363
+ lastChargedPeriod: periodIndex,
364
+ subscriptionId,
365
+ }),
366
+ result: true,
367
+ }
368
+ })
369
+ if (!committed) return { status: 'claimMismatch' }
370
+
371
+ const ownsLookup = await store.update(
372
+ lookupRecordKey(result.subscription.lookupKey),
373
+ (current) => {
374
+ if (current !== subscriptionId) return { op: 'noop', result: false }
375
+ return { op: 'set', value: subscriptionId, result: true }
376
+ },
377
+ )
378
+ if (!ownsLookup) return { status: 'superseded', subscription: started.subscription }
379
+ return { status: 'renewed', result }
380
+ },
381
+ }
382
+ }
383
+
384
+ export declare namespace fromStore {
385
+ type Options = {
386
+ /** Key prefix for server-owned subscription access keys. @default `'tempo:subscription:access-key:'` */
387
+ accessKeyPrefix?: string | undefined
388
+ /** Key prefix for resolved subscription activation locks. @default `'tempo:subscription:activation:'` */
389
+ activationPrefix?: string | undefined
390
+ /** Milliseconds before a stuck activation lock can be replaced. @default `900000` */
391
+ activationTimeoutMs?: number | undefined
392
+ /** Key prefix for single-use activation credential markers. @default `'tempo:subscription:credential:'` */
393
+ credentialPrefix?: string | undefined
394
+ /** Key prefix for subscription records. @default `'tempo:subscription:record:'` */
395
+ recordPrefix?: string | undefined
396
+ /** Milliseconds before a stuck renewal lock can be replaced. @default `900000` */
397
+ renewalTimeoutMs?: number | undefined
398
+ /** Key prefix for resolved request keys. @default `'tempo:subscription:key:'` */
399
+ keyPrefix?: string | undefined
400
+ }
401
+ }
402
+
403
+ function isStaleActivation(marker: { startedAt?: string | undefined }, timeoutMs: number) {
404
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) return false
405
+ if ('committingAt' in marker && marker.committingAt) return false
406
+ const startedAt = new Date(marker.startedAt ?? '').getTime()
407
+ if (!Number.isFinite(startedAt)) return true
408
+ return Date.now() - startedAt >= timeoutMs
409
+ }
410
+
411
+ function isStaleRenewal(subscription: SubscriptionRecord, timeoutMs: number) {
412
+ return isStaleActivation({ startedAt: subscription.inFlightStartedAt }, timeoutMs)
413
+ }
414
+
415
+ function clearRenewal(subscription: SubscriptionRecord): SubscriptionRecord {
416
+ return {
417
+ ...subscription,
418
+ inFlightAttempt: undefined,
419
+ inFlightPeriod: undefined,
420
+ inFlightReference: undefined,
421
+ inFlightStartedAt: undefined,
422
+ }
423
+ }
424
+
425
+ function timestamp() {
426
+ return new Date().toISOString()
427
+ }
428
+
429
+ function createAttemptToken() {
430
+ return globalThis.crypto.randomUUID()
431
+ }
@@ -0,0 +1,68 @@
1
+ import type { Address } from 'viem'
2
+
3
+ /** Tempo-supported subscription period units. The shared intent also defines `month`, but Tempo cannot represent calendar-month periods exactly. */
4
+ export type SubscriptionPeriodUnit = 'day' | 'week'
5
+
6
+ /** Access key information used to authorize recurring Tempo payments. */
7
+ export type SubscriptionAccessKey = {
8
+ accessKeyAddress: Address
9
+ keyType: 'p256' | 'secp256k1' | 'webAuthn'
10
+ }
11
+
12
+ /** Server-owned subscription access key persisted for automatic billing. */
13
+ export type SubscriptionAccessKeyRecord = SubscriptionAccessKey & {
14
+ privateKey: `0x${string}`
15
+ }
16
+
17
+ /** Request-scoped lookup key for the active subscription tied to a route. */
18
+ export type SubscriptionLookup = {
19
+ accessKey?: SubscriptionAccessKey | undefined
20
+ key: string
21
+ }
22
+
23
+ /** Persisted recurring Tempo subscription state. */
24
+ export type SubscriptionRecord = {
25
+ amount: string
26
+ billingAnchor: string
27
+ chainId?: number | undefined
28
+ currency: Address | string
29
+ externalId?: string | undefined
30
+ accessKey?: SubscriptionAccessKey | undefined
31
+ inFlightPeriod?: number | undefined
32
+ /** Per-attempt ownership token for the renewal currently in progress. */
33
+ inFlightAttempt?: string | undefined
34
+ /** Stable idempotency/reconciliation reference for a renewal currently in progress. */
35
+ inFlightReference?: string | undefined
36
+ inFlightStartedAt?: string | undefined
37
+ /** Signed key authorization used to activate the access key. */
38
+ keyAuthorization?: `0x${string}` | undefined
39
+ lastChargedPeriod: number
40
+ lookupKey: string
41
+ /** Root account that authorized the stored subscription access key. */
42
+ payer?: { address: Address; chainId: number } | undefined
43
+ periodCount: string
44
+ periodUnit: SubscriptionPeriodUnit
45
+ recipient: Address | string
46
+ reference: string
47
+ subscriptionExpires: string
48
+ subscriptionId: string
49
+ timestamp: string
50
+ canceledAt?: string | undefined
51
+ revokedAt?: string | undefined
52
+ }
53
+
54
+ /** Credential payload for a Tempo subscription activation. */
55
+ export type SubscriptionCredentialPayload = {
56
+ signature: `0x${string}`
57
+ type: 'keyAuthorization'
58
+ }
59
+
60
+ /** Receipt returned for a Tempo subscription activation or renewal. */
61
+ export type SubscriptionReceipt = {
62
+ method: 'tempo'
63
+ reference: string
64
+ status: 'success'
65
+ subscriptionId: string
66
+ timestamp: string
67
+ externalId?: string | undefined
68
+ }
@@ -0,0 +1,23 @@
1
+ export { createSubscriptionReceipt, fromRecord } from './Receipt.js'
2
+ export {
3
+ getSubscriptionRpcAllowedCalls,
4
+ getSubscriptionScopes,
5
+ signSubscriptionKeyAuthorization,
6
+ toSubscriptionExpiryDate,
7
+ toSubscriptionExpirySeconds,
8
+ toSubscriptionPeriodSeconds,
9
+ transferSelector,
10
+ transferWithMemoSelector,
11
+ verifySubscriptionKeyAuthorization,
12
+ } from './KeyAuthorization.js'
13
+ export { fromStore } from './Store.js'
14
+ export type { ActivateResult, RenewResult, SubscriptionStore } from './Store.js'
15
+ export type {
16
+ SubscriptionAccessKey,
17
+ SubscriptionAccessKeyRecord,
18
+ SubscriptionCredentialPayload,
19
+ SubscriptionLookup,
20
+ SubscriptionPeriodUnit,
21
+ SubscriptionRecord,
22
+ SubscriptionReceipt,
23
+ } from './Types.js'
package/src/zod.test.ts CHANGED
@@ -1,6 +1,16 @@
1
1
  import { describe, expect, test } from 'vp/test'
2
2
 
3
- import { address, amount, datetime, hash, period, signature, unwrapOptional, z } from './zod.js'
3
+ import {
4
+ address,
5
+ amount,
6
+ datetime,
7
+ datetimeInput,
8
+ hash,
9
+ period,
10
+ signature,
11
+ unwrapOptional,
12
+ z,
13
+ } from './zod.js'
4
14
 
5
15
  describe('amount', () => {
6
16
  test.each([
@@ -34,6 +44,18 @@ describe('datetime', () => {
34
44
  })
35
45
  })
36
46
 
47
+ describe('datetimeInput', () => {
48
+ test('accepts Date objects', () => {
49
+ const result = datetimeInput().parse(new Date('2025-01-06T12:00:00Z'))
50
+
51
+ expect(result.toISOString()).toBe('2025-01-06T12:00:00.000Z')
52
+ })
53
+
54
+ test('rejects invalid Date objects', () => {
55
+ expect(datetimeInput().safeParse(new Date(Number.NaN)).success).toBe(false)
56
+ })
57
+ })
58
+
37
59
  describe('address', () => {
38
60
  test.each([
39
61
  { input: '0x1234567890abcdef1234567890abcdef12345678', expected: true, desc: 'lowercase hex' },
package/src/zod.ts CHANGED
@@ -2,6 +2,8 @@ import { type ZodMiniOptional, type ZodMiniType, z } from 'zod/mini'
2
2
 
3
3
  export * from 'zod/mini'
4
4
 
5
+ export type DatetimeInput = string | Date
6
+
5
7
  /** Numeric string amount (e.g., "1", "1.5", "1000000"). */
6
8
  export function amount() {
7
9
  return z.string().check(z.regex(/^\d+(\.\d+)?$/, 'Invalid amount'))
@@ -19,6 +21,28 @@ export function datetime() {
19
21
  )
20
22
  }
21
23
 
24
+ /** ISO 8601 datetime string or Date object, transformed to a Date. */
25
+ export function datetimeInput(message = 'Invalid ISO 8601 datetime') {
26
+ return z
27
+ .pipe(
28
+ z.union([datetime(), z.custom<Date>((value) => value instanceof Date)]),
29
+ z.transform(toDate),
30
+ )
31
+ .check(z.refine((value) => Number.isFinite(value.getTime()), message))
32
+ }
33
+
34
+ /** Converts an ISO 8601 datetime string or Date object to a Date. */
35
+ export function toDate(value: DatetimeInput): Date {
36
+ return value instanceof Date ? value : new Date(value)
37
+ }
38
+
39
+ /** Serializes an ISO 8601 datetime string or Date object for wire output. */
40
+ export function toDatetimeString(value: DatetimeInput): string {
41
+ if (!(value instanceof Date)) return value
42
+ if (!Number.isFinite(value.getTime())) return 'Invalid Date'
43
+ return value.toISOString()
44
+ }
45
+
22
46
  /** Hex-encoded address string (0x-prefixed, 40 hex chars). */
23
47
  export function address() {
24
48
  return z.string().check(z.regex(/^0x[0-9a-fA-F]{40}$/, 'Invalid address'))