mppx 0.17.1 → 0.17.3
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 +28 -0
- package/dist/Store.d.ts +225 -0
- package/dist/Store.js +358 -0
- package/dist/cli/cli.js +95 -94
- package/dist/cli/plugins/index.js +12 -11
- package/dist/cli/sessions/Manager.js +12 -1
- package/dist/cli/utils.d.ts +2 -0
- package/dist/cli/utils.js +6 -0
- package/dist/client/Mppx.d.ts +1 -1
- package/dist/client/Mppx.js +7 -3
- package/dist/client/internal/Fetch.d.ts +1 -1
- package/dist/client/internal/Fetch.js +9 -0
- package/dist/client/internal/MethodResponse.d.ts +8 -0
- package/dist/client/internal/MethodResponse.js +9 -0
- package/dist/client/internal/ProofRealm.d.ts +3 -3
- package/dist/client/internal/ProofRealm.js +4 -4
- package/dist/client/internal/protocols/Mcp.d.ts +4 -0
- package/dist/client/internal/protocols/Mcp.js +27 -14
- package/dist/identity/Client.js +7 -1
- package/dist/internal/sse.d.ts +21 -0
- package/dist/internal/sse.js +65 -0
- package/dist/internal/version.d.ts +2 -2
- package/dist/internal/version.js +1 -1
- package/dist/mcp/client/McpClient.d.ts +2 -2
- package/dist/mcp/client/McpClient.js +5 -2
- package/dist/proxy/Proxy.d.ts +2 -0
- package/dist/proxy/Proxy.js +31 -6
- package/dist/proxy/internal/Replay.js +1 -1
- package/dist/server/Mppx.js +5 -4
- package/dist/server/Transport.js +1 -1
- package/dist/server/internal/html/config.d.ts +7 -1
- package/dist/server/internal/html/config.js +4 -0
- package/dist/stripe/internal/types.d.ts +82 -6
- package/dist/stripe/server/Charge.js +1 -2
- package/dist/stripe/server/internal/html.gen.js +1 -1
- package/dist/tempo/internal/fee-payer.d.ts +85 -10
- package/dist/tempo/internal/fee-payer.js +183 -19
- package/dist/tempo/legacy/session/Chain.js +2 -0
- package/dist/tempo/server/Charge.js +2 -2
- package/dist/tempo/server/Subscription.js +1 -0
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/server/internal/transport.d.ts +6 -0
- package/dist/tempo/server/internal/transport.js +21 -21
- package/dist/tempo/session/client/ChannelStore.d.ts +2 -0
- package/dist/tempo/session/client/CredentialState.js +2 -0
- package/dist/tempo/session/client/Session.d.ts +11 -1
- package/dist/tempo/session/client/Session.js +39 -6
- package/dist/tempo/session/client/SessionManager.d.ts +1 -1
- package/dist/tempo/session/client/SessionManager.js +8 -4
- package/dist/tempo/session/client/Transports.d.ts +2 -0
- package/dist/tempo/session/client/Transports.js +4 -1
- package/dist/tempo/session/precompile/Chain.js +1 -0
- package/dist/tempo/session/server/ChannelStore.d.ts +9 -0
- package/dist/tempo/session/server/ChannelStore.js +8 -1
- package/dist/tempo/session/server/MeteredStream.d.ts +4 -0
- package/dist/tempo/session/server/MeteredStream.js +6 -1
- package/dist/tempo/session/server/Sse.d.ts +59 -0
- package/dist/tempo/session/server/Sse.js +175 -10
- package/dist/zod.d.ts +2 -1
- package/dist/zod.js +1 -1
- package/package.json +1 -3
- package/dist/stripe/server/internal/html/types.d.ts +0 -2
- package/dist/stripe/server/internal/html/types.js +0 -2
|
@@ -4,11 +4,13 @@ import * as ChannelStore from '../../session/server/ChannelStore.js';
|
|
|
4
4
|
import type { SettleChargedSessionChannel } from '../../session/server/Settlement.js';
|
|
5
5
|
import * as Sse_core from '../../session/server/Sse.js';
|
|
6
6
|
declare const prepaidSessionTick: unique symbol;
|
|
7
|
+
declare const prepaidRefundKey: unique symbol;
|
|
7
8
|
/** SSE transport with Tempo session controller. */
|
|
8
9
|
export type Sse = Transport.Sse<Sse_core.SessionController>;
|
|
9
10
|
/** Receipt marker used to avoid double-charging a request already charged during verification. */
|
|
10
11
|
export type PrepaidSessionReceipt = SessionReceipt & {
|
|
11
12
|
[prepaidSessionTick]?: true | undefined;
|
|
13
|
+
[prepaidRefundKey]?: string | undefined;
|
|
12
14
|
};
|
|
13
15
|
/** Marks a session receipt as already charged for the current request. */
|
|
14
16
|
export declare function markPrepaidSessionTick(receipt: SessionReceipt): SessionReceipt;
|
|
@@ -42,6 +44,10 @@ export declare namespace sse {
|
|
|
42
44
|
* @default false
|
|
43
45
|
*/
|
|
44
46
|
poll?: boolean | undefined;
|
|
47
|
+
/** Opt in to SDK-owned prepaid credits; leave false with an existing merchant refund handler. */
|
|
48
|
+
autoCreditPrepaid?: boolean | undefined;
|
|
49
|
+
/** Merchant reconciliation hook for prepaid items that fail before delivery; see `Sse.serve.Options.onRefundRequired`. */
|
|
50
|
+
onRefundRequired?: Sse_core.serve.Options['onRefundRequired'];
|
|
45
51
|
/** Polling interval (in milliseconds). @default 10 */
|
|
46
52
|
pollingInterval?: number | undefined;
|
|
47
53
|
};
|
|
@@ -14,8 +14,15 @@ import * as ChannelStore from '../../session/server/ChannelStore.js';
|
|
|
14
14
|
import * as Sse_core from '../../session/server/Sse.js';
|
|
15
15
|
import { captureRequestBodyProbe, shouldChargePlainResponse } from './request-body.js';
|
|
16
16
|
const prepaidSessionTick = Symbol('mppx.prepaidSessionTick');
|
|
17
|
+
const consumedPrepaidReceipts = new WeakSet();
|
|
18
|
+
const prepaidRefundKey = Symbol('mppx.prepaidRefundKey');
|
|
17
19
|
/** Marks a session receipt as already charged for the current request. */
|
|
18
20
|
export function markPrepaidSessionTick(receipt) {
|
|
21
|
+
if (hasPrepaidSessionTick(receipt))
|
|
22
|
+
return receipt;
|
|
23
|
+
Object.defineProperty(receipt, prepaidRefundKey, {
|
|
24
|
+
value: `stream:${globalThis.crypto.randomUUID()}`,
|
|
25
|
+
});
|
|
19
26
|
Object.defineProperty(receipt, prepaidSessionTick, {
|
|
20
27
|
configurable: false,
|
|
21
28
|
enumerable: false,
|
|
@@ -65,6 +72,11 @@ export function sse(options) {
|
|
|
65
72
|
return base.respondChallenge(options);
|
|
66
73
|
},
|
|
67
74
|
async respondReceipt({ credential, envelope, receipt, response, challengeId, input }) {
|
|
75
|
+
if (hasPrepaidSessionTick(receipt)) {
|
|
76
|
+
if (consumedPrepaidReceipts.has(receipt))
|
|
77
|
+
throw new Error('Prepaid receipt already used; verify a new payment before retrying delivery');
|
|
78
|
+
consumedPrepaidReceipts.add(receipt);
|
|
79
|
+
}
|
|
68
80
|
const verifiedCredential = envelope?.credential ?? credential;
|
|
69
81
|
const verifiedChallengeId = envelope?.challenge.id ?? challengeId;
|
|
70
82
|
const verifiedRequest = envelope?.request ?? verifiedCredential.challenge.request;
|
|
@@ -86,20 +98,25 @@ export function sse(options) {
|
|
|
86
98
|
// Pass async generator functions directly so Sse.serve gives them
|
|
87
99
|
// a SessionController for manual charge(). Pass raw AsyncIterables
|
|
88
100
|
// as-is so Sse.serve auto-charges per yielded value.
|
|
89
|
-
const generate =
|
|
90
|
-
const
|
|
101
|
+
const generate = resolved;
|
|
102
|
+
const streamOptions = {
|
|
91
103
|
store,
|
|
92
104
|
channelId,
|
|
93
105
|
challengeId: verifiedChallengeId,
|
|
94
106
|
tickCost,
|
|
95
107
|
pollIntervalMs: pollingInterval,
|
|
96
108
|
generate,
|
|
109
|
+
chargeOnce: unitType === 'request',
|
|
97
110
|
beforeCharge: () => options.beforeCharge?.(channelId),
|
|
98
111
|
onChargeCommitted: options.settleCharged,
|
|
99
112
|
prepaidUnits: hasPrepaidSessionTick(receipt) ? 1 : 0,
|
|
113
|
+
refundIdempotencyKey: receipt[prepaidRefundKey],
|
|
114
|
+
onRefundRequired: options.onRefundRequired,
|
|
115
|
+
autoCreditPrepaid: options.autoCreditPrepaid,
|
|
100
116
|
signal: input.signal,
|
|
101
|
-
}
|
|
102
|
-
|
|
117
|
+
};
|
|
118
|
+
await Sse_core.preparePrepaidRefund(streamOptions);
|
|
119
|
+
return Sse_core.toResponse(Sse_core.serve(streamOptions));
|
|
103
120
|
}
|
|
104
121
|
const baseResponse = base.respondReceipt({
|
|
105
122
|
credential: verifiedCredential,
|
|
@@ -246,23 +263,6 @@ function isAsyncGeneratorFunction(value) {
|
|
|
246
263
|
function isAsyncIterable(value) {
|
|
247
264
|
return value !== null && typeof value === 'object' && Symbol.asyncIterator in value;
|
|
248
265
|
}
|
|
249
|
-
function resolveMeteredGenerate(value, unitType) {
|
|
250
|
-
if (isAsyncGeneratorFunction(value))
|
|
251
|
-
return value;
|
|
252
|
-
if (unitType !== 'request')
|
|
253
|
-
return value;
|
|
254
|
-
const iterable = value;
|
|
255
|
-
return async function* chargeOnce(stream) {
|
|
256
|
-
let charged = false;
|
|
257
|
-
for await (const chunk of iterable) {
|
|
258
|
-
if (!charged) {
|
|
259
|
-
await stream.charge();
|
|
260
|
-
charged = true;
|
|
261
|
-
}
|
|
262
|
-
yield chunk;
|
|
263
|
-
}
|
|
264
|
-
};
|
|
265
|
-
}
|
|
266
266
|
function isNullBodyStatus(status) {
|
|
267
267
|
return [101, 204, 205, 304].includes(status);
|
|
268
268
|
}
|
|
@@ -15,6 +15,8 @@ export type ChannelStore = {
|
|
|
15
15
|
};
|
|
16
16
|
/** Channel persistence and update notification for credential results. */
|
|
17
17
|
export type ChannelSink = {
|
|
18
|
+
/** Origin attached to newly created or recovered channels before persistence. */
|
|
19
|
+
origin?: string | undefined;
|
|
18
20
|
store: ChannelStore;
|
|
19
21
|
notifyUpdate: (entry: ChannelEntry) => void;
|
|
20
22
|
};
|
|
@@ -25,6 +25,8 @@ export function readCredentialCumulativeAmount(payload) {
|
|
|
25
25
|
* can react to the close.
|
|
26
26
|
*/
|
|
27
27
|
async function storeChannelEntry(sink, entry) {
|
|
28
|
+
if (sink.origin !== undefined)
|
|
29
|
+
entry = { ...entry, origin: sink.origin };
|
|
28
30
|
if (entry.opened)
|
|
29
31
|
await persistChannel(sink.store, entry);
|
|
30
32
|
else
|
|
@@ -132,7 +132,17 @@ export declare namespace session {
|
|
|
132
132
|
allowCustomEscrow?: boolean | undefined;
|
|
133
133
|
/** Automatically acquire the session currency from fallback stablecoins before open/top-up. */
|
|
134
134
|
autoSwap?: AutoSwap.resolve.Value | undefined;
|
|
135
|
-
/**
|
|
135
|
+
/**
|
|
136
|
+
* HTTP origin authorized for credential creation and server-snapshot recovery.
|
|
137
|
+
* Fetch supplies the request origin, but recovering an unknown channel from its snapshot
|
|
138
|
+
* requires this explicit origin pin or a caller-supplied descriptor.
|
|
139
|
+
*/
|
|
140
|
+
origin?: string | undefined;
|
|
141
|
+
/**
|
|
142
|
+
* Pluggable persistence for reusable channels. Defaults to an in-memory store.
|
|
143
|
+
* Use a separate store per HTTP origin: each payment scope holds one channel, and a
|
|
144
|
+
* channel already bound to another origin is rejected rather than overwritten or reused.
|
|
145
|
+
*/
|
|
136
146
|
channelStore?: ChannelStore | undefined;
|
|
137
147
|
/**
|
|
138
148
|
* Allowlist of TIP-20 currency addresses this client will pay sessions in, compared
|
|
@@ -147,6 +147,9 @@ export function session(parameters = {}) {
|
|
|
147
147
|
const getAccount = Account.getResolver({ account });
|
|
148
148
|
const maxDeposit = maxDepositParameter !== undefined ? parseUnits(maxDepositParameter, decimals) : undefined;
|
|
149
149
|
const topUpAmount = topUpAmountParameter !== undefined ? parseUnits(topUpAmountParameter, decimals) : undefined;
|
|
150
|
+
const configuredOrigin = parameters.origin === undefined ? undefined : new URL(parameters.origin);
|
|
151
|
+
if (configuredOrigin && !['http:', 'https:'].includes(configuredOrigin.protocol))
|
|
152
|
+
throw new Error('Session origins require HTTP or HTTPS');
|
|
150
153
|
const store = channelStore ?? createChannelStore();
|
|
151
154
|
const sink = { store, notifyUpdate: (entry) => onChannelUpdate?.(entry) };
|
|
152
155
|
const resolveCredentialAccount = async (resolved, context, entry) => {
|
|
@@ -194,6 +197,13 @@ export function session(parameters = {}) {
|
|
|
194
197
|
getClient,
|
|
195
198
|
});
|
|
196
199
|
const attempt = MethodResponse.getAttempt(parameters);
|
|
200
|
+
const origin = MethodResponse.getOrigin(parameters) ?? configuredOrigin?.origin;
|
|
201
|
+
if (origin !== undefined &&
|
|
202
|
+
(origin === 'null' || !['http:', 'https:'].includes(new URL(origin).protocol)))
|
|
203
|
+
throw new Error('Session payments require an HTTP or HTTPS origin');
|
|
204
|
+
if (configuredOrigin && origin !== configuredOrigin.origin)
|
|
205
|
+
throw new Error('Session channel origin mismatch');
|
|
206
|
+
const scopedSink = { ...sink, origin };
|
|
197
207
|
let release = () => { };
|
|
198
208
|
try {
|
|
199
209
|
const automatic = !hasSessionAction(context) && context?.descriptor === undefined;
|
|
@@ -203,10 +213,16 @@ export function session(parameters = {}) {
|
|
|
203
213
|
await attempt.prepare();
|
|
204
214
|
}
|
|
205
215
|
let entry = await store.get(resolved.key);
|
|
216
|
+
if (entry && entry.origin !== origin)
|
|
217
|
+
throw new Error('Session channel origin mismatch');
|
|
218
|
+
if (origin && !entry && resolved.snapshot && !context?.descriptor && !configuredOrigin)
|
|
219
|
+
throw new Error('Session snapshot recovery requires an explicitly configured origin or descriptor');
|
|
206
220
|
let plan = await resolveCredentialPlan(resolved, context, entry);
|
|
207
221
|
if (!automatic && attempt && plan.type === 'open') {
|
|
208
222
|
release = await lockChannel(store, resolved.key);
|
|
209
223
|
const nextEntry = await store.get(resolved.key);
|
|
224
|
+
if (nextEntry && nextEntry.origin !== origin)
|
|
225
|
+
throw new Error('Session channel origin mismatch');
|
|
210
226
|
if (nextEntry?.channelId !== entry?.channelId) {
|
|
211
227
|
entry = nextEntry;
|
|
212
228
|
if (entry?.opened) {
|
|
@@ -224,13 +240,13 @@ export function session(parameters = {}) {
|
|
|
224
240
|
store: {
|
|
225
241
|
get: (key) => store.get(key),
|
|
226
242
|
async set(next) {
|
|
227
|
-
pendingOpen = next;
|
|
243
|
+
pendingOpen = origin ? { ...next, origin } : next;
|
|
228
244
|
},
|
|
229
245
|
delete: (key) => store.delete(key),
|
|
230
246
|
},
|
|
231
247
|
notifyUpdate() { },
|
|
232
248
|
}
|
|
233
|
-
:
|
|
249
|
+
: scopedSink;
|
|
234
250
|
const payload = await executeCredentialPlan(plan, credentialSink, AutoSwap.resolve(context?.autoSwap ?? autoSwapParameter, AutoSwap.defaultCurrencies));
|
|
235
251
|
const credential = await serializeCredential(challenge, payload, resolved.chainId, plan.account);
|
|
236
252
|
if (attempt && pendingOpen) {
|
|
@@ -265,7 +281,12 @@ export function session(parameters = {}) {
|
|
|
265
281
|
}
|
|
266
282
|
},
|
|
267
283
|
});
|
|
268
|
-
|
|
284
|
+
function createSessionCredential(challenge, context, input) {
|
|
285
|
+
const parameters = { challenge, context };
|
|
286
|
+
MethodResponse.attachOrigin(parameters, resolveInputOrigin(input));
|
|
287
|
+
return method.createCredential(parameters);
|
|
288
|
+
}
|
|
289
|
+
const topUpChannelIfNeeded = async ({ autoSwap, challenge, channel, deposit, fetch, input, requiredCumulative, signal, }) => {
|
|
269
290
|
return withFundingLock(store, entryKey(channel), async () => {
|
|
270
291
|
signal?.throwIfAborted();
|
|
271
292
|
const fundingChannel = await store.get(entryKey(channel));
|
|
@@ -285,11 +306,12 @@ export function session(parameters = {}) {
|
|
|
285
306
|
});
|
|
286
307
|
if (additionalDeposit > 0n)
|
|
287
308
|
await postTopUp({
|
|
309
|
+
autoSwap,
|
|
288
310
|
additionalDeposit,
|
|
289
311
|
challenge,
|
|
290
312
|
channel,
|
|
291
313
|
channelId: channel.channelId,
|
|
292
|
-
createSessionCredential: (challenge, context) =>
|
|
314
|
+
createSessionCredential: (challenge, context) => createSessionCredential(challenge, context, input),
|
|
293
315
|
onUncertainFunding: async (entry) => {
|
|
294
316
|
const current = await store.get(entryKey(entry));
|
|
295
317
|
const unresolved = {
|
|
@@ -333,6 +355,9 @@ export function session(parameters = {}) {
|
|
|
333
355
|
const channel = await store.get(resolved.key);
|
|
334
356
|
if (!channel?.opened)
|
|
335
357
|
return;
|
|
358
|
+
const origin = resolveInputOrigin(input);
|
|
359
|
+
if (channel.origin !== origin)
|
|
360
|
+
throw new Error('Session channel origin mismatch');
|
|
336
361
|
const snapshot = resolved.snapshot?.channelId.toLowerCase() === channel.channelId.toLowerCase()
|
|
337
362
|
? resolved.snapshot
|
|
338
363
|
: undefined;
|
|
@@ -347,6 +372,7 @@ export function session(parameters = {}) {
|
|
|
347
372
|
if (!canSignDescriptor(account, channel.descriptor))
|
|
348
373
|
return;
|
|
349
374
|
await topUpChannelIfNeeded({
|
|
375
|
+
autoSwap: sessionContext?.autoSwap,
|
|
350
376
|
challenge,
|
|
351
377
|
channel,
|
|
352
378
|
deposit,
|
|
@@ -356,7 +382,7 @@ export function session(parameters = {}) {
|
|
|
356
382
|
signal,
|
|
357
383
|
});
|
|
358
384
|
});
|
|
359
|
-
return MethodResponse.register(method, async ({ challenge, credential, fetch, headers, input, refetch, response, signal }) => {
|
|
385
|
+
return MethodResponse.register(method, async ({ challenge, context, credential, fetch, headers, input, refetch, response, signal, }) => {
|
|
360
386
|
if (!isTip1034SessionChallenge(challenge))
|
|
361
387
|
return response;
|
|
362
388
|
if (!isEventStream(response)) {
|
|
@@ -376,13 +402,14 @@ export function session(parameters = {}) {
|
|
|
376
402
|
let channel = await store.get(channelKey);
|
|
377
403
|
const driver = {
|
|
378
404
|
assertVoucherWithinLocalLimit: (cumulativeAmount) => assertWithinMaxDeposit(cumulativeAmount, maxDeposit),
|
|
379
|
-
createSessionCredential: (challenge, context) =>
|
|
405
|
+
createSessionCredential: (challenge, context) => createSessionCredential(challenge, context, input),
|
|
380
406
|
fetch,
|
|
381
407
|
getChannel: () => channel ?? null,
|
|
382
408
|
async topUpIfNeeded({ deposit, requiredCumulative, signal: topUpSignal }) {
|
|
383
409
|
if (!channel)
|
|
384
410
|
return;
|
|
385
411
|
await topUpChannelIfNeeded({
|
|
412
|
+
autoSwap: context === undefined ? undefined : sessionContextSchema.parse(context).autoSwap,
|
|
386
413
|
challenge,
|
|
387
414
|
channel,
|
|
388
415
|
deposit: channel.deposit > deposit ? channel.deposit : deposit,
|
|
@@ -401,4 +428,10 @@ export function session(parameters = {}) {
|
|
|
401
428
|
});
|
|
402
429
|
});
|
|
403
430
|
}
|
|
431
|
+
function resolveInputOrigin(input) {
|
|
432
|
+
const url = new URL(input instanceof Request ? input.url : input, typeof globalThis.location === 'undefined' ? undefined : globalThis.location.href);
|
|
433
|
+
if (!['http:', 'https:'].includes(url.protocol))
|
|
434
|
+
throw new Error('Session payments require an HTTP or HTTPS origin');
|
|
435
|
+
return url.origin;
|
|
436
|
+
}
|
|
404
437
|
//# sourceMappingURL=Session.js.map
|
|
@@ -76,7 +76,7 @@ export declare namespace sessionManager {
|
|
|
76
76
|
/** Enables same-route HEAD bootstrap from a server session snapshot before opening a new channel. */
|
|
77
77
|
bootstrap?: boolean | undefined;
|
|
78
78
|
/**
|
|
79
|
-
* Server origins allowed to request bootstrap proofs for a realm other than their hostname,
|
|
79
|
+
* Server origins allowed to request bootstrap proofs or session payments for a realm other than their hostname,
|
|
80
80
|
* mapped to that realm, such as `{ 'https://api.example.com': 'MPP Payment' }`.
|
|
81
81
|
*/
|
|
82
82
|
trustedTempoProofRealms?: ProofRealm.TrustedRealms | undefined;
|
|
@@ -146,7 +146,7 @@ export function sessionManager(parameters) {
|
|
|
146
146
|
async function getReusable(key) {
|
|
147
147
|
const entry = await backing.get(key);
|
|
148
148
|
if (entry?.opened && !ignoredChannelIds.has(entry.channelId)) {
|
|
149
|
-
if (entry.origin !==
|
|
149
|
+
if (entry.origin !== sessionOrigin)
|
|
150
150
|
throw new Error('SessionManager: stored channel origin mismatch');
|
|
151
151
|
return entry;
|
|
152
152
|
}
|
|
@@ -290,6 +290,7 @@ export function sessionManager(parameters) {
|
|
|
290
290
|
resolveAccount: parameters.resolveAccount,
|
|
291
291
|
});
|
|
292
292
|
const wrappedFetch = Fetch.from({
|
|
293
|
+
trustedTempoProofRealms: parameters.trustedTempoProofRealms,
|
|
293
294
|
fetch: guardedFetch,
|
|
294
295
|
identity: inheritedIdentity,
|
|
295
296
|
methods: [method],
|
|
@@ -316,10 +317,13 @@ export function sessionManager(parameters) {
|
|
|
316
317
|
},
|
|
317
318
|
});
|
|
318
319
|
function createSessionCredential(challenge, context) {
|
|
319
|
-
|
|
320
|
+
const parameters = {
|
|
320
321
|
challenge,
|
|
321
322
|
context: mergeSessionCredentialContext(credentialContext, context),
|
|
322
|
-
}
|
|
323
|
+
};
|
|
324
|
+
if (sessionOrigin)
|
|
325
|
+
MethodResponse.attachOrigin(parameters, sessionOrigin);
|
|
326
|
+
return method.createCredential(parameters);
|
|
323
327
|
}
|
|
324
328
|
function updateSpentFromReceipt(receipt) {
|
|
325
329
|
applySessionReceiptToRuntime({
|
|
@@ -954,7 +958,7 @@ export function sessionManager(parameters) {
|
|
|
954
958
|
},
|
|
955
959
|
rehydrate({ channel, challenge, input, spent }) {
|
|
956
960
|
const origin = bindOrigin(input);
|
|
957
|
-
if (channel.origin !==
|
|
961
|
+
if (channel.origin !== origin)
|
|
958
962
|
throw new Error('SessionManager: stored channel origin mismatch');
|
|
959
963
|
if (!channel.opened)
|
|
960
964
|
throw new Error('Cannot restore a closed session channel.');
|
|
@@ -201,6 +201,8 @@ export type TempoSessionChallenge = Challenge.Challenge<z.output<typeof Methods.
|
|
|
201
201
|
export type CreateSessionCredential = (challenge: TempoSessionChallenge, context: SessionContext) => Promise<string>;
|
|
202
202
|
/** Inputs for posting a precompile session top-up credential. */
|
|
203
203
|
export type PostTopUpParameters = {
|
|
204
|
+
/** Request-local swap policy; undefined inherits the method default. */
|
|
205
|
+
autoSwap?: SessionContext['autoSwap'] | undefined;
|
|
204
206
|
/** Additional deposit in raw token units. */
|
|
205
207
|
additionalDeposit: bigint;
|
|
206
208
|
/** Challenge used to authorize the management request. */
|
|
@@ -331,6 +331,7 @@ export async function postTopUp(parameters) {
|
|
|
331
331
|
challenge: parameters.challenge,
|
|
332
332
|
context: {
|
|
333
333
|
action: 'topUp',
|
|
334
|
+
...(parameters.autoSwap === undefined ? {} : { autoSwap: parameters.autoSwap }),
|
|
334
335
|
channelId,
|
|
335
336
|
descriptor: channel.descriptor,
|
|
336
337
|
additionalDepositRaw: parameters.additionalDeposit.toString(),
|
|
@@ -526,7 +527,9 @@ export async function closeHttpSession(parameters) {
|
|
|
526
527
|
challengeId: closeChallenge.id,
|
|
527
528
|
channelId: parameters.target.channelId,
|
|
528
529
|
expectedCloseAmount: signedCloseAmount,
|
|
529
|
-
|
|
530
|
+
// HTTP credits can reduce previously acknowledged spend. Authorization and receipt scope
|
|
531
|
+
// remain bound to the exact close credential; a lower final charge benefits the payer.
|
|
532
|
+
minimumSpent: 0n,
|
|
530
533
|
receipt,
|
|
531
534
|
}))
|
|
532
535
|
throw new Error('Session close response included a mismatched payment receipt.');
|
|
@@ -451,6 +451,7 @@ export async function sendCredentialTransaction(parameters) {
|
|
|
451
451
|
...(allowedFeeTokens?.[0] ? { feeToken: transaction.feeToken ?? allowedFeeTokens[0] } : {}),
|
|
452
452
|
};
|
|
453
453
|
const completed = await FeePayer.preflightSponsorship({
|
|
454
|
+
policy: feePayerPolicy,
|
|
454
455
|
transaction: sponsorshipTransaction,
|
|
455
456
|
simulate: (request) => simulateTempoTransaction(client, request),
|
|
456
457
|
async complete() {
|
|
@@ -292,6 +292,13 @@ export interface BaseState {
|
|
|
292
292
|
pendingSettlement?: PendingSettlement | undefined;
|
|
293
293
|
/** Outcomes of keyed charges and credits, by `charge:<key>` and `credit:<key>`, until they expire. */
|
|
294
294
|
idempotencyMarkers?: Record<string, IdempotencyMarker> | undefined;
|
|
295
|
+
/** Durable prepaid delivery obligations. Pending entries require delivery reconciliation, never blind refunds. */
|
|
296
|
+
prepaidRefunds?: Record<string, {
|
|
297
|
+
amount: string;
|
|
298
|
+
units: number;
|
|
299
|
+
status: 'pending' | 'failed';
|
|
300
|
+
createdAt: number;
|
|
301
|
+
}> | undefined;
|
|
295
302
|
/** First block at which the close supervisor read this channel open on-chain. */
|
|
296
303
|
closeSupervisedFromBlock?: bigint | undefined;
|
|
297
304
|
}
|
|
@@ -500,6 +507,8 @@ export type ChargeOptions = {
|
|
|
500
507
|
};
|
|
501
508
|
/** Options for returning an amount to a channel at most once per key. */
|
|
502
509
|
export type CreditOptions = {
|
|
510
|
+
/** Maximum live idempotency markers; existing keys remain replayable at capacity. */
|
|
511
|
+
maxMarkers?: number | undefined;
|
|
503
512
|
/**
|
|
504
513
|
* Key of the credit. Its first outcome, credited or declined, is final: a retry with the same key
|
|
505
514
|
* and terms returns it without crediting again, and the same key with other terms throws
|
|
@@ -684,17 +684,22 @@ export async function creditChannel(store, channelId, amount, options) {
|
|
|
684
684
|
throw new Error('Credit amount must be positive.');
|
|
685
685
|
if (!Number.isSafeInteger(units) || units < 0)
|
|
686
686
|
throw new Error('`units` must be a non-negative integer.');
|
|
687
|
+
const maxMarkers = options.maxMarkers ?? 1024;
|
|
688
|
+
if (!Number.isSafeInteger(maxMarkers) || maxMarkers < 1)
|
|
689
|
+
throw new Error('maxMarkers must be a positive safe integer');
|
|
687
690
|
const now = Date.now();
|
|
688
691
|
const expiresAt = now + (options.retention ?? defaultIdempotencyRetention);
|
|
689
692
|
let result;
|
|
690
693
|
try {
|
|
691
|
-
result = await applyChange(store, channelId, (current) => planCredit(current, { amount, expiresAt, idempotencyKey, now, units }));
|
|
694
|
+
result = await applyChange(store, channelId, (current) => planCredit(current, { amount, expiresAt, idempotencyKey, now, units, maxMarkers }));
|
|
692
695
|
}
|
|
693
696
|
catch (error) {
|
|
694
697
|
return { status: 'unknown', error };
|
|
695
698
|
}
|
|
696
699
|
if (!result)
|
|
697
700
|
throw new ChannelNotFoundError({ reason: 'channel not found' });
|
|
701
|
+
if (result === 'capacity')
|
|
702
|
+
throw new Error('Credit idempotency marker capacity reached; reconcile manually');
|
|
698
703
|
if (result === 'conflict')
|
|
699
704
|
throw new IdempotencyKeyConflictError({ idempotencyKey, operation: 'credit' });
|
|
700
705
|
return result;
|
|
@@ -718,6 +723,8 @@ function planCredit(current, terms) {
|
|
|
718
723
|
: { status: 'declined', reason: recorded.outcome, channel: current, replayed: true },
|
|
719
724
|
};
|
|
720
725
|
}
|
|
726
|
+
if (Object.keys(markers).length >= terms.maxMarkers)
|
|
727
|
+
return { op: 'noop', result: 'capacity' };
|
|
721
728
|
const reason = creditDeclineReason(current, amount, now);
|
|
722
729
|
const marker = {
|
|
723
730
|
amount,
|
|
@@ -33,6 +33,10 @@ export type MeteredStreamOptions = {
|
|
|
33
33
|
generate: SessionStreamGenerator;
|
|
34
34
|
/** Verifies that the channel can still fund work and delivery; failures stop the stream. */
|
|
35
35
|
beforeCharge?: (() => MaybePromise<void>) | undefined;
|
|
36
|
+
/** Charges automatic streams once per request instead of once per value. */
|
|
37
|
+
chargeOnce?: boolean | undefined;
|
|
38
|
+
/** Runs when an explicit manual charge consumes a prepaid unit. */
|
|
39
|
+
onPrepaidChargeCommitted?: (() => void) | undefined;
|
|
36
40
|
/** Runs after a nonzero reserved charge has been committed. */
|
|
37
41
|
onChargeCommitted?: ((channel: ChannelStore.State) => MaybePromise<unknown>) | undefined;
|
|
38
42
|
/** Store polling interval when `waitForUpdate` is unavailable. */
|
|
@@ -9,6 +9,7 @@ export async function* meterIterable(options) {
|
|
|
9
9
|
channelId: options.channelId,
|
|
10
10
|
reservationId: globalThis.crypto.randomUUID(),
|
|
11
11
|
};
|
|
12
|
+
let charged = false;
|
|
12
13
|
let pending = false;
|
|
13
14
|
const releaseOnAbort = () => {
|
|
14
15
|
void releaseReservedCharges(reservation).catch(() => { });
|
|
@@ -22,6 +23,8 @@ export async function* meterIterable(options) {
|
|
|
22
23
|
signal.throwIfAborted();
|
|
23
24
|
if (amount === undefined && prepaidUnits > 0) {
|
|
24
25
|
prepaidUnits -= 1;
|
|
26
|
+
if (manual)
|
|
27
|
+
options.onPrepaidChargeCommitted?.();
|
|
25
28
|
return;
|
|
26
29
|
}
|
|
27
30
|
if ((amount ?? options.tickCost) === 0n)
|
|
@@ -67,8 +70,10 @@ export async function* meterIterable(options) {
|
|
|
67
70
|
await commit();
|
|
68
71
|
break;
|
|
69
72
|
}
|
|
70
|
-
if (!manual)
|
|
73
|
+
if (!manual && (!options.chargeOnce || !charged)) {
|
|
71
74
|
await charge();
|
|
75
|
+
charged = true;
|
|
76
|
+
}
|
|
72
77
|
await commit();
|
|
73
78
|
yield value;
|
|
74
79
|
}
|
|
@@ -1,6 +1,41 @@
|
|
|
1
1
|
import type { Hex } from 'viem';
|
|
2
2
|
import * as ChannelStore from './ChannelStore.js';
|
|
3
3
|
import { type MeteredStreamOptions, type SessionController } from './MeteredStream.js';
|
|
4
|
+
/** A failed prepaid delivery that needs merchant reconciliation. */
|
|
5
|
+
export type PrepaidRefund = {
|
|
6
|
+
/** Channel that paid for the undelivered items. */
|
|
7
|
+
channelId: Hex;
|
|
8
|
+
/** Undelivered prepaid amount in raw token units; use externalRefundAmount for transfers. */
|
|
9
|
+
amount: bigint;
|
|
10
|
+
/** Captured share safe to refund externally, or null until reconciliation establishes it. */
|
|
11
|
+
externalRefundAmount: bigint | null;
|
|
12
|
+
/** Charge units included in the credit attempt. */
|
|
13
|
+
units: number;
|
|
14
|
+
/** Use this key for both credit retries and an idempotent merchant refund. */
|
|
15
|
+
idempotencyKey: string;
|
|
16
|
+
/** An unknown credit must be reconciled with the same key before refunding externally. */
|
|
17
|
+
credit: {
|
|
18
|
+
status: 'failed';
|
|
19
|
+
error: unknown;
|
|
20
|
+
} | Exclude<ChannelStore.CreditResult, {
|
|
21
|
+
status: 'credited';
|
|
22
|
+
}>;
|
|
23
|
+
};
|
|
24
|
+
/** Exposes durable retry information when no refund hook is configured or it fails. */
|
|
25
|
+
export declare class PrepaidRefundError extends Error {
|
|
26
|
+
readonly refund: PrepaidRefund;
|
|
27
|
+
readonly name = "PrepaidRefundError";
|
|
28
|
+
constructor(refund: PrepaidRefund);
|
|
29
|
+
}
|
|
30
|
+
/** Maximum retained automatic refund operations per channel. Never evict live retry keys. */
|
|
31
|
+
export declare const maxPrepaidRefunds = 128;
|
|
32
|
+
/**
|
|
33
|
+
* Persist a delivery obligation before returning a managed response. Failed hook deliveries remain
|
|
34
|
+
* in `channel.prepaidRefunds` for a merchant worker. Pending entries require manual delivery
|
|
35
|
+
* reconciliation; failed entries may retry the credit with their original terms within 24 hours.
|
|
36
|
+
* Acknowledging the hook means the merchant has durably accepted responsibility for the job.
|
|
37
|
+
*/
|
|
38
|
+
export declare function preparePrepaidRefund(options: serve.Options): Promise<void>;
|
|
4
39
|
/**
|
|
5
40
|
* SSE (Server-Sent Events) utilities for metered streaming payments.
|
|
6
41
|
*
|
|
@@ -31,6 +66,9 @@ export type { SessionController } from './MeteredStream.js';
|
|
|
31
66
|
* `stream.charge()` resolves, retaining them on cancellation or failure.
|
|
32
67
|
* 5. On generator completion, emits a final `event: payment-receipt`.
|
|
33
68
|
*
|
|
69
|
+
* With `autoCreditPrepaid`, a failure before a prepaid item is enqueued attempts an idempotent credit.
|
|
70
|
+
* If credit is declined or uncertain, `onRefundRequired` handles merchant reconciliation.
|
|
71
|
+
*
|
|
34
72
|
* Returns a `ReadableStream<Uint8Array>` suitable for use as an HTTP response body.
|
|
35
73
|
*/
|
|
36
74
|
export declare function serve(options: serve.Options): ReadableStream<Uint8Array>;
|
|
@@ -42,12 +80,33 @@ export declare namespace serve {
|
|
|
42
80
|
challengeId: string;
|
|
43
81
|
tickCost: bigint;
|
|
44
82
|
generate: AsyncIterable<string> | ((stream: SessionController) => AsyncIterable<string>);
|
|
83
|
+
/** Charge automatic iterables once per request; explicit manual charges are unaffected. */
|
|
84
|
+
chargeOnce?: boolean | undefined;
|
|
45
85
|
/** Verifies live escrow before charging and delivering stream items. */
|
|
46
86
|
beforeCharge?: MeteredStreamOptions['beforeCharge'];
|
|
47
87
|
/** Callback invoked after each nonzero stream charge is committed. */
|
|
48
88
|
onChargeCommitted?: MeteredStreamOptions['onChargeCommitted'];
|
|
49
89
|
pollIntervalMs?: number | undefined;
|
|
90
|
+
/** Opt in to SDK-owned credits. Leave disabled when the merchant already refunds failed requests. @default false */
|
|
91
|
+
autoCreditPrepaid?: boolean | undefined;
|
|
92
|
+
/** Units charged before streaming. */
|
|
50
93
|
prepaidUnits?: number | undefined;
|
|
94
|
+
/** Stable per-charge key used for automatic credit and merchant refund retries. Defaults to a random stream key. */
|
|
95
|
+
refundIdempotencyKey?: string | undefined;
|
|
96
|
+
/**
|
|
97
|
+
* Persist or perform an idempotent refund when a failed prepaid delivery cannot be credited.
|
|
98
|
+
* For `credit.status === 'unknown'`, retry `Session.Server.credit` with the supplied key,
|
|
99
|
+
* amount and units before refunding externally; the credit may already have committed.
|
|
100
|
+
* Retry within the credit marker retention (24 hours); later retries need manual reconciliation.
|
|
101
|
+
* Only transfer `externalRefundAmount` when non-null; closing, pending settlement and definite
|
|
102
|
+
* failures require reconciliation first. `failed` is a definite non-credit, not an uncertain write.
|
|
103
|
+
* Successful return must mean durable acceptance. Missing/throwing hooks leave obligations in
|
|
104
|
+
* `channel.prepaidRefunds`; run a recovery worker and reconcile pending delivery records manually.
|
|
105
|
+
* Records are bounded and never evicted while unresolved. A full ledger rejects new streams.
|
|
106
|
+
* Hook failures surface a `PrepaidRefundError` containing the retry information.
|
|
107
|
+
* Delivered items, explicit manual charges and client cancellation are not automatically refunded.
|
|
108
|
+
*/
|
|
109
|
+
onRefundRequired?: ((refund: PrepaidRefund) => void | Promise<void>) | undefined;
|
|
51
110
|
signal?: AbortSignal | undefined;
|
|
52
111
|
};
|
|
53
112
|
}
|