mppx 0.17.0 → 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 +45 -0
- package/dist/BodyDigest.js +13 -1
- package/dist/Method.d.ts +5 -0
- package/dist/Store.d.ts +225 -0
- package/dist/Store.js +358 -0
- package/dist/cli/cli.js +136 -135
- package/dist/cli/plugins/index.js +12 -11
- package/dist/cli/sessions/Manager.js +12 -1
- package/dist/cli/sessions/store.js +7 -1
- package/dist/cli/utils.d.ts +2 -0
- package/dist/cli/utils.js +6 -0
- package/dist/cli/validate/helpers.d.ts +1 -1
- package/dist/cli/validate/helpers.js +14 -2
- package/dist/client/Mppx.d.ts +1 -1
- package/dist/client/Mppx.js +18 -6
- package/dist/client/internal/Fetch.d.ts +3 -1
- package/dist/client/internal/Fetch.js +68 -3
- 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/AcceptPayment.d.ts +12 -2
- package/dist/internal/AcceptPayment.js +12 -3
- 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 +6 -3
- package/dist/proxy/Proxy.d.ts +17 -0
- package/dist/proxy/Proxy.js +116 -3
- package/dist/proxy/internal/Replay.d.ts +105 -0
- package/dist/proxy/internal/Replay.js +257 -0
- package/dist/server/Mppx.d.ts +2 -0
- package/dist/server/Mppx.js +7 -5
- 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/client/Charge.d.ts +9 -0
- package/dist/tempo/client/Charge.js +13 -9
- package/dist/tempo/client/Methods.d.ts +2 -0
- 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 +6 -3
- 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/ChannelOps.d.ts +2 -0
- package/dist/tempo/session/client/ChannelStore.d.ts +2 -0
- package/dist/tempo/session/client/ChannelStore.js +5 -0
- package/dist/tempo/session/client/CredentialState.js +2 -0
- package/dist/tempo/session/client/Session.d.ts +37 -2
- package/dist/tempo/session/client/Session.js +111 -7
- package/dist/tempo/session/client/SessionManager.d.ts +2 -1
- package/dist/tempo/session/client/SessionManager.js +60 -9
- package/dist/tempo/session/client/Transports.d.ts +4 -0
- package/dist/tempo/session/client/Transports.js +35 -3
- package/dist/tempo/session/precompile/Chain.js +1 -0
- package/dist/tempo/session/precompile/Voucher.js +20 -7
- 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/Session.d.ts +2 -1
- package/dist/tempo/session/server/Session.js +2 -1
- package/dist/tempo/session/server/Settlement.d.ts +4 -2
- package/dist/tempo/session/server/Settlement.js +2 -2
- package/dist/tempo/session/server/Sse.d.ts +59 -0
- package/dist/tempo/session/server/Sse.js +175 -10
- package/dist/tempo/session/server/Ws.js +29 -12
- 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
|
@@ -262,10 +262,20 @@ export function requestInitWithAuthorization(input, init, credential, challenge)
|
|
|
262
262
|
}, Challenge.credentialHeader(challenge), credential);
|
|
263
263
|
}
|
|
264
264
|
/** Returns the exact resource URL used for out-of-band management POSTs, without its fragment. */
|
|
265
|
+
/** Returns whether a value is a `Request`, including one from another realm such as an iframe. */
|
|
266
|
+
export function isRequest(value) {
|
|
267
|
+
return (value instanceof Request ||
|
|
268
|
+
(typeof value === 'object' &&
|
|
269
|
+
value !== null &&
|
|
270
|
+
!(value instanceof URL) &&
|
|
271
|
+
typeof value.url === 'string' &&
|
|
272
|
+
typeof value.method === 'string' &&
|
|
273
|
+
typeof value.clone === 'function'));
|
|
274
|
+
}
|
|
265
275
|
export function managementInput(input) {
|
|
266
276
|
try {
|
|
267
277
|
const base = typeof location === 'undefined' ? undefined : location.href;
|
|
268
|
-
const url = input
|
|
278
|
+
const url = isRequest(input)
|
|
269
279
|
? new URL(input.url)
|
|
270
280
|
: input instanceof URL
|
|
271
281
|
? new URL(input)
|
|
@@ -321,6 +331,7 @@ export async function postTopUp(parameters) {
|
|
|
321
331
|
challenge: parameters.challenge,
|
|
322
332
|
context: {
|
|
323
333
|
action: 'topUp',
|
|
334
|
+
...(parameters.autoSwap === undefined ? {} : { autoSwap: parameters.autoSwap }),
|
|
324
335
|
channelId,
|
|
325
336
|
descriptor: channel.descriptor,
|
|
326
337
|
additionalDepositRaw: parameters.additionalDeposit.toString(),
|
|
@@ -456,6 +467,21 @@ export async function closeHttpSession(parameters) {
|
|
|
456
467
|
}
|
|
457
468
|
let closeChallenge = parameters.target.challenge;
|
|
458
469
|
let signedCloseAmount = parameters.signedCloseAmount;
|
|
470
|
+
const request = isRequest(parameters.lastUrl) ? parameters.lastUrl : undefined;
|
|
471
|
+
// Retain transport policy without inheriting the resource body or cancellation. Redirects stay
|
|
472
|
+
// manual so the credential never follows one.
|
|
473
|
+
const requestOptions = request
|
|
474
|
+
? {
|
|
475
|
+
cache: request.cache,
|
|
476
|
+
credentials: request.credentials,
|
|
477
|
+
integrity: request.integrity,
|
|
478
|
+
keepalive: request.keepalive,
|
|
479
|
+
// Fetch rejects `navigate` in a request init; a navigation becomes `same-origin`, as Fetch does.
|
|
480
|
+
mode: request.mode === 'navigate' ? 'same-origin' : request.mode,
|
|
481
|
+
referrer: request.referrer,
|
|
482
|
+
referrerPolicy: request.referrerPolicy,
|
|
483
|
+
}
|
|
484
|
+
: undefined;
|
|
459
485
|
const postClose = async (challenge, refreshed = false) => {
|
|
460
486
|
closeChallenge = challenge;
|
|
461
487
|
signedCloseAmount =
|
|
@@ -467,7 +493,11 @@ export async function closeHttpSession(parameters) {
|
|
|
467
493
|
descriptor: parameters.target.channel.descriptor,
|
|
468
494
|
cumulativeAmountRaw: signedCloseAmount,
|
|
469
495
|
});
|
|
470
|
-
|
|
496
|
+
// Close is a separate management POST, independent of the resource Request.
|
|
497
|
+
// A Request's URL is absolute. Pass other inputs unchanged so the configured fetch resolves
|
|
498
|
+
// relative URLs exactly as it did for the paid resource.
|
|
499
|
+
return parameters.fetch(request ? managementInput(request) : parameters.lastUrl, {
|
|
500
|
+
...requestOptions,
|
|
471
501
|
method: 'POST',
|
|
472
502
|
redirect: 'manual',
|
|
473
503
|
headers: managementHeaders(challenge, credential, parameters.lastUrl, parameters.headers),
|
|
@@ -497,7 +527,9 @@ export async function closeHttpSession(parameters) {
|
|
|
497
527
|
challengeId: closeChallenge.id,
|
|
498
528
|
channelId: parameters.target.channelId,
|
|
499
529
|
expectedCloseAmount: signedCloseAmount,
|
|
500
|
-
|
|
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,
|
|
501
533
|
receipt,
|
|
502
534
|
}))
|
|
503
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() {
|
|
@@ -67,6 +67,10 @@ function getVoucherPayload(verifyingContract, chainId, voucher) {
|
|
|
67
67
|
function isPrimitiveEnvelope(type) {
|
|
68
68
|
return type === 'secp256k1' || type === 'p256' || type === 'webAuthn';
|
|
69
69
|
}
|
|
70
|
+
/** Detects a viem/tempo access key account the same way `Account.signVoucher` does. */
|
|
71
|
+
function isAccessKeyAccount(account) {
|
|
72
|
+
return account.source === 'accessKey' && 'accessKeyAddress' in account;
|
|
73
|
+
}
|
|
70
74
|
function signCanonicalTempoVoucher(account, parameters) {
|
|
71
75
|
// viem/tempo's canonical TIP-1034 voucher signer accepts Tempo account
|
|
72
76
|
// extensions that are wider than viem's base Account type. Keep that
|
|
@@ -109,13 +113,22 @@ export async function signVoucher(client, account, voucher, verifyingContract, c
|
|
|
109
113
|
* Signs a distinct EIP-712 authorization for cooperatively closing a channel.
|
|
110
114
|
*/
|
|
111
115
|
export async function signCloseAuthorization(client, account, authorization, verifyingContract, chainId) {
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
116
|
+
// `signTypedData` wraps access key signatures in a keychain envelope, which
|
|
117
|
+
// TIP-1034 rejects. Sign the hash raw instead, as viem/tempo's
|
|
118
|
+
// `Account.signVoucher` does for vouchers: the access key is the channel's
|
|
119
|
+
// authorized signer.
|
|
120
|
+
const signature = isAccessKeyAccount(account)
|
|
121
|
+
? await account.sign({
|
|
122
|
+
hash: getCloseAuthorizationPayload(verifyingContract, chainId, authorization),
|
|
123
|
+
raw: true,
|
|
124
|
+
})
|
|
125
|
+
: await signTypedData(client, {
|
|
126
|
+
account,
|
|
127
|
+
domain: getVoucherDomain(verifyingContract, chainId),
|
|
128
|
+
types: closeAuthorizationTypes,
|
|
129
|
+
primaryType: 'CloseAuthorization',
|
|
130
|
+
message: authorization,
|
|
131
|
+
});
|
|
119
132
|
const envelope = SignatureEnvelope.from(signature);
|
|
120
133
|
if (!isPrimitiveEnvelope(envelope.type))
|
|
121
134
|
throw new Error(`TIP-1034 close authorizations require a TIP-1020 primitive signature; received "${envelope.type}".`);
|
|
@@ -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
|
}
|
|
@@ -184,7 +184,8 @@ export declare namespace session {
|
|
|
184
184
|
/**
|
|
185
185
|
* Charges request-priced content (`unitType: 'request'`) in the same store update that accepts
|
|
186
186
|
* its voucher, before the response, instead of in a second update when the response is
|
|
187
|
-
* delivered.
|
|
187
|
+
* delivered. Always enabled for SSE request-priced content, including POST requests that
|
|
188
|
+
* return plain responses. The server must refund requests it cannot serve.
|
|
188
189
|
*/
|
|
189
190
|
chargeOnAcceptance?: boolean | undefined;
|
|
190
191
|
/**
|
|
@@ -363,7 +363,7 @@ export function session(p) {
|
|
|
363
363
|
minVoucherDelta: parameters.minVoucherDelta,
|
|
364
364
|
request,
|
|
365
365
|
});
|
|
366
|
-
const chargeAmount = parameters.chargeOnAcceptance &&
|
|
366
|
+
const chargeAmount = (parameters.chargeOnAcceptance || parameters.sse) &&
|
|
367
367
|
shouldChargeVoucherOnAcceptance({
|
|
368
368
|
capturedRequest: envelope?.capturedRequest,
|
|
369
369
|
payloadAction: payload.action,
|
|
@@ -433,6 +433,7 @@ export function session(p) {
|
|
|
433
433
|
getRequestAmount: () => BigInt(context.request.amount ?? challenge.request.amount),
|
|
434
434
|
sseEnabled: Boolean(parameters.sse),
|
|
435
435
|
markPrepaidReceipt: Transport.markPrepaidSessionTick,
|
|
436
|
+
unitType: context.request.unitType,
|
|
436
437
|
charge: (channelId, requestAmount) => chargeSessionChannel({ store, channelId, amount: requestAmount }),
|
|
437
438
|
settleCharged: settleAcceptedCharge,
|
|
438
439
|
});
|
|
@@ -167,13 +167,15 @@ export type ApplyVerifiedHttpAccountingParameters = {
|
|
|
167
167
|
receipt: SessionReceipt;
|
|
168
168
|
/** Marks an SSE receipt whose first content unit was charged during verification. */
|
|
169
169
|
markPrepaidReceipt?: ((receipt: SessionReceipt) => SessionReceipt) | undefined;
|
|
170
|
-
/**
|
|
170
|
+
/** Pricing unit; request-priced content is charged before handing control to the application. */
|
|
171
|
+
unitType?: unknown;
|
|
172
|
+
/** Whether SSE transport is enabled. Non-request units are charged as the stream advances. */
|
|
171
173
|
sseEnabled: boolean;
|
|
172
174
|
/** Runs optional server settlement policy after a successful content charge. */
|
|
173
175
|
settleCharged: SettleChargedSessionChannel;
|
|
174
176
|
};
|
|
175
177
|
/** Returns whether an accepted credential will authorize and charge an HTTP content response. */
|
|
176
|
-
export declare function shouldApplyVerifiedHttpAccounting(parameters: Pick<ApplyVerifiedHttpAccountingParameters, 'capturedRequest' | 'payloadAction' | 'sseEnabled'>): boolean;
|
|
178
|
+
export declare function shouldApplyVerifiedHttpAccounting(parameters: Pick<ApplyVerifiedHttpAccountingParameters, 'capturedRequest' | 'payloadAction' | 'sseEnabled' | 'unitType'>): boolean;
|
|
177
179
|
/**
|
|
178
180
|
* Returns whether a voucher pays for its request in the store update that accepts it. A
|
|
179
181
|
* request-priced content request is charged exactly once, so this is the charge the HTTP
|
|
@@ -161,12 +161,12 @@ export async function releaseScheduledSettlement(parameters) {
|
|
|
161
161
|
}
|
|
162
162
|
/** Returns whether an accepted credential will authorize and charge an HTTP content response. */
|
|
163
163
|
export function shouldApplyVerifiedHttpAccounting(parameters) {
|
|
164
|
-
const { capturedRequest, payloadAction, sseEnabled } = parameters;
|
|
164
|
+
const { capturedRequest, payloadAction, sseEnabled, unitType } = parameters;
|
|
165
165
|
if (!capturedRequest)
|
|
166
166
|
return false;
|
|
167
167
|
if (payloadAction !== 'open' && payloadAction !== 'voucher')
|
|
168
168
|
return false;
|
|
169
|
-
if (sseEnabled && capturedRequest.method === 'POST')
|
|
169
|
+
if (sseEnabled && capturedRequest.method === 'POST' && unitType !== 'request')
|
|
170
170
|
return false;
|
|
171
171
|
return isSessionContentRequest(capturedRequest);
|
|
172
172
|
}
|
|
@@ -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
|
}
|
|
@@ -1,7 +1,58 @@
|
|
|
1
1
|
import * as Constants from '../../../Constants.js';
|
|
2
2
|
import * as Credential from '../../../Credential.js';
|
|
3
3
|
import { createSessionReceipt, extractData, formatMessageEvent, formatNeedVoucherEvent, formatReceiptEvent, readSessionChallengeAmount, requireSessionCredentialContext, } from '../precompile/Protocol.js';
|
|
4
|
+
import * as ChannelStore from './ChannelStore.js';
|
|
4
5
|
import { meterIterable, } from './MeteredStream.js';
|
|
6
|
+
/** Exposes durable retry information when no refund hook is configured or it fails. */
|
|
7
|
+
export class PrepaidRefundError extends Error {
|
|
8
|
+
refund;
|
|
9
|
+
name = 'PrepaidRefundError';
|
|
10
|
+
constructor(refund) {
|
|
11
|
+
super('Prepaid stream delivery failed and requires merchant refund reconciliation.');
|
|
12
|
+
this.refund = refund;
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
/** Maximum retained automatic refund operations per channel. Never evict live retry keys. */
|
|
16
|
+
export const maxPrepaidRefunds = 128;
|
|
17
|
+
/**
|
|
18
|
+
* Persist a delivery obligation before returning a managed response. Failed hook deliveries remain
|
|
19
|
+
* in `channel.prepaidRefunds` for a merchant worker. Pending entries require manual delivery
|
|
20
|
+
* reconciliation; failed entries may retry the credit with their original terms within 24 hours.
|
|
21
|
+
* Acknowledging the hook means the merchant has durably accepted responsibility for the job.
|
|
22
|
+
*/
|
|
23
|
+
export async function preparePrepaidRefund(options) {
|
|
24
|
+
if (!options.autoCreditPrepaid || !options.prepaidUnits || options.tickCost === 0n)
|
|
25
|
+
return;
|
|
26
|
+
if (!options.refundIdempotencyKey)
|
|
27
|
+
throw new Error('Automatic refunds require a stable refundIdempotencyKey');
|
|
28
|
+
const key = options.refundIdempotencyKey;
|
|
29
|
+
const amount = (BigInt(options.prepaidUnits) * options.tickCost).toString();
|
|
30
|
+
await options.store.updateChannel(options.channelId, (current) => {
|
|
31
|
+
if (!current)
|
|
32
|
+
throw new Error('Cannot prepare prepaid refund: channel not found');
|
|
33
|
+
const existing = current.prepaidRefunds?.[key];
|
|
34
|
+
if (existing) {
|
|
35
|
+
if (existing.amount !== amount || existing.units !== options.prepaidUnits)
|
|
36
|
+
throw new Error('Prepaid refund key conflicts with an existing delivery');
|
|
37
|
+
return current;
|
|
38
|
+
}
|
|
39
|
+
const liveMarkers = Object.values(current.idempotencyMarkers ?? {}).filter((marker) => marker.expiresAt > Date.now()).length;
|
|
40
|
+
if (Object.keys(current.prepaidRefunds ?? {}).length + liveMarkers >= maxPrepaidRefunds)
|
|
41
|
+
throw new Error('Prepaid refund capacity reached; reconcile outstanding deliveries');
|
|
42
|
+
return {
|
|
43
|
+
...current,
|
|
44
|
+
prepaidRefunds: {
|
|
45
|
+
...current.prepaidRefunds,
|
|
46
|
+
[key]: {
|
|
47
|
+
amount,
|
|
48
|
+
units: options.prepaidUnits,
|
|
49
|
+
status: 'pending',
|
|
50
|
+
createdAt: Date.now(),
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
});
|
|
55
|
+
}
|
|
5
56
|
/**
|
|
6
57
|
* SSE (Server-Sent Events) utilities for metered streaming payments.
|
|
7
58
|
*
|
|
@@ -30,6 +81,9 @@ export { extractData, formatMessageEvent, formatNeedVoucherEvent, formatReceiptE
|
|
|
30
81
|
* `stream.charge()` resolves, retaining them on cancellation or failure.
|
|
31
82
|
* 5. On generator completion, emits a final `event: payment-receipt`.
|
|
32
83
|
*
|
|
84
|
+
* With `autoCreditPrepaid`, a failure before a prepaid item is enqueued attempts an idempotent credit.
|
|
85
|
+
* If credit is declined or uncertain, `onRefundRequired` handles merchant reconciliation.
|
|
86
|
+
*
|
|
33
87
|
* Returns a `ReadableStream<Uint8Array>` suitable for use as an HTTP response body.
|
|
34
88
|
*/
|
|
35
89
|
export function serve(options) {
|
|
@@ -38,11 +92,100 @@ export function serve(options) {
|
|
|
38
92
|
const abortController = new AbortController();
|
|
39
93
|
let streamController;
|
|
40
94
|
let terminal = false;
|
|
95
|
+
let preparation = Promise.resolve();
|
|
96
|
+
let termination;
|
|
97
|
+
let committedPrepaidAwaitingDelivery = 0;
|
|
98
|
+
let undeliveredPrepaidUnits = options.prepaidUnits ?? 0;
|
|
99
|
+
if (!Number.isSafeInteger(undeliveredPrepaidUnits) || undeliveredPrepaidUnits < 0)
|
|
100
|
+
throw new Error('prepaidUnits must be a non-negative safe integer');
|
|
101
|
+
const refundIdempotencyKey = options.refundIdempotencyKey ?? `stream:${globalThis.crypto.randomUUID()}`;
|
|
102
|
+
ChannelStore.assertIdempotencyOptions({ idempotencyKey: refundIdempotencyKey });
|
|
103
|
+
const refundOptions = { ...options, refundIdempotencyKey };
|
|
104
|
+
const clearObligation = async () => {
|
|
105
|
+
if (!options.autoCreditPrepaid)
|
|
106
|
+
return;
|
|
107
|
+
await store.updateChannel(channelId, (current) => {
|
|
108
|
+
if (!current?.prepaidRefunds?.[refundIdempotencyKey])
|
|
109
|
+
return current;
|
|
110
|
+
const prepaidRefunds = { ...current.prepaidRefunds };
|
|
111
|
+
delete prepaidRefunds[refundIdempotencyKey];
|
|
112
|
+
return { ...current, prepaidRefunds };
|
|
113
|
+
});
|
|
114
|
+
};
|
|
115
|
+
const creditFailedDelivery = async () => {
|
|
116
|
+
if (!options.autoCreditPrepaid)
|
|
117
|
+
return;
|
|
118
|
+
if (undeliveredPrepaidUnits === 0 || tickCost === 0n) {
|
|
119
|
+
await clearObligation();
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const recordFailure = await store
|
|
123
|
+
.updateChannel(channelId, (current) => {
|
|
124
|
+
if (!current?.prepaidRefunds?.[refundIdempotencyKey])
|
|
125
|
+
throw new Error('Prepaid refund obligation missing');
|
|
126
|
+
return {
|
|
127
|
+
...current,
|
|
128
|
+
prepaidRefunds: {
|
|
129
|
+
...current.prepaidRefunds,
|
|
130
|
+
[refundIdempotencyKey]: {
|
|
131
|
+
...current.prepaidRefunds[refundIdempotencyKey],
|
|
132
|
+
status: 'failed',
|
|
133
|
+
amount: (BigInt(undeliveredPrepaidUnits) * tickCost).toString(),
|
|
134
|
+
units: undeliveredPrepaidUnits,
|
|
135
|
+
},
|
|
136
|
+
},
|
|
137
|
+
};
|
|
138
|
+
})
|
|
139
|
+
.then(() => undefined, (error) => ({ error }));
|
|
140
|
+
const amount = BigInt(undeliveredPrepaidUnits) * tickCost;
|
|
141
|
+
const result = recordFailure
|
|
142
|
+
? { status: 'failed', error: recordFailure.error }
|
|
143
|
+
: await ChannelStore.creditChannel(store, channelId, amount, {
|
|
144
|
+
idempotencyKey: refundIdempotencyKey,
|
|
145
|
+
units: undeliveredPrepaidUnits,
|
|
146
|
+
maxMarkers: maxPrepaidRefunds,
|
|
147
|
+
}).catch((error) => ({ status: 'failed', error }));
|
|
148
|
+
if (result.status === 'credited') {
|
|
149
|
+
await clearObligation();
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
let externalRefundAmount = null;
|
|
153
|
+
if (result.status === 'declined' && result.reason === 'finalized') {
|
|
154
|
+
const captured = result.channel.settledOnChain - (result.channel.spent - amount);
|
|
155
|
+
externalRefundAmount = captured < 0n ? 0n : captured > amount ? amount : captured;
|
|
156
|
+
if (externalRefundAmount === 0n) {
|
|
157
|
+
await clearObligation();
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const refund = {
|
|
162
|
+
channelId,
|
|
163
|
+
amount,
|
|
164
|
+
externalRefundAmount,
|
|
165
|
+
units: undeliveredPrepaidUnits,
|
|
166
|
+
idempotencyKey: refundIdempotencyKey,
|
|
167
|
+
credit: result,
|
|
168
|
+
};
|
|
169
|
+
if (!options.onRefundRequired)
|
|
170
|
+
throw new PrepaidRefundError(refund);
|
|
171
|
+
try {
|
|
172
|
+
await options.onRefundRequired(refund);
|
|
173
|
+
await clearObligation();
|
|
174
|
+
}
|
|
175
|
+
catch {
|
|
176
|
+
throw new PrepaidRefundError(refund);
|
|
177
|
+
}
|
|
178
|
+
};
|
|
41
179
|
const iterator = meterIterable({
|
|
42
180
|
store,
|
|
43
181
|
channelId,
|
|
44
182
|
tickCost,
|
|
45
183
|
generate,
|
|
184
|
+
chargeOnce: options.chargeOnce,
|
|
185
|
+
onPrepaidChargeCommitted() {
|
|
186
|
+
undeliveredPrepaidUnits = Math.max(0, undeliveredPrepaidUnits - 1);
|
|
187
|
+
committedPrepaidAwaitingDelivery += 1;
|
|
188
|
+
},
|
|
46
189
|
beforeCharge: options.beforeCharge,
|
|
47
190
|
onChargeCommitted: options.onChargeCommitted,
|
|
48
191
|
pollIntervalMs,
|
|
@@ -54,23 +197,33 @@ export function serve(options) {
|
|
|
54
197
|
const cleanup = () => signal?.removeEventListener('abort', abort);
|
|
55
198
|
const terminate = (reason) => {
|
|
56
199
|
if (terminal)
|
|
57
|
-
return;
|
|
200
|
+
return termination;
|
|
58
201
|
terminal = true;
|
|
59
202
|
abortController.abort(reason);
|
|
60
203
|
cleanup();
|
|
61
204
|
void Promise.resolve()
|
|
62
205
|
.then(() => iterator.return?.(undefined))
|
|
63
206
|
.catch(() => { });
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
207
|
+
termination = preparation
|
|
208
|
+
.catch(() => { })
|
|
209
|
+
.then(clearObligation)
|
|
210
|
+
.finally(() => {
|
|
211
|
+
try {
|
|
212
|
+
streamController?.close();
|
|
213
|
+
}
|
|
214
|
+
catch { }
|
|
215
|
+
});
|
|
216
|
+
return termination;
|
|
217
|
+
};
|
|
218
|
+
const abort = () => {
|
|
219
|
+
void terminate(signal?.reason)?.catch(() => { });
|
|
68
220
|
};
|
|
69
|
-
const abort = () => terminate(signal?.reason);
|
|
70
221
|
signal?.addEventListener('abort', abort, { once: true });
|
|
71
222
|
return new ReadableStream({
|
|
72
|
-
start(controller) {
|
|
223
|
+
async start(controller) {
|
|
73
224
|
streamController = controller;
|
|
225
|
+
preparation = preparePrepaidRefund(refundOptions);
|
|
226
|
+
await preparation;
|
|
74
227
|
if (signal?.aborted)
|
|
75
228
|
abort();
|
|
76
229
|
},
|
|
@@ -86,8 +239,13 @@ export function serve(options) {
|
|
|
86
239
|
return;
|
|
87
240
|
const value = next.value;
|
|
88
241
|
controller.enqueue(encoder.encode(formatMessageEvent(value)));
|
|
242
|
+
if (committedPrepaidAwaitingDelivery > 0)
|
|
243
|
+
committedPrepaidAwaitingDelivery -= 1;
|
|
244
|
+
else
|
|
245
|
+
undeliveredPrepaidUnits = Math.max(0, undeliveredPrepaidUnits - 1);
|
|
89
246
|
return;
|
|
90
247
|
}
|
|
248
|
+
await clearObligation();
|
|
91
249
|
if (!abortController.signal.aborted) {
|
|
92
250
|
const channel = await store.getChannel(channelId);
|
|
93
251
|
if (channel) {
|
|
@@ -114,14 +272,21 @@ export function serve(options) {
|
|
|
114
272
|
return;
|
|
115
273
|
terminal = true;
|
|
116
274
|
cleanup();
|
|
117
|
-
if (!abortController.signal.aborted)
|
|
118
|
-
|
|
275
|
+
if (!abortController.signal.aborted) {
|
|
276
|
+
try {
|
|
277
|
+
await creditFailedDelivery();
|
|
278
|
+
controller.error(e);
|
|
279
|
+
}
|
|
280
|
+
catch (refundError) {
|
|
281
|
+
controller.error(new AggregateError([e, refundError], 'Stream delivery and prepaid refund failed.'));
|
|
282
|
+
}
|
|
283
|
+
}
|
|
119
284
|
else
|
|
120
285
|
controller.close();
|
|
121
286
|
}
|
|
122
287
|
},
|
|
123
288
|
cancel(reason) {
|
|
124
|
-
terminate(reason);
|
|
289
|
+
return terminate(reason);
|
|
125
290
|
},
|
|
126
291
|
});
|
|
127
292
|
}
|