mppx 0.17.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 +17 -0
- package/dist/BodyDigest.js +13 -1
- package/dist/Method.d.ts +5 -0
- package/dist/cli/cli.js +133 -133
- 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/client/Mppx.js +11 -3
- package/dist/client/internal/Fetch.d.ts +2 -0
- package/dist/client/internal/Fetch.js +59 -3
- 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 +2 -0
- package/dist/server/Mppx.js +2 -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/server/Charge.js +4 -1
- 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
|
@@ -33,6 +33,7 @@ export class SessionBusyError extends Error {
|
|
|
33
33
|
}
|
|
34
34
|
}
|
|
35
35
|
const storedChannelSchema = z.object({
|
|
36
|
+
origin: z.optional(z.string()),
|
|
36
37
|
channelId: z.hash(),
|
|
37
38
|
cumulativeAmount: z.string(),
|
|
38
39
|
deposit: z.string(),
|
|
@@ -183,6 +184,11 @@ export function createSessionRegistry(options = {}) {
|
|
|
183
184
|
const previous = previousValue === undefined ? undefined : parseStoredSession(previousValue, file);
|
|
184
185
|
if (previous)
|
|
185
186
|
assertSameSession(previous, input, file);
|
|
187
|
+
const origin = previous?.channel.origin ?? channel.origin;
|
|
188
|
+
if (previous?.channel.origin && channel.origin && previous.channel.origin !== channel.origin)
|
|
189
|
+
throw stateError(file, 'Session payment origin mismatch.');
|
|
190
|
+
if (origin !== undefined && new URL(origin).origin !== origin)
|
|
191
|
+
throw stateError(file, 'Session payment origin must be a normalized origin.');
|
|
186
192
|
const challenge = parseSessionChallenge(input.challenge, file);
|
|
187
193
|
assertChallengeMatchesChannel(challenge, channel, file);
|
|
188
194
|
const receipt = input.receipt
|
|
@@ -193,7 +199,7 @@ export function createSessionRegistry(options = {}) {
|
|
|
193
199
|
const spent = maxBigInt(input.spent ?? 0n, receipt ? BigInt(receipt.spent) : 0n, previous ? BigInt(previous.spent) : 0n);
|
|
194
200
|
if (spent > cumulativeAmount)
|
|
195
201
|
throw stateError(file, 'Session spend exceeds the locally authorized cumulative amount.');
|
|
196
|
-
const storedChannel = sanitizeStoredChannel(serializeEntry({ ...channel, cumulativeAmount }), file);
|
|
202
|
+
const storedChannel = sanitizeStoredChannel(serializeEntry({ ...channel, cumulativeAmount, ...(origin !== undefined && { origin }) }), file);
|
|
197
203
|
const timestamp = monotonicTimestamp(previous?.updatedAt, now(), file);
|
|
198
204
|
const latestReceipt = selectLatestReceipt(previous?.receipt, receipt);
|
|
199
205
|
const account = sanitizeAccount(input.account, file);
|
|
@@ -37,7 +37,7 @@ export type Counts = {
|
|
|
37
37
|
skipped: number;
|
|
38
38
|
};
|
|
39
39
|
export declare function printResults(results: CheckResult[], counts: Counts): void;
|
|
40
|
-
/** Fetches
|
|
40
|
+
/** Fetches a validation target without automatic payments or redirected probes. */
|
|
41
41
|
export declare function fetchWithTimeout(url: RequestInfo | URL, init: RequestInit, timeoutMs?: number): Promise<Response>;
|
|
42
42
|
export declare function formatBytes(bytes: number): string;
|
|
43
43
|
export declare const HTTP_METHODS: Set<string>;
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { identityOf, skipPayment, unwrapFetch } from '../../client/internal/Fetch.js';
|
|
1
2
|
import { pc } from '../utils.js';
|
|
2
3
|
export function check(label, detail) {
|
|
3
4
|
return detail ? { label, detail, severity: 'pass' } : { label, severity: 'pass' };
|
|
@@ -57,12 +58,23 @@ export function printResults(results, counts) {
|
|
|
57
58
|
counts.skipped++;
|
|
58
59
|
}
|
|
59
60
|
}
|
|
60
|
-
/** Fetches
|
|
61
|
+
/** Fetches a validation target without automatic payments or redirected probes. */
|
|
61
62
|
export async function fetchWithTimeout(url, init, timeoutMs = 15_000) {
|
|
62
63
|
const controller = new AbortController();
|
|
63
64
|
const timeout = setTimeout(() => controller.abort(), timeoutMs);
|
|
64
65
|
try {
|
|
65
|
-
|
|
66
|
+
const fetch = globalThis.fetch;
|
|
67
|
+
const base = unwrapFetch(fetch);
|
|
68
|
+
const identity = identityOf(fetch);
|
|
69
|
+
const options = {
|
|
70
|
+
[skipPayment]: true,
|
|
71
|
+
...init,
|
|
72
|
+
redirect: 'error',
|
|
73
|
+
signal: controller.signal,
|
|
74
|
+
};
|
|
75
|
+
return identity
|
|
76
|
+
? (await identity.send((input, init) => base(input, { ...init, [skipPayment]: true }), new Request(url, options))).response
|
|
77
|
+
: await base(url, options);
|
|
66
78
|
}
|
|
67
79
|
finally {
|
|
68
80
|
clearTimeout(timeout);
|
package/dist/client/Mppx.js
CHANGED
|
@@ -71,7 +71,10 @@ export function create(config) {
|
|
|
71
71
|
function onPaymentResponse(handler) {
|
|
72
72
|
return events.on('payment.response', handler);
|
|
73
73
|
}
|
|
74
|
-
async function preparePayment(response, options
|
|
74
|
+
async function preparePayment(response, options,
|
|
75
|
+
// Credential context known before selection, such as an explicit session close passed to
|
|
76
|
+
// `createCredential`, so methods can accept offers they would otherwise skip.
|
|
77
|
+
selectionContext) {
|
|
75
78
|
const eventResponse = snapshotResponse(response);
|
|
76
79
|
const challenges = await transport.getChallenges(response, options?.request);
|
|
77
80
|
const challengeSnapshots = Object.freeze(challenges.map((candidate) => snapshotValue(candidate)));
|
|
@@ -79,7 +82,12 @@ export function create(config) {
|
|
|
79
82
|
let challenge;
|
|
80
83
|
let mi;
|
|
81
84
|
try {
|
|
82
|
-
|
|
85
|
+
let candidates = AcceptPayment.selectChallengeCandidates(challenges, methods, preferences, selectionContext);
|
|
86
|
+
// Without a context yet, as in `preparePayment(response).createCredential(context)`, fall back
|
|
87
|
+
// to offers a method skips only by policy: an explicit close may still use them, and the
|
|
88
|
+
// method enforces the policy for anything else when the credential is created.
|
|
89
|
+
if (candidates.length === 0 && selectionContext === undefined)
|
|
90
|
+
candidates = AcceptPayment.selectChallengeCandidates(challenges, methods, preferences, AcceptPayment.deferredContext);
|
|
83
91
|
const orderedCandidates = await resolveChallengeOrder(candidates, options?.orderChallenges ?? orderChallenges);
|
|
84
92
|
const selected = orderedCandidates[0];
|
|
85
93
|
if (!selected)
|
|
@@ -237,7 +245,7 @@ export function create(config) {
|
|
|
237
245
|
preparePayment,
|
|
238
246
|
prepareRequest: prepareRequest,
|
|
239
247
|
async createCredential(response, context, options) {
|
|
240
|
-
const prepared = await preparePayment(response, options);
|
|
248
|
+
const prepared = await preparePayment(response, options, context);
|
|
241
249
|
return prepared.createCredential(context);
|
|
242
250
|
},
|
|
243
251
|
};
|
|
@@ -6,6 +6,8 @@ import type * as Method from '../../Method.js';
|
|
|
6
6
|
import type * as z from '../../zod.js';
|
|
7
7
|
import * as Transport from '../Transport.js';
|
|
8
8
|
import * as ProofRealm from './ProofRealm.js';
|
|
9
|
+
/** Per-request payment opt-out preserved by forwarding fetch layers. @internal */
|
|
10
|
+
export declare const skipPayment: unique symbol;
|
|
9
11
|
export type ClientEventMap<methods extends readonly Method.AnyClient[] = readonly Method.AnyClient[], response = Response> = {
|
|
10
12
|
'challenge.received': ChallengeReceivedPayload<methods, response>;
|
|
11
13
|
'credential.created': CredentialCreatedPayload<methods, response>;
|
|
@@ -14,6 +14,8 @@ import * as ProofRealm from './ProofRealm.js';
|
|
|
14
14
|
const MPPX_FETCH_WRAPPER = Symbol.for('mppx.fetch.wrapper');
|
|
15
15
|
/** Identity runner of an mppx fetch wrapper, inherited when that wrapper is wrapped again. */
|
|
16
16
|
const MPPX_FETCH_IDENTITY = Symbol.for('mppx.fetch.identity');
|
|
17
|
+
/** Per-request payment opt-out preserved by forwarding fetch layers. @internal */
|
|
18
|
+
export const skipPayment = Symbol.for('mppx.fetch.skipPayment');
|
|
17
19
|
const defaultMaxPaymentRetries = 3;
|
|
18
20
|
const maxErrorBodyBytes = 64 * 1024;
|
|
19
21
|
const errorBodyTimeoutMs = 2_000;
|
|
@@ -125,6 +127,8 @@ export function from(config) {
|
|
|
125
127
|
const initialRequest = prepareInitialRequest(input, init, callerHeaders, paymentPreferences.header, hasExplicitAcceptPayment, acceptPaymentPolicy);
|
|
126
128
|
let response = await send(cloneRequestInput(initialRequest.input), initialRequest.init);
|
|
127
129
|
const transportRequest = withCapturedBody(initialRequest.init, await capturedBody);
|
|
130
|
+
if (init?.[skipPayment])
|
|
131
|
+
return response;
|
|
128
132
|
if (!(await transport.isPaymentRequired(response, transportRequest)))
|
|
129
133
|
return response;
|
|
130
134
|
// Only extract context for payment handling after confirming 402.
|
|
@@ -139,7 +143,7 @@ export function from(config) {
|
|
|
139
143
|
try {
|
|
140
144
|
for (let retry = 0; retry < maxPaymentRetries; retry++) {
|
|
141
145
|
challenges = await transport.getChallenges(response, transportRequest);
|
|
142
|
-
const candidates = AcceptPayment.selectChallengeCandidates(challenges, methods, paymentPreferences.entries);
|
|
146
|
+
const candidates = AcceptPayment.selectChallengeCandidates(challenges, methods, paymentPreferences.entries, context);
|
|
143
147
|
const orderedCandidates = await resolveChallengeOrder(candidates, requestOrderChallenges ??
|
|
144
148
|
orderChallenges);
|
|
145
149
|
const selected = orderedCandidates[0];
|
|
@@ -161,7 +165,7 @@ export function from(config) {
|
|
|
161
165
|
if (challenge.expires)
|
|
162
166
|
Expires.assert(challenge.expires, challenge.id);
|
|
163
167
|
await identity?.challenge(response, { challenge: selectedChallenge }, resolveRequestUrl(initialRequest.input).href);
|
|
164
|
-
const paymentInput = resolvePaymentRetryInput(response, initialRequest.input, initialRequest.input);
|
|
168
|
+
const paymentInput = await resolvePaymentRetryInput(response, initialRequest.input, initialRequest.input, fetchInit);
|
|
165
169
|
ProofRealm.assert(selectedChallenge, () => resolveRequestUrl(paymentInput), trustedTempoProofRealms);
|
|
166
170
|
const previousCredential = preparedCredential;
|
|
167
171
|
const key = Challenge.serialize(selectedChallenge);
|
|
@@ -184,6 +188,8 @@ export function from(config) {
|
|
|
184
188
|
await settleAttempt(previousCredential, { status: 'rejected' });
|
|
185
189
|
preparedCredential = undefined;
|
|
186
190
|
}
|
|
191
|
+
// A new payment must not start for a request the caller already aborted.
|
|
192
|
+
resolveRequestSignal(input, init)?.throwIfAborted();
|
|
187
193
|
const credentialContext = overrideContext ?? context;
|
|
188
194
|
if (!MethodChallenge.has(selected.method))
|
|
189
195
|
return resolveCredential(selectedChallenge, selected.method, credentialContext);
|
|
@@ -807,7 +813,21 @@ function resolveRequestUrl(input) {
|
|
|
807
813
|
return new URL(input.url);
|
|
808
814
|
return new URL(input, isBrowser() ? globalThis.location.href : undefined);
|
|
809
815
|
}
|
|
810
|
-
|
|
816
|
+
/** Rejects Request modes whose paid retry cannot carry a credential to the server. */
|
|
817
|
+
function assertPayableRequest(input, init) {
|
|
818
|
+
const request = input instanceof Request ? input : undefined;
|
|
819
|
+
const mode = init.mode ?? request?.mode;
|
|
820
|
+
// Fetch strips the Authorization header from no-cors requests.
|
|
821
|
+
if (mode === 'no-cors')
|
|
822
|
+
throw new Error('Cannot automatically pay a no-cors request: it cannot carry a credential.');
|
|
823
|
+
const cache = init.cache ?? request?.cache;
|
|
824
|
+
// A cache-only retry never reaches the server.
|
|
825
|
+
if (cache === 'only-if-cached')
|
|
826
|
+
throw new Error('Cannot automatically pay an only-if-cached request: its retry cannot reach the server.');
|
|
827
|
+
}
|
|
828
|
+
/** Retains Request state while targeting the same-origin challenge response URL. */
|
|
829
|
+
async function resolvePaymentRetryInput(response, fallback, initialInput, init) {
|
|
830
|
+
assertPayableRequest(fallback, init);
|
|
811
831
|
if (!response.url)
|
|
812
832
|
return fallback;
|
|
813
833
|
const responseUrl = new URL(response.url);
|
|
@@ -815,6 +835,42 @@ function resolvePaymentRetryInput(response, fallback, initialInput) {
|
|
|
815
835
|
if (responseUrl.origin !== initialUrl.origin) {
|
|
816
836
|
throw new Error(`Refusing to send payment credential across redirect from ${initialUrl.origin} to ${responseUrl.origin}`);
|
|
817
837
|
}
|
|
838
|
+
const method = (init.method ?? (fallback instanceof Request ? fallback.method : 'GET')).toUpperCase();
|
|
839
|
+
// Fetch does not expose the redirect chain, so a non-GET/HEAD request may
|
|
840
|
+
// have become a GET before reaching the challenge (301/302/303).
|
|
841
|
+
if (response.redirected && method !== 'GET' && method !== 'HEAD')
|
|
842
|
+
throw new Error(`Cannot automatically pay a redirected ${method} request. Use prepareRequest() to preserve redirect semantics.`);
|
|
843
|
+
if (fallback instanceof Request) {
|
|
844
|
+
// A replacement body makes a consumed or locked original body irrelevant.
|
|
845
|
+
// Otherwise retain the original body for any later payment challenges.
|
|
846
|
+
const source = init.body != null ? fallback : fallback.clone();
|
|
847
|
+
// User-created requests cannot use navigate mode; retry a navigation as same-origin.
|
|
848
|
+
const request = new Request(source, fallback.mode === 'navigate' && init.mode === undefined
|
|
849
|
+
? { ...init, mode: 'same-origin' }
|
|
850
|
+
: init);
|
|
851
|
+
// Fetch never sends a URL fragment, and the response URL has none.
|
|
852
|
+
if (request.url.split('#')[0] === response.url)
|
|
853
|
+
return request;
|
|
854
|
+
if (request.body) {
|
|
855
|
+
// Retargeting would turn the body into a stream without a length (and keepalive rejects
|
|
856
|
+
// streams). Retarget a buffered copy so the paid retry keeps its Content-Length.
|
|
857
|
+
return new Request(response.url, {
|
|
858
|
+
body: await request.arrayBuffer(),
|
|
859
|
+
cache: request.cache,
|
|
860
|
+
credentials: request.credentials,
|
|
861
|
+
headers: request.headers,
|
|
862
|
+
integrity: request.integrity,
|
|
863
|
+
keepalive: request.keepalive,
|
|
864
|
+
method: request.method,
|
|
865
|
+
mode: request.mode,
|
|
866
|
+
redirect: request.redirect,
|
|
867
|
+
referrer: request.referrer,
|
|
868
|
+
referrerPolicy: request.referrerPolicy,
|
|
869
|
+
signal: request.signal,
|
|
870
|
+
});
|
|
871
|
+
}
|
|
872
|
+
return new Request(response.url, request);
|
|
873
|
+
}
|
|
818
874
|
return response.url;
|
|
819
875
|
}
|
|
820
876
|
//# sourceMappingURL=Fetch.js.map
|
|
@@ -3,10 +3,17 @@ import type { MaybePromise } from './types.js';
|
|
|
3
3
|
type MethodLike = {
|
|
4
4
|
canHandleChallenge?: ((parameters: {
|
|
5
5
|
challenge: Challenge.Challenge;
|
|
6
|
+
context?: unknown;
|
|
6
7
|
}) => boolean) | undefined;
|
|
7
8
|
intent: string;
|
|
8
9
|
name: string;
|
|
9
10
|
};
|
|
11
|
+
/**
|
|
12
|
+
* Selection context for flows that receive the credential context only after an offer is chosen,
|
|
13
|
+
* such as `preparePayment()`. Methods may accept offers they would otherwise skip by policy and
|
|
14
|
+
* enforce that policy when the credential is created.
|
|
15
|
+
*/
|
|
16
|
+
export declare const deferredContext: unique symbol;
|
|
10
17
|
/** Supported challenge paired with the configured client method that can sign it. */
|
|
11
18
|
export type ChallengeCandidate<method extends MethodLike = MethodLike> = method extends MethodLike ? {
|
|
12
19
|
challenge: Challenge.Challenge<Record<string, unknown>, method['intent'], method['name']>;
|
|
@@ -79,8 +86,11 @@ export declare function selectChallenge<const methods extends readonly MethodLik
|
|
|
79
86
|
challenge: Challenge.Challenge;
|
|
80
87
|
method: methods[number];
|
|
81
88
|
} | undefined;
|
|
82
|
-
/**
|
|
83
|
-
|
|
89
|
+
/**
|
|
90
|
+
* Returns supported challenge candidates ordered by client payment preferences.
|
|
91
|
+
* `context` is the caller's credential context, when known before selection.
|
|
92
|
+
*/
|
|
93
|
+
export declare function selectChallengeCandidates<const methods extends readonly MethodLike[]>(challenges: readonly Challenge.Challenge[], methods: methods, preferences: readonly Entry[], context?: unknown): ChallengeCandidate<methods[number]>[];
|
|
84
94
|
/** Returns the canonical `method/intent` key for a method or challenge-like value. */
|
|
85
95
|
export declare function keyOf(value: {
|
|
86
96
|
intent: string;
|
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
import * as x402_ChallengeBrand from '../x402/internal/ChallengeBrand.js';
|
|
2
|
+
/**
|
|
3
|
+
* Selection context for flows that receive the credential context only after an offer is chosen,
|
|
4
|
+
* such as `preparePayment()`. Methods may accept offers they would otherwise skip by policy and
|
|
5
|
+
* enforce that policy when the credential is created.
|
|
6
|
+
*/
|
|
7
|
+
export const deferredContext = Symbol('mppx.deferredContext');
|
|
2
8
|
/** Builds the typed key tree used by callback-style preference config. */
|
|
3
9
|
export function buildKeys(methods) {
|
|
4
10
|
const keys = {};
|
|
@@ -70,8 +76,11 @@ export function selectChallenge(challenges, methods, preferences) {
|
|
|
70
76
|
method: candidate.method,
|
|
71
77
|
};
|
|
72
78
|
}
|
|
73
|
-
/**
|
|
74
|
-
|
|
79
|
+
/**
|
|
80
|
+
* Returns supported challenge candidates ordered by client payment preferences.
|
|
81
|
+
* `context` is the caller's credential context, when known before selection.
|
|
82
|
+
*/
|
|
83
|
+
export function selectChallengeCandidates(challenges, methods, preferences, context) {
|
|
75
84
|
const methodsByKey = new Map();
|
|
76
85
|
for (const method of methods) {
|
|
77
86
|
const key = keyOf(method);
|
|
@@ -90,7 +99,7 @@ export function selectChallengeCandidates(challenges, methods, preferences) {
|
|
|
90
99
|
if (!match || match.q <= 0)
|
|
91
100
|
return [];
|
|
92
101
|
return matchingMethods
|
|
93
|
-
.filter((method) => method.canHandleChallenge?.({ challenge }) ?? true)
|
|
102
|
+
.filter((method) => method.canHandleChallenge?.(context === undefined ? { challenge } : { challenge, context }) ?? true)
|
|
94
103
|
.map((method) => ({
|
|
95
104
|
challenge,
|
|
96
105
|
index,
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Current mppx package version. */
|
|
2
|
-
export declare const version = "0.17.
|
|
2
|
+
export declare const version = "0.17.1";
|
|
3
3
|
/** Canonical SDK identifier for this mppx release. */
|
|
4
|
-
export declare const sdkIdentifier = "mppx/0.17.
|
|
4
|
+
export declare const sdkIdentifier = "mppx/0.17.1";
|
|
5
5
|
//# sourceMappingURL=version.d.ts.map
|
package/dist/internal/version.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// Generated by scripts/sync-version.ts.
|
|
2
2
|
/** Current mppx package version. */
|
|
3
|
-
export const version = '0.17.
|
|
3
|
+
export const version = '0.17.1';
|
|
4
4
|
/** Canonical SDK identifier for this mppx release. */
|
|
5
5
|
export const sdkIdentifier = `mppx/${version}`;
|
|
6
6
|
//# sourceMappingURL=version.js.map
|
|
@@ -100,7 +100,7 @@ function createPaymentAwareCallTool(callTool, config) {
|
|
|
100
100
|
const paymentPreferences = AcceptPayment.resolve(methods, config.paymentPreferences);
|
|
101
101
|
const retryWithPayment = async (params, call, paymentRequired, cause, attemptsRemaining = maxPaymentAttempts) => {
|
|
102
102
|
const challenges = paymentRequired.challenges;
|
|
103
|
-
const candidates = AcceptPayment.selectChallengeCandidates(challenges, methods, paymentPreferences.entries);
|
|
103
|
+
const candidates = AcceptPayment.selectChallengeCandidates(challenges, methods, paymentPreferences.entries, call.context);
|
|
104
104
|
const orderedCandidates = config.orderChallenges
|
|
105
105
|
? await config.orderChallenges(candidates)
|
|
106
106
|
: candidates;
|
package/dist/proxy/Proxy.d.ts
CHANGED
|
@@ -1,5 +1,8 @@
|
|
|
1
1
|
import type * as http from 'node:http';
|
|
2
|
+
import * as Replay from './internal/Replay.js';
|
|
2
3
|
import * as Service from './Service.js';
|
|
4
|
+
/** Options for {@link create.Config.replay}. */
|
|
5
|
+
export type ReplayOptions = Replay.Options;
|
|
3
6
|
/** A paid API proxy that gates upstream services behind the mppx 402 protocol. */
|
|
4
7
|
export type Proxy = {
|
|
5
8
|
/** Fetch API handler. Works with Bun, Deno, Next.js, Hono, Elysia, SvelteKit, etc. */
|
|
@@ -48,6 +51,18 @@ export declare namespace create {
|
|
|
48
51
|
fetch?: typeof globalThis.fetch | undefined;
|
|
49
52
|
/** Default handler for failed upstream attempts. Services may override it. */
|
|
50
53
|
onUpstreamError?: Service.UpstreamErrorHandler | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* Lets a payer collect a paid response after losing the connection.
|
|
56
|
+
*
|
|
57
|
+
* The paid upstream request runs detached from the client (its response must
|
|
58
|
+
* start within `ttl`), and its outcome is recorded under a key derived from the
|
|
59
|
+
* credential and the request's method, target and body. A retry with the
|
|
60
|
+
* same credential and request never pays again: it gets the stored
|
|
61
|
+
* response, `202` while the delivery runs, or, after a failed delivery, a
|
|
62
|
+
* new upstream attempt up to `maxAttempts`. Streamed (`text/event-stream`)
|
|
63
|
+
* and oversized responses are delivered but not stored. Off by default.
|
|
64
|
+
*/
|
|
65
|
+
replay?: ReplayOptions | undefined;
|
|
51
66
|
/** Services to proxy. Each service is mounted at `/{serviceId}/`. */
|
|
52
67
|
services: Service.Service[];
|
|
53
68
|
/** Human-readable title for the proxy shown in `llms.txt`. */
|
package/dist/proxy/Proxy.js
CHANGED
|
@@ -4,6 +4,7 @@ import * as Scope from '../server/internal/scope.js';
|
|
|
4
4
|
import * as Mppx from '../server/Mppx.js';
|
|
5
5
|
import * as Request from '../server/Request.js';
|
|
6
6
|
import * as Headers from './internal/Headers.js';
|
|
7
|
+
import * as Replay from './internal/Replay.js';
|
|
7
8
|
import * as Route from './internal/Route.js';
|
|
8
9
|
import * as Service from './Service.js';
|
|
9
10
|
/**
|
|
@@ -34,6 +35,7 @@ import * as Service from './Service.js';
|
|
|
34
35
|
*/
|
|
35
36
|
export function create(config) {
|
|
36
37
|
const fetchImpl = config.fetch ?? globalThis.fetch;
|
|
38
|
+
const replay = config.replay ? Replay.resolve(config.replay) : undefined;
|
|
37
39
|
const services = new Map(config.services.map((s) => {
|
|
38
40
|
const proxy = createFetchProxy(s.baseUrl, { fetch: fetchImpl });
|
|
39
41
|
return [s.id, { service: s, proxy }];
|
|
@@ -108,13 +110,59 @@ export function create(config) {
|
|
|
108
110
|
proxy,
|
|
109
111
|
onUpstreamError: service.onUpstreamError ?? config.onUpstreamError,
|
|
110
112
|
});
|
|
113
|
+
// With replay, a paid request whose credential already paid for this exact
|
|
114
|
+
// request collects its recorded delivery instead of paying again.
|
|
115
|
+
let paidRequest = request;
|
|
116
|
+
let replayBody;
|
|
117
|
+
let replayKey;
|
|
118
|
+
// Routes with identity verifiers keep paying normally: a replay would skip them.
|
|
119
|
+
const credential = replay && !fallbackMatch && !requiresIdentity(handler)
|
|
120
|
+
? Replay.credentialOf(request, credentialHeaders(handler))
|
|
121
|
+
: undefined;
|
|
122
|
+
if (replay && credential) {
|
|
123
|
+
const body = await Replay.readBody(request, replay.maxRequestBodySize);
|
|
124
|
+
if (!body)
|
|
125
|
+
return new Response('Payload Too Large', { status: 413 });
|
|
126
|
+
replayBody = body;
|
|
127
|
+
paidRequest = Replay.withBody(request, body);
|
|
128
|
+
replayKey = Replay.key({
|
|
129
|
+
body,
|
|
130
|
+
credential,
|
|
131
|
+
method: request.method,
|
|
132
|
+
target: url.origin + url.pathname + url.search,
|
|
133
|
+
});
|
|
134
|
+
const found = await Replay.lookup(replay, replayKey);
|
|
135
|
+
if (found.type === 'respond')
|
|
136
|
+
return found.response;
|
|
137
|
+
if (found.type === 'retry') {
|
|
138
|
+
const options = Service.getOptions(endpoint);
|
|
139
|
+
return Replay.deliver({
|
|
140
|
+
applyReceipt: (response) => Replay.withReceiptHeader(response, found.receipt),
|
|
141
|
+
attempts: found.attempts,
|
|
142
|
+
replay,
|
|
143
|
+
replayKey,
|
|
144
|
+
requestHeaders: found.requestHeaders,
|
|
145
|
+
run: (signal) => {
|
|
146
|
+
// Send the headers of the request that was paid for, not the retry's.
|
|
147
|
+
const upstream = Replay.withBody(paidRequest, body, signal, found.requestHeaders);
|
|
148
|
+
return proxyUpstream({
|
|
149
|
+
request: upstream,
|
|
150
|
+
service,
|
|
151
|
+
ctx: { ...ctx, request: upstream, ...options },
|
|
152
|
+
proxy,
|
|
153
|
+
onUpstreamError: service.onUpstreamError ?? config.onUpstreamError,
|
|
154
|
+
});
|
|
155
|
+
},
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
111
159
|
const scope = getConfiguredScope(handler) ??
|
|
112
160
|
deriveRouteScope({
|
|
113
161
|
basePath: config.basePath,
|
|
114
162
|
routeKey: matched.key,
|
|
115
163
|
serviceId,
|
|
116
164
|
});
|
|
117
|
-
const result = await handler(getConfiguredScope(handler) ?
|
|
165
|
+
const result = await handler(getConfiguredScope(handler) ? paidRequest : Scope.attach(paidRequest, scope));
|
|
118
166
|
if (result.status === 402)
|
|
119
167
|
return result.challenge;
|
|
120
168
|
const managementResponse = (() => {
|
|
@@ -132,6 +180,26 @@ export function create(config) {
|
|
|
132
180
|
if (fallbackMatch)
|
|
133
181
|
return new Response('Method Not Allowed', { status: 405 });
|
|
134
182
|
const options = Service.getOptions(endpoint);
|
|
183
|
+
if (replay && replayKey && replayBody) {
|
|
184
|
+
const body = replayBody;
|
|
185
|
+
return Replay.deliver({
|
|
186
|
+
applyReceipt: (response) => result.withReceipt(response),
|
|
187
|
+
attempts: 1,
|
|
188
|
+
replay,
|
|
189
|
+
replayKey,
|
|
190
|
+
requestHeaders: [...Headers.scrub(paidRequest.headers).entries()],
|
|
191
|
+
run: (signal) => {
|
|
192
|
+
const upstream = Replay.withBody(paidRequest, body, signal);
|
|
193
|
+
return proxyUpstream({
|
|
194
|
+
request: upstream,
|
|
195
|
+
service,
|
|
196
|
+
ctx: { ...ctx, request: upstream, ...options },
|
|
197
|
+
proxy,
|
|
198
|
+
onUpstreamError: service.onUpstreamError ?? config.onUpstreamError,
|
|
199
|
+
});
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
}
|
|
135
203
|
const upstreamRes = await proxyUpstream({
|
|
136
204
|
request,
|
|
137
205
|
service,
|
|
@@ -284,6 +352,26 @@ function buildDiscoveryRoutes(services) {
|
|
|
284
352
|
};
|
|
285
353
|
}));
|
|
286
354
|
}
|
|
355
|
+
/** Headers the handler reads Payment credentials from, lowercased (Authorization by default). */
|
|
356
|
+
function credentialHeaders(handler) {
|
|
357
|
+
const internal = ('_internal' in handler ? handler._internal : undefined);
|
|
358
|
+
const headers = internal?.offers
|
|
359
|
+
? internal.offers.flatMap((offer) => offer.credentialHeaders ?? [])
|
|
360
|
+
: (internal?.credentialHeaders ?? []);
|
|
361
|
+
const names = [...new Set(headers.map((header) => header.toLowerCase()))];
|
|
362
|
+
return names.length > 0 ? names : ['authorization'];
|
|
363
|
+
}
|
|
364
|
+
/** Whether a paid handler, or any offer of a composed one, requires identity verifiers. */
|
|
365
|
+
function requiresIdentity(handler) {
|
|
366
|
+
if (!('_internal' in handler))
|
|
367
|
+
return false;
|
|
368
|
+
const internal = handler._internal;
|
|
369
|
+
if (!internal)
|
|
370
|
+
return false;
|
|
371
|
+
if (internal.offers)
|
|
372
|
+
return internal.offers.some((offer) => offer._identity);
|
|
373
|
+
return internal._identity === true;
|
|
374
|
+
}
|
|
287
375
|
function getConfiguredScope(handler) {
|
|
288
376
|
if (!('_internal' in handler))
|
|
289
377
|
return undefined;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import type * as Store from '../../Store.js';
|
|
2
|
+
/** Options for replaying paid proxy deliveries. */
|
|
3
|
+
export type Options = {
|
|
4
|
+
/**
|
|
5
|
+
* Upstream attempts per credential, including the first, before a retry gets `502`.
|
|
6
|
+
* A retry after a failed delivery (an upstream error, a 5xx or a body that broke
|
|
7
|
+
* off) sends the paid request upstream again without charging, so a
|
|
8
|
+
* non-idempotent upstream may run the operation twice; set `1` to only ever
|
|
9
|
+
* replay a recorded response. @default 3
|
|
10
|
+
*/
|
|
11
|
+
maxAttempts?: number | undefined;
|
|
12
|
+
/** Largest response body, in bytes, kept for replay. Larger or streamed bodies are delivered but not kept. @default 1_048_576 */
|
|
13
|
+
maxBodySize?: number | undefined;
|
|
14
|
+
/** Largest request body, in bytes, a paid request may carry; larger ones get `413`. @default 1_048_576 */
|
|
15
|
+
maxRequestBodySize?: number | undefined;
|
|
16
|
+
/** `Retry-After` seconds returned while a delivery is still in flight. @default 5 */
|
|
17
|
+
retryAfter?: number | undefined;
|
|
18
|
+
/** Atomic store shared by every proxy instance that may receive a retry. */
|
|
19
|
+
store: Store.AtomicStore;
|
|
20
|
+
/**
|
|
21
|
+
* How long to wait for the upstream to start responding, and how long its outcome is kept after it is
|
|
22
|
+
* recorded, in milliseconds. Expired records are removed when they are next
|
|
23
|
+
* looked up, as with mppx's replay markers; give the backing store its own
|
|
24
|
+
* key expiry to reclaim records nobody retries. @default 900_000
|
|
25
|
+
*/
|
|
26
|
+
ttl?: number | undefined;
|
|
27
|
+
/** Keeps background work alive after the response returns, such as Cloudflare's `ctx.waitUntil`. */
|
|
28
|
+
waitUntil?: ((promise: Promise<unknown>) => void) | undefined;
|
|
29
|
+
};
|
|
30
|
+
/** Replay options with defaults applied. */
|
|
31
|
+
export type Resolved = {
|
|
32
|
+
maxAttempts: number;
|
|
33
|
+
maxBodySize: number;
|
|
34
|
+
maxRequestBodySize: number;
|
|
35
|
+
retryAfter: number;
|
|
36
|
+
store: Store.AtomicStore;
|
|
37
|
+
ttl: number;
|
|
38
|
+
waitUntil: ((promise: Promise<unknown>) => void) | undefined;
|
|
39
|
+
};
|
|
40
|
+
/** A delivery record, keyed by credential and request. */
|
|
41
|
+
export type Record = {
|
|
42
|
+
attempts: number;
|
|
43
|
+
expires: number;
|
|
44
|
+
receipt: string | null;
|
|
45
|
+
requestHeaders: [string, string][];
|
|
46
|
+
status: 'pending';
|
|
47
|
+
} | {
|
|
48
|
+
attempts: number;
|
|
49
|
+
expires: number;
|
|
50
|
+
receipt: string | null;
|
|
51
|
+
requestHeaders: [string, string][];
|
|
52
|
+
status: 'failed';
|
|
53
|
+
} | {
|
|
54
|
+
expires: number;
|
|
55
|
+
status: 'delivered';
|
|
56
|
+
} | {
|
|
57
|
+
body: string;
|
|
58
|
+
expires: number;
|
|
59
|
+
headers: [string, string][];
|
|
60
|
+
responseStatus: number;
|
|
61
|
+
status: 'complete';
|
|
62
|
+
statusText: string;
|
|
63
|
+
};
|
|
64
|
+
/** Applies defaults to replay options. */
|
|
65
|
+
export declare function resolve(options: Options): Resolved;
|
|
66
|
+
/** Returns the request's Payment credential header value, if it carries one. */
|
|
67
|
+
export declare function credentialOf(request: Request, headerNames?: readonly string[]): string | undefined;
|
|
68
|
+
/** Reads a request body, or returns null when it is larger than maxRequestBodySize. */
|
|
69
|
+
export declare function readBody(request: Request, maxRequestBodySize: number): Promise<Uint8Array<ArrayBuffer> | null>;
|
|
70
|
+
/** Derives the replay key from the credential and the exact request (origin, path, query, method, body) it paid for. */
|
|
71
|
+
export declare function key(parameters: {
|
|
72
|
+
body: Uint8Array;
|
|
73
|
+
credential: string;
|
|
74
|
+
method: string;
|
|
75
|
+
target: string;
|
|
76
|
+
}): string;
|
|
77
|
+
/** Rebuilds a request around an already-read body, optionally with another abort signal. */
|
|
78
|
+
export declare function withBody(request: Request, body: Uint8Array, signal?: AbortSignal, headers?: HeadersInit): Request;
|
|
79
|
+
/** What a request with a known credential should do. */
|
|
80
|
+
export type Lookup = {
|
|
81
|
+
type: 'none';
|
|
82
|
+
} | {
|
|
83
|
+
response: Response;
|
|
84
|
+
type: 'respond';
|
|
85
|
+
} | {
|
|
86
|
+
attempts: number;
|
|
87
|
+
receipt: string | null;
|
|
88
|
+
requestHeaders: [string, string][];
|
|
89
|
+
type: 'retry';
|
|
90
|
+
};
|
|
91
|
+
/** Looks up a delivery and, for a failed one, claims the next upstream attempt. */
|
|
92
|
+
export declare function lookup(replay: Resolved, replayKey: string): Promise<Lookup>;
|
|
93
|
+
/** Runs a paid upstream delivery detached from the client and records its outcome. */
|
|
94
|
+
export declare function deliver(parameters: {
|
|
95
|
+
applyReceipt: (response: Response) => Response | Promise<Response>;
|
|
96
|
+
attempts: number;
|
|
97
|
+
replay: Resolved;
|
|
98
|
+
replayKey: string;
|
|
99
|
+
/** The upstream headers of the paid request; a retried delivery reuses them. */
|
|
100
|
+
requestHeaders: [string, string][];
|
|
101
|
+
run: (signal: AbortSignal) => Promise<Response>;
|
|
102
|
+
}): Promise<Response>;
|
|
103
|
+
/** Adds a stored receipt to a response built for a retried delivery. */
|
|
104
|
+
export declare function withReceiptHeader(response: Response, receipt: string | null): Response;
|
|
105
|
+
//# sourceMappingURL=Replay.d.ts.map
|