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.
- package/CHANGELOG.md +7 -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/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/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/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/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,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 {
|
|
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'))
|