mppx 0.16.0 → 0.17.1
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 +36 -0
- package/dist/BodyDigest.js +13 -1
- package/dist/Challenge.d.ts +6 -0
- package/dist/Challenge.js +12 -0
- package/dist/Method.d.ts +7 -0
- package/dist/cli/cli.js +140 -134
- package/dist/cli/plugins/index.js +11 -11
- package/dist/cli/sessions/store.js +7 -1
- package/dist/cli/validate/helpers.d.ts +1 -1
- package/dist/cli/validate/helpers.js +14 -2
- package/dist/cli/validate/payment.js +39 -15
- package/dist/client/Mppx.js +11 -3
- package/dist/client/internal/Fetch.d.ts +2 -0
- package/dist/client/internal/Fetch.js +59 -3
- package/dist/client/internal/protocols/Mpp.js +4 -1
- package/dist/identity/Client.js +3 -3
- package/dist/identity/Server.d.ts +13 -0
- package/dist/identity/Server.js +50 -1
- package/dist/identity/Types.d.ts +32 -10
- package/dist/internal/AcceptPayment.d.ts +12 -2
- package/dist/internal/AcceptPayment.js +12 -3
- package/dist/internal/version.d.ts +2 -2
- package/dist/internal/version.js +1 -1
- package/dist/mcp/client/McpClient.js +1 -1
- package/dist/proxy/Proxy.d.ts +15 -0
- package/dist/proxy/Proxy.js +89 -1
- package/dist/proxy/internal/Replay.d.ts +105 -0
- package/dist/proxy/internal/Replay.js +257 -0
- package/dist/server/Mppx.d.ts +4 -2
- package/dist/server/Mppx.js +50 -24
- package/dist/server/internal/challengeMeta.d.ts +6 -0
- package/dist/server/internal/challengeMeta.js +17 -1
- package/dist/tempo/client/Charge.d.ts +20 -0
- package/dist/tempo/client/Charge.js +43 -10
- package/dist/tempo/client/Methods.d.ts +2 -0
- package/dist/tempo/internal/defaults.d.ts +9 -0
- package/dist/tempo/internal/defaults.js +9 -0
- package/dist/tempo/internal/fee-payer.js +2 -2
- package/dist/tempo/server/Charge.js +16 -2
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/dist/tempo/session/client/ChannelOps.d.ts +2 -0
- package/dist/tempo/session/client/ChannelStore.js +5 -0
- package/dist/tempo/session/client/Session.d.ts +26 -1
- package/dist/tempo/session/client/Session.js +72 -1
- package/dist/tempo/session/client/SessionManager.d.ts +1 -0
- package/dist/tempo/session/client/SessionManager.js +54 -7
- package/dist/tempo/session/client/Transports.d.ts +2 -0
- package/dist/tempo/session/client/Transports.js +31 -2
- package/dist/tempo/session/precompile/Voucher.js +20 -7
- 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/Ws.js +29 -12
- package/package.json +1 -1
|
@@ -19,6 +19,8 @@ export type TempoChannelCall = {
|
|
|
19
19
|
};
|
|
20
20
|
/** Client-side cached channel metadata used for automatic voucher management. */
|
|
21
21
|
export type ChannelEntry = {
|
|
22
|
+
/** HTTP origin bound by a session manager; absent on legacy or standalone method entries. */
|
|
23
|
+
origin?: string | undefined;
|
|
22
24
|
/** TIP-1034 channel ID derived from descriptor, escrow, and chain ID. */
|
|
23
25
|
channelId: Hex.Hex;
|
|
24
26
|
/** Highest cumulative amount this client has locally authorized. */
|
|
@@ -91,6 +91,11 @@ export function withFundingLock(store, key, operation) {
|
|
|
91
91
|
export async function persistChannel(store, entry) {
|
|
92
92
|
await exclusive(store, `write:${entryKey(entry)}`, async () => {
|
|
93
93
|
const current = await store.get(entryKey(entry));
|
|
94
|
+
if (current?.origin !== undefined) {
|
|
95
|
+
if (entry.origin !== undefined && entry.origin !== current.origin)
|
|
96
|
+
throw new Error('SessionManager: stored channel origin mismatch');
|
|
97
|
+
entry.origin = current.origin;
|
|
98
|
+
}
|
|
94
99
|
if (current?.uncertainTopUp) {
|
|
95
100
|
if (current.channelId !== entry.channelId)
|
|
96
101
|
throw new Error('Session funding outcome is unknown; reconcile the pending payment before replacing the channel.');
|
|
@@ -134,7 +134,20 @@ export declare namespace session {
|
|
|
134
134
|
autoSwap?: AutoSwap.resolve.Value | undefined;
|
|
135
135
|
/** Pluggable persistence for reusable channels. Defaults to an in-memory store. */
|
|
136
136
|
channelStore?: ChannelStore | undefined;
|
|
137
|
-
/**
|
|
137
|
+
/**
|
|
138
|
+
* Allowlist of TIP-20 currency addresses this client will pay sessions in, compared
|
|
139
|
+
* case-insensitively. Offers in other currencies are skipped during selection and
|
|
140
|
+
* rejected before signing; an empty list rejects all. Required with `maxAmount` or
|
|
141
|
+
* `maxAtomicAmount`, which are denominated in the challenge currency.
|
|
142
|
+
*/
|
|
143
|
+
currencies?: readonly Address[] | undefined;
|
|
144
|
+
/**
|
|
145
|
+
* Allowlist of session recipients (the channel payee), compared case-insensitively. Offers
|
|
146
|
+
* to other recipients, or without one, are skipped during selection and rejected before
|
|
147
|
+
* signing, as for charge. Explicit closes are exempt.
|
|
148
|
+
*/
|
|
149
|
+
expectedRecipients?: readonly Address[] | undefined;
|
|
150
|
+
/** Token decimals for parsing human-readable amounts (default: 6). `maxAmount` requires it explicitly. */
|
|
138
151
|
decimals?: number | undefined;
|
|
139
152
|
/** Exact TIP20EscrowChannel address pin. Takes precedence over `allowCustomEscrow`. */
|
|
140
153
|
escrow?: Address | undefined;
|
|
@@ -144,6 +157,18 @@ export declare namespace session {
|
|
|
144
157
|
* challenge or resolved client must select an allowed chain.
|
|
145
158
|
*/
|
|
146
159
|
allowedChainIds?: readonly number[] | undefined;
|
|
160
|
+
/**
|
|
161
|
+
* Maximum display-unit amount (e.g. `'1.5'`) the client is willing to pay per session
|
|
162
|
+
* unit (the challenge `amount`), parsed with an explicit `decimals`. Values with more
|
|
163
|
+
* fractional digits than `decimals` are rejected rather than rounded. Larger offers are
|
|
164
|
+
* skipped during selection and rejected before signing. Requires `currencies`.
|
|
165
|
+
*/
|
|
166
|
+
maxAmount?: string | undefined;
|
|
167
|
+
/**
|
|
168
|
+
* Maximum amount the client is willing to pay per session unit, in raw base units of the
|
|
169
|
+
* challenge currency. Checked like `maxAmount`. Requires `currencies`.
|
|
170
|
+
*/
|
|
171
|
+
maxAtomicAmount?: string | undefined;
|
|
147
172
|
/** Maximum channel deposit in human-readable units. Caps server-suggested opens and automatic top-ups. */
|
|
148
173
|
maxDeposit?: string | undefined;
|
|
149
174
|
/**
|
|
@@ -4,6 +4,7 @@ import * as MethodChallenge from '../../../client/internal/MethodChallenge.js';
|
|
|
4
4
|
import * as MethodResponse from '../../../client/internal/MethodResponse.js';
|
|
5
5
|
import * as Constants from '../../../Constants.js';
|
|
6
6
|
import * as Credential from '../../../Credential.js';
|
|
7
|
+
import * as AcceptPayment from '../../../internal/AcceptPayment.js';
|
|
7
8
|
import * as Method from '../../../Method.js';
|
|
8
9
|
import * as Account from '../../../viem/Account.js';
|
|
9
10
|
import * as Client from '../../../viem/Client.js';
|
|
@@ -69,6 +70,65 @@ function acknowledgesOpen(outcome, challenge, opened) {
|
|
|
69
70
|
return matchesOpen(opened, getSessionSnapshot(candidate));
|
|
70
71
|
}) ?? false);
|
|
71
72
|
}
|
|
73
|
+
/** Validates and parses the client's session spending options once, at construction. */
|
|
74
|
+
function resolveSpendingPolicy(parameters) {
|
|
75
|
+
const { currencies, decimals, expectedRecipients, maxAmount, maxAtomicAmount } = parameters;
|
|
76
|
+
// Caps are denominated in the challenge currency, so they require a currency pin.
|
|
77
|
+
if ((maxAmount !== undefined || maxAtomicAmount !== undefined) && currencies === undefined)
|
|
78
|
+
throw new Error('Tempo session maxAmount and maxAtomicAmount require currencies.');
|
|
79
|
+
return {
|
|
80
|
+
currencies: currencies?.map((currency) => currency.toLowerCase()),
|
|
81
|
+
expectedRecipients: expectedRecipients
|
|
82
|
+
? new Set(expectedRecipients.map((recipient) => recipient.toLowerCase()))
|
|
83
|
+
: undefined,
|
|
84
|
+
maxAmount: parseMaxAmount(maxAmount, decimals),
|
|
85
|
+
maxAtomicAmount: maxAtomicAmount === undefined ? undefined : BigInt(maxAtomicAmount),
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
/** Parses a display-unit cap, rejecting values that `parseUnits` would round. */
|
|
89
|
+
function parseMaxAmount(maxAmount, decimals) {
|
|
90
|
+
if (maxAmount === undefined)
|
|
91
|
+
return undefined;
|
|
92
|
+
if (decimals === undefined)
|
|
93
|
+
throw new Error('Tempo session maxAmount requires currency decimals.');
|
|
94
|
+
const fraction = maxAmount.split('.')[1]?.replace(/0+$/, '') ?? '';
|
|
95
|
+
if (fraction.length > decimals)
|
|
96
|
+
throw new Error(`Tempo session maxAmount ${maxAmount} has more fractional digits than ${decimals} decimals allow.`);
|
|
97
|
+
return parseUnits(maxAmount, decimals);
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Rejects a session challenge whose currency, recipient or per-unit amount violates the client
|
|
101
|
+
* policy. Runs during challenge selection, in the automatic top-up hook, and before any signing work.
|
|
102
|
+
*/
|
|
103
|
+
function assertSpendingPolicy(policy, request) {
|
|
104
|
+
if (policy.currencies && !policy.currencies.includes(request.currency.toLowerCase()))
|
|
105
|
+
throw new Error(`Tempo session currency is not allowed: ${request.currency}.`);
|
|
106
|
+
// The recipient is the channel payee, so it must be one the client expects, as for charge.
|
|
107
|
+
if (policy.expectedRecipients &&
|
|
108
|
+
(!request.recipient || !policy.expectedRecipients.has(request.recipient.toLowerCase())))
|
|
109
|
+
throw new Error(`Unexpected session recipient: ${request.recipient}`);
|
|
110
|
+
const amount = BigInt(request.amount);
|
|
111
|
+
if (policy.maxAtomicAmount !== undefined && amount > policy.maxAtomicAmount)
|
|
112
|
+
throw new Error('Tempo session amount exceeds maxAtomicAmount.');
|
|
113
|
+
if (policy.maxAmount !== undefined && amount > policy.maxAmount)
|
|
114
|
+
throw new Error('Tempo session amount exceeds maxAmount.');
|
|
115
|
+
}
|
|
116
|
+
/** Returns whether a session challenge satisfies the client policy. */
|
|
117
|
+
function satisfiesSpendingPolicy(policy, request) {
|
|
118
|
+
try {
|
|
119
|
+
assertSpendingPolicy(policy, request);
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
/** Returns whether a caller-supplied credential context asks to close a channel. */
|
|
127
|
+
function isExplicitClose(context) {
|
|
128
|
+
return (typeof context === 'object' &&
|
|
129
|
+
context !== null &&
|
|
130
|
+
context.action === 'close');
|
|
131
|
+
}
|
|
72
132
|
/**
|
|
73
133
|
* Creates the low-level TIP-1034 session payment method for use with `Mppx.create()`.
|
|
74
134
|
*
|
|
@@ -78,6 +138,7 @@ function acknowledgesOpen(outcome, challenge, opened) {
|
|
|
78
138
|
*/
|
|
79
139
|
export function session(parameters = {}) {
|
|
80
140
|
const { account, allowCustomEscrow = false, autoSwap: autoSwapParameter, channelStore, decimals = defaults.decimals, escrow: escrowOverride, allowedChainIds, getClient: getClientParameter, maxDeposit: maxDepositParameter, topUpAmount: topUpAmountParameter, onChannelUpdate, resolveAccount, } = parameters;
|
|
141
|
+
const policy = resolveSpendingPolicy(parameters);
|
|
81
142
|
const getClient = Client.getResolver({
|
|
82
143
|
chain: tempo_chain,
|
|
83
144
|
getClient: getClientParameter,
|
|
@@ -112,10 +173,19 @@ export function session(parameters = {}) {
|
|
|
112
173
|
resolved,
|
|
113
174
|
});
|
|
114
175
|
const method = Method.toClient(Methods.session, {
|
|
115
|
-
|
|
176
|
+
// Skip offers that violate client policy so an acceptable offer can be chosen. An explicit
|
|
177
|
+
// close, when the caller supplies it before selection, stays available for any offer. Flows
|
|
178
|
+
// that learn the context only after selection defer the policy to createCredential.
|
|
179
|
+
canHandleChallenge: ({ challenge, context }) => isTip1034SessionChallenge(challenge) &&
|
|
180
|
+
(isExplicitClose(context) ||
|
|
181
|
+
context === AcceptPayment.deferredContext ||
|
|
182
|
+
satisfiesSpendingPolicy(policy, challenge.request)),
|
|
116
183
|
context: sessionContextSchema,
|
|
117
184
|
async createCredential(parameters) {
|
|
118
185
|
const { challenge, context } = parameters;
|
|
186
|
+
// Closing settles what the channel already spent, so it stays available for any offer.
|
|
187
|
+
if (context?.action !== 'close')
|
|
188
|
+
assertSpendingPolicy(policy, challenge.request);
|
|
119
189
|
const resolved = await resolveChallengeContext({
|
|
120
190
|
allowCustomEscrow,
|
|
121
191
|
challenge,
|
|
@@ -252,6 +322,7 @@ export function session(parameters = {}) {
|
|
|
252
322
|
const sessionContext = context === undefined ? undefined : sessionContextSchema.parse(context);
|
|
253
323
|
if (hasSessionAction(sessionContext))
|
|
254
324
|
return;
|
|
325
|
+
assertSpendingPolicy(policy, challenge.request);
|
|
255
326
|
const resolved = await resolveChallengeContext({
|
|
256
327
|
allowCustomEscrow,
|
|
257
328
|
challenge,
|
|
@@ -60,6 +60,7 @@ export declare function mergeSessionCredentialContext(credentialContext: unknown
|
|
|
60
60
|
* channel state management and credential creation, and to `Fetch.from`
|
|
61
61
|
* for the 402 challenge/retry flow.
|
|
62
62
|
*
|
|
63
|
+
* Each manager is pinned to its first request origin. Use a separate manager per origin.
|
|
63
64
|
* `channelStore` can persist reusable channels between manager instances.
|
|
64
65
|
*/
|
|
65
66
|
export declare function sessionManager(parameters: sessionManager.Parameters): SessionManager;
|
|
@@ -7,6 +7,7 @@ import * as MethodResponse from '../../../client/internal/MethodResponse.js';
|
|
|
7
7
|
import * as ProofRealm from '../../../client/internal/ProofRealm.js';
|
|
8
8
|
import { setCredentialHeader } from '../../../client/internal/protocols/Shared.js';
|
|
9
9
|
import * as Constants from '../../../Constants.js';
|
|
10
|
+
import * as IdentityClient from '../../../identity/Client.js';
|
|
10
11
|
import * as Account from '../../../viem/Account.js';
|
|
11
12
|
import * as Client from '../../../viem/Client.js';
|
|
12
13
|
import { charge as chargePlugin } from '../../client/Charge.js';
|
|
@@ -23,7 +24,7 @@ import { resolveCloseTarget } from './Runtime.js';
|
|
|
23
24
|
import { assertVoucherWithinLocalLimit as assertVoucherWithinLocalAuthorization } from './Runtime.js';
|
|
24
25
|
import { applySessionReceiptToRuntime, captureRuntimeSnapshot as captureRuntimeStateSnapshot, computeFallbackCloseAmount, createSessionManagerRuntime, dispatchSessionEvent, restoreCumulativeAuthorization, restoreRuntimeSnapshot as restoreRuntimeStateSnapshot, resolveAutomaticTopUp, } from './Runtime.js';
|
|
25
26
|
import { closeSocketSession } from './Runtime.js';
|
|
26
|
-
import { closeHttpSession, getSessionSnapshot, isTempoSessionChallenge, postTopUp, retryHttpPaymentRequired, webSocketProbeUrl, } from './Transports.js';
|
|
27
|
+
import { closeHttpSession, getSessionSnapshot, isTempoSessionChallenge, isRequest, postTopUp, retryHttpPaymentRequired, webSocketProbeUrl, } from './Transports.js';
|
|
27
28
|
import { WebSocketReadyState, } from './Transports.js';
|
|
28
29
|
import { consumeSseSessionResponse, openSseSession } from './Transports.js';
|
|
29
30
|
import { UncertainFundingError, applyTopUpResult, resolveManualTopUp, } from './Transports.js';
|
|
@@ -47,7 +48,7 @@ function isZeroAmountChargeChallenge(challenge) {
|
|
|
47
48
|
function requestInitWithSessionHint(input, init, channelId) {
|
|
48
49
|
if (!channelId)
|
|
49
50
|
return init;
|
|
50
|
-
const requestHeaders = input
|
|
51
|
+
const requestHeaders = isRequest(input) ? input.headers : undefined;
|
|
51
52
|
const headers = {
|
|
52
53
|
...Fetch.normalizeHeaders(requestHeaders),
|
|
53
54
|
...Fetch.normalizeHeaders(init?.headers),
|
|
@@ -88,6 +89,7 @@ function resolveSessionManagerConfig(parameters) {
|
|
|
88
89
|
* channel state management and credential creation, and to `Fetch.from`
|
|
89
90
|
* for the 402 challenge/retry flow.
|
|
90
91
|
*
|
|
92
|
+
* Each manager is pinned to its first request origin. Use a separate manager per origin.
|
|
91
93
|
* `channelStore` can persist reusable channels between manager instances.
|
|
92
94
|
*/
|
|
93
95
|
export function sessionManager(parameters) {
|
|
@@ -96,6 +98,34 @@ export function sessionManager(parameters) {
|
|
|
96
98
|
Client.assertAllowedChainId(parameters.allowedChainIds, clientChainId);
|
|
97
99
|
const allowCustomEscrow = parameters.allowCustomEscrow ?? false;
|
|
98
100
|
const config = resolveSessionManagerConfig(parameters);
|
|
101
|
+
let sessionOrigin;
|
|
102
|
+
function bindOrigin(input) {
|
|
103
|
+
const url = new URL(isRequest(input) ? input.url : String(input), typeof location === 'undefined' ? undefined : location.href);
|
|
104
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:')
|
|
105
|
+
throw new Error('SessionManager: session payments require an HTTP origin');
|
|
106
|
+
const origin = url.origin;
|
|
107
|
+
if (sessionOrigin !== undefined && sessionOrigin !== origin)
|
|
108
|
+
throw new Error('SessionManager: session payment origin mismatch');
|
|
109
|
+
sessionOrigin = origin;
|
|
110
|
+
return origin;
|
|
111
|
+
}
|
|
112
|
+
const inheritedIdentity = Fetch.identityOf(config.fetch);
|
|
113
|
+
const rawFetch = Fetch.unwrapFetch(config.fetch);
|
|
114
|
+
config.fetch = async (input, init) => {
|
|
115
|
+
const origin = bindOrigin(input);
|
|
116
|
+
const response = await rawFetch(input, {
|
|
117
|
+
...init,
|
|
118
|
+
redirect: (init?.redirect ?? (isRequest(input) ? input.redirect : undefined)) === 'manual'
|
|
119
|
+
? 'manual'
|
|
120
|
+
: 'error',
|
|
121
|
+
});
|
|
122
|
+
if (response.url && new URL(response.url).origin !== origin)
|
|
123
|
+
throw new Error('SessionManager: session response origin mismatch');
|
|
124
|
+
return response;
|
|
125
|
+
};
|
|
126
|
+
const guardedFetch = config.fetch;
|
|
127
|
+
if (inheritedIdentity)
|
|
128
|
+
config.fetch = IdentityClient.wrapFetch(guardedFetch, inheritedIdentity);
|
|
99
129
|
const getClient = Client.getResolver({
|
|
100
130
|
chain: tempo_chain,
|
|
101
131
|
getClient: parameters.client ? () => parameters.client : parameters.getClient,
|
|
@@ -115,11 +145,19 @@ export function sessionManager(parameters) {
|
|
|
115
145
|
/** Returns the backing entry for `key` only when it is open and not ignored. */
|
|
116
146
|
async function getReusable(key) {
|
|
117
147
|
const entry = await backing.get(key);
|
|
118
|
-
if (entry?.opened && !ignoredChannelIds.has(entry.channelId))
|
|
148
|
+
if (entry?.opened && !ignoredChannelIds.has(entry.channelId)) {
|
|
149
|
+
if (entry.origin !== undefined && entry.origin !== sessionOrigin)
|
|
150
|
+
throw new Error('SessionManager: stored channel origin mismatch');
|
|
119
151
|
return entry;
|
|
152
|
+
}
|
|
120
153
|
return undefined;
|
|
121
154
|
}
|
|
122
|
-
const persistChannel = (entry) =>
|
|
155
|
+
const persistChannel = (entry) => {
|
|
156
|
+
if (entry.origin !== undefined && entry.origin !== sessionOrigin)
|
|
157
|
+
throw new Error('SessionManager: stored channel origin mismatch');
|
|
158
|
+
entry.origin = sessionOrigin;
|
|
159
|
+
return persistStoredChannel(backing, entry);
|
|
160
|
+
};
|
|
123
161
|
const store = {
|
|
124
162
|
async get(key) {
|
|
125
163
|
const entry = await getReusable(key);
|
|
@@ -252,7 +290,8 @@ export function sessionManager(parameters) {
|
|
|
252
290
|
resolveAccount: parameters.resolveAccount,
|
|
253
291
|
});
|
|
254
292
|
const wrappedFetch = Fetch.from({
|
|
255
|
-
fetch:
|
|
293
|
+
fetch: guardedFetch,
|
|
294
|
+
identity: inheritedIdentity,
|
|
256
295
|
methods: [method],
|
|
257
296
|
onChallenge: async (challenge, _helpers) => {
|
|
258
297
|
if (!isTempoSessionChallenge(challenge))
|
|
@@ -334,9 +373,9 @@ export function sessionManager(parameters) {
|
|
|
334
373
|
return undefined;
|
|
335
374
|
if (runtime.channel?.opened)
|
|
336
375
|
return undefined;
|
|
337
|
-
const requestHeaders = input
|
|
376
|
+
const requestHeaders = isRequest(input) ? input.headers : undefined;
|
|
338
377
|
const { body: _body, method: _method, ...bootstrapInit } = init ?? {};
|
|
339
|
-
const bootstrapInput = input
|
|
378
|
+
const bootstrapInput = isRequest(input) ? input.url : input;
|
|
340
379
|
const headInit = {
|
|
341
380
|
...bootstrapInit,
|
|
342
381
|
method: 'HEAD',
|
|
@@ -646,6 +685,7 @@ export function sessionManager(parameters) {
|
|
|
646
685
|
// corrupt each other's runtime and channel tracking.
|
|
647
686
|
if (channelUse)
|
|
648
687
|
throw new Error('SessionManager: a request is already in flight; concurrent requests on one manager are not supported');
|
|
688
|
+
bindOrigin(input);
|
|
649
689
|
runtime.lastUrl = input;
|
|
650
690
|
runtime.lastHeaders = init?.headers;
|
|
651
691
|
const previous = captureRuntimeStateSnapshot({
|
|
@@ -818,6 +858,7 @@ export function sessionManager(parameters) {
|
|
|
818
858
|
throw new Error('No WebSocket implementation available. Pass `webSocket` to sessionManager() in this runtime.');
|
|
819
859
|
}
|
|
820
860
|
const probeUrl = webSocketProbeUrl(input);
|
|
861
|
+
bindOrigin(probeUrl);
|
|
821
862
|
const signalInit = init?.signal ? { signal: init.signal } : undefined;
|
|
822
863
|
await bootstrapSession(probeUrl, signalInit);
|
|
823
864
|
// Cold starts resume from `channelStore` after the probe's 402.
|
|
@@ -906,9 +947,15 @@ export function sessionManager(parameters) {
|
|
|
906
947
|
};
|
|
907
948
|
registerSessionManagerInternals(self, {
|
|
908
949
|
consumeSseResponse(input, response, options) {
|
|
950
|
+
const origin = bindOrigin(input);
|
|
951
|
+
if (response.url && new URL(response.url).origin !== origin)
|
|
952
|
+
throw new Error('SessionManager: session response origin mismatch');
|
|
909
953
|
return consumeSseSessionResponse(input, response, options, sseDriver);
|
|
910
954
|
},
|
|
911
955
|
rehydrate({ channel, challenge, input, spent }) {
|
|
956
|
+
const origin = bindOrigin(input);
|
|
957
|
+
if (channel.origin !== undefined && channel.origin !== origin)
|
|
958
|
+
throw new Error('SessionManager: stored channel origin mismatch');
|
|
912
959
|
if (!channel.opened)
|
|
913
960
|
throw new Error('Cannot restore a closed session channel.');
|
|
914
961
|
if (!isTempoSessionChallenge(challenge)) {
|
|
@@ -282,6 +282,8 @@ export declare function getSessionSnapshot(challenge: TempoSessionChallenge): Se
|
|
|
282
282
|
/** Merges request headers and sets the challenge's credential header for a retry. */
|
|
283
283
|
export declare function requestInitWithAuthorization(input: RequestInfo | URL, init: RequestInit | undefined, credential: string, challenge: TempoSessionChallenge): RequestInit;
|
|
284
284
|
/** Returns the exact resource URL used for out-of-band management POSTs, without its fragment. */
|
|
285
|
+
/** Returns whether a value is a `Request`, including one from another realm such as an iframe. */
|
|
286
|
+
export declare function isRequest(value: unknown): value is Request;
|
|
285
287
|
export declare function managementInput(input: RequestInfo | URL): RequestInfo | URL;
|
|
286
288
|
/** Converts a WebSocket URL into the HTTP URL used for its payment challenge probe. */
|
|
287
289
|
export declare function webSocketProbeUrl(input: string | URL): URL;
|
|
@@ -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)
|
|
@@ -456,6 +466,21 @@ export async function closeHttpSession(parameters) {
|
|
|
456
466
|
}
|
|
457
467
|
let closeChallenge = parameters.target.challenge;
|
|
458
468
|
let signedCloseAmount = parameters.signedCloseAmount;
|
|
469
|
+
const request = isRequest(parameters.lastUrl) ? parameters.lastUrl : undefined;
|
|
470
|
+
// Retain transport policy without inheriting the resource body or cancellation. Redirects stay
|
|
471
|
+
// manual so the credential never follows one.
|
|
472
|
+
const requestOptions = request
|
|
473
|
+
? {
|
|
474
|
+
cache: request.cache,
|
|
475
|
+
credentials: request.credentials,
|
|
476
|
+
integrity: request.integrity,
|
|
477
|
+
keepalive: request.keepalive,
|
|
478
|
+
// Fetch rejects `navigate` in a request init; a navigation becomes `same-origin`, as Fetch does.
|
|
479
|
+
mode: request.mode === 'navigate' ? 'same-origin' : request.mode,
|
|
480
|
+
referrer: request.referrer,
|
|
481
|
+
referrerPolicy: request.referrerPolicy,
|
|
482
|
+
}
|
|
483
|
+
: undefined;
|
|
459
484
|
const postClose = async (challenge, refreshed = false) => {
|
|
460
485
|
closeChallenge = challenge;
|
|
461
486
|
signedCloseAmount =
|
|
@@ -467,7 +492,11 @@ export async function closeHttpSession(parameters) {
|
|
|
467
492
|
descriptor: parameters.target.channel.descriptor,
|
|
468
493
|
cumulativeAmountRaw: signedCloseAmount,
|
|
469
494
|
});
|
|
470
|
-
|
|
495
|
+
// Close is a separate management POST, independent of the resource Request.
|
|
496
|
+
// A Request's URL is absolute. Pass other inputs unchanged so the configured fetch resolves
|
|
497
|
+
// relative URLs exactly as it did for the paid resource.
|
|
498
|
+
return parameters.fetch(request ? managementInput(request) : parameters.lastUrl, {
|
|
499
|
+
...requestOptions,
|
|
471
500
|
method: 'POST',
|
|
472
501
|
redirect: 'manual',
|
|
473
502
|
headers: managementHeaders(challenge, credential, parameters.lastUrl, parameters.headers),
|
|
@@ -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}".`);
|
|
@@ -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
|
}
|
|
@@ -40,7 +40,32 @@ export async function serve(options) {
|
|
|
40
40
|
runtime.closed = true;
|
|
41
41
|
abortController.abort();
|
|
42
42
|
unsubscribe();
|
|
43
|
-
await
|
|
43
|
+
await closeSocket(code, reason);
|
|
44
|
+
};
|
|
45
|
+
const closeSocket = async (code, reason) => {
|
|
46
|
+
try {
|
|
47
|
+
await socket.close(code, reason);
|
|
48
|
+
}
|
|
49
|
+
catch {
|
|
50
|
+
// Some adapters reject a close even after the session has stopped.
|
|
51
|
+
// Retry without application data and contain both sync and async errors.
|
|
52
|
+
try {
|
|
53
|
+
await socket.close();
|
|
54
|
+
}
|
|
55
|
+
catch { }
|
|
56
|
+
}
|
|
57
|
+
};
|
|
58
|
+
const rejectAndClose = (message, status, code) => {
|
|
59
|
+
runtime.closed = true;
|
|
60
|
+
abortController.abort();
|
|
61
|
+
unsubscribe();
|
|
62
|
+
// Queue the small final diagnostic synchronously before close changes socket state.
|
|
63
|
+
// Do not wait for an adapter's send promise: a stalled peer must still be closed.
|
|
64
|
+
try {
|
|
65
|
+
void Promise.resolve(socket.send(formatErrorMessage({ message, status }))).catch(() => { });
|
|
66
|
+
}
|
|
67
|
+
catch { }
|
|
68
|
+
void closeSocket(code, message);
|
|
44
69
|
};
|
|
45
70
|
const sendCloseReady = async () => {
|
|
46
71
|
if (runtime.closeReadySent || !runtime.streamContext || runtime.closed)
|
|
@@ -129,7 +154,7 @@ export async function serve(options) {
|
|
|
129
154
|
message: authorizationResult.message,
|
|
130
155
|
status: authorizationResult.responseStatus,
|
|
131
156
|
}));
|
|
132
|
-
await close(1008,
|
|
157
|
+
await close(1008, 'payment authorization rejected');
|
|
133
158
|
return;
|
|
134
159
|
}
|
|
135
160
|
const { receipt } = authorizationResult;
|
|
@@ -169,11 +194,7 @@ export async function serve(options) {
|
|
|
169
194
|
return;
|
|
170
195
|
const messageBytes = frameByteLength(payload);
|
|
171
196
|
if (messageBytes !== null && messageBytes > maxIncomingMessageBytes) {
|
|
172
|
-
|
|
173
|
-
abortController.abort();
|
|
174
|
-
unsubscribe();
|
|
175
|
-
void send(socket, formatErrorMessage({ message: 'payment message is too large', status: 413 })).catch(() => { });
|
|
176
|
-
void Promise.resolve(socket.close(1009, 'payment message is too large')).catch(() => { });
|
|
197
|
+
rejectAndClose('payment message is too large', 413, 1009);
|
|
177
198
|
return;
|
|
178
199
|
}
|
|
179
200
|
const raw = toText(payload);
|
|
@@ -194,11 +215,7 @@ export async function serve(options) {
|
|
|
194
215
|
if (!work)
|
|
195
216
|
return;
|
|
196
217
|
if (runtime.queuedActions >= maxQueuedPaymentMessages) {
|
|
197
|
-
|
|
198
|
-
message: 'too many queued payment messages',
|
|
199
|
-
status: 429,
|
|
200
|
-
})).catch(() => { });
|
|
201
|
-
void close(1008, 'too many queued payment messages');
|
|
218
|
+
rejectAndClose('too many queued payment messages', 429, 1008);
|
|
202
219
|
return;
|
|
203
220
|
}
|
|
204
221
|
runtime.queuedActions++;
|