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
|
@@ -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);
|
|
@@ -740,33 +740,57 @@ export function captureEventStream(fetch) {
|
|
|
740
740
|
const response = await fetch(input, init);
|
|
741
741
|
if (!response.body ||
|
|
742
742
|
!isEventStream(response) ||
|
|
743
|
-
response.headers.
|
|
743
|
+
response.headers.get(Constants.Headers.paymentReceipt))
|
|
744
744
|
return response;
|
|
745
745
|
// Session management requests (such as mid-stream voucher posts) share this fetch, so a
|
|
746
746
|
// later stream without a receipt must not discard one already seen.
|
|
747
747
|
receipt ??= null;
|
|
748
748
|
const decoder = new TextDecoder();
|
|
749
|
-
|
|
749
|
+
// Normalized pieces of the current partial event, joined only when the event ends, so a
|
|
750
|
+
// long event costs linear time. A CR that may start a CRLF is held apart.
|
|
751
|
+
let parts = [];
|
|
752
|
+
let size = 0;
|
|
753
|
+
let heldCr = false;
|
|
750
754
|
// Set after an oversized event is dropped, until its end, so its remaining lines are
|
|
751
755
|
// never parsed as an event of their own.
|
|
752
756
|
let skipping = false;
|
|
753
757
|
const scan = (text, done) => {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
758
|
+
let chunk = heldCr ? `\r${text}` : text;
|
|
759
|
+
heldCr = !done && chunk.endsWith('\r');
|
|
760
|
+
if (heldCr)
|
|
761
|
+
chunk = chunk.slice(0, -1);
|
|
762
|
+
let rest = chunk.replace(/\r\n|\r/g, '\n');
|
|
763
|
+
const events = [];
|
|
764
|
+
// A boundary split across chunks: the partial event ends in a newline and this text starts with one.
|
|
765
|
+
if (rest.startsWith('\n') && parts.at(-1)?.endsWith('\n')) {
|
|
766
|
+
events.push(parts.join('').slice(0, -1));
|
|
767
|
+
parts = [];
|
|
768
|
+
size = 0;
|
|
769
|
+
rest = rest.slice(1);
|
|
770
|
+
}
|
|
771
|
+
for (let index = rest.indexOf('\n\n'); index !== -1; index = rest.indexOf('\n\n')) {
|
|
772
|
+
events.push(parts.join('') + rest.slice(0, index));
|
|
773
|
+
parts = [];
|
|
774
|
+
size = 0;
|
|
775
|
+
rest = rest.slice(index + 2);
|
|
776
|
+
}
|
|
777
|
+
if (rest) {
|
|
778
|
+
parts.push(rest);
|
|
779
|
+
size += rest.length;
|
|
780
|
+
}
|
|
781
|
+
if (done) {
|
|
782
|
+
events.push(parts.join(''));
|
|
783
|
+
parts = [];
|
|
784
|
+
size = 0;
|
|
785
|
+
}
|
|
786
|
+
if (skipping && events.length > 0) {
|
|
763
787
|
events.shift();
|
|
764
788
|
skipping = false;
|
|
765
789
|
}
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
790
|
+
if (size > maxPendingEventBytes) {
|
|
791
|
+
// Keep a trailing newline: it may be the first half of the event's boundary.
|
|
792
|
+
parts = parts.at(-1)?.endsWith('\n') ? ['\n'] : [];
|
|
793
|
+
size = parts.length;
|
|
770
794
|
skipping = true;
|
|
771
795
|
}
|
|
772
796
|
for (const raw of events) {
|
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
|
|
@@ -5,8 +5,11 @@ import { paymentRequiredStatus, setCredentialHeader } from './Shared.js';
|
|
|
5
5
|
export function mpp() {
|
|
6
6
|
return {
|
|
7
7
|
getChallenges(response) {
|
|
8
|
+
const header = response.headers.get(Constants.Headers.wwwAuthenticate);
|
|
9
|
+
// A 402 without a Payment challenge (such as `WWW-Authenticate: X402`) is left to other protocols.
|
|
8
10
|
if (response.status !== paymentRequiredStatus ||
|
|
9
|
-
!
|
|
11
|
+
!header ||
|
|
12
|
+
!Challenge.hasPaymentScheme(header))
|
|
10
13
|
return [];
|
|
11
14
|
return Challenge.fromResponseList(response);
|
|
12
15
|
},
|
package/dist/identity/Client.js
CHANGED
|
@@ -57,7 +57,7 @@ export function from(provers) {
|
|
|
57
57
|
continue;
|
|
58
58
|
const copy = response.clone();
|
|
59
59
|
copies.push(copy);
|
|
60
|
-
if (await prover.retry(copy
|
|
60
|
+
if (await prover.retry(copy))
|
|
61
61
|
retry = true;
|
|
62
62
|
}
|
|
63
63
|
if (!retry) {
|
|
@@ -69,11 +69,11 @@ export function from(provers) {
|
|
|
69
69
|
},
|
|
70
70
|
async challenge(response, context, url) {
|
|
71
71
|
for (const prover of inScope(url)) {
|
|
72
|
-
if (!prover.
|
|
72
|
+
if (!prover.prepare)
|
|
73
73
|
continue;
|
|
74
74
|
const copy = response.clone();
|
|
75
75
|
try {
|
|
76
|
-
await prover.
|
|
76
|
+
await prover.prepare(copy, context);
|
|
77
77
|
}
|
|
78
78
|
finally {
|
|
79
79
|
release([copy]);
|
|
@@ -35,6 +35,19 @@ export type Outcome = {
|
|
|
35
35
|
* when several handlers evaluate the same request.
|
|
36
36
|
*/
|
|
37
37
|
export declare function verify(request: Request, verifiers: readonly Types.Verifier[], context?: Types.Context): Promise<Outcome>;
|
|
38
|
+
/**
|
|
39
|
+
* Collects the challenge headers of payment-stage verifiers for one 402.
|
|
40
|
+
*
|
|
41
|
+
* Each verifier's hook runs at most once per request and nonce, so every offer
|
|
42
|
+
* in a composed 402 carries the same challenge. @internal
|
|
43
|
+
*/
|
|
44
|
+
export declare function challenge(request: Request, verifiers: readonly Types.Verifier[], nonce: string): Promise<Headers>;
|
|
45
|
+
/**
|
|
46
|
+
* Returns the identity nonce for every 402 issued on one request.
|
|
47
|
+
*
|
|
48
|
+
* Composed routes call each offer with the same request, so all offers share it. @internal
|
|
49
|
+
*/
|
|
50
|
+
export declare function nonceFor(request: Request, create: () => string): string;
|
|
38
51
|
/** Builds the rejection response for a failed request-stage outcome. @internal */
|
|
39
52
|
export declare function toResponse(outcome: Exclude<Outcome, {
|
|
40
53
|
status: 'valid';
|
package/dist/identity/Server.js
CHANGED
|
@@ -90,6 +90,48 @@ export async function verify(request, verifiers, context = {}) {
|
|
|
90
90
|
reason: missing.reason ?? 'Identity verification is required.',
|
|
91
91
|
};
|
|
92
92
|
}
|
|
93
|
+
/**
|
|
94
|
+
* Collects the challenge headers of payment-stage verifiers for one 402.
|
|
95
|
+
*
|
|
96
|
+
* Each verifier's hook runs at most once per request and nonce, so every offer
|
|
97
|
+
* in a composed 402 carries the same challenge. @internal
|
|
98
|
+
*/
|
|
99
|
+
export async function challenge(request, verifiers, nonce) {
|
|
100
|
+
const headers = new Headers();
|
|
101
|
+
for (const verifier of verifiers) {
|
|
102
|
+
for (const member of leaves(verifier)) {
|
|
103
|
+
if (!member.challenge)
|
|
104
|
+
continue;
|
|
105
|
+
let byVerifier = challenges.get(request);
|
|
106
|
+
if (!byVerifier)
|
|
107
|
+
challenges.set(request, (byVerifier = new Map()));
|
|
108
|
+
let byNonce = byVerifier.get(member);
|
|
109
|
+
if (!byNonce)
|
|
110
|
+
byVerifier.set(member, (byNonce = new Map()));
|
|
111
|
+
let result = byNonce.get(nonce);
|
|
112
|
+
if (!result) {
|
|
113
|
+
result = Promise.resolve(member.challenge(request, { nonce }));
|
|
114
|
+
byNonce.set(nonce, result);
|
|
115
|
+
}
|
|
116
|
+
const init = await result;
|
|
117
|
+
if (init)
|
|
118
|
+
for (const [name, value] of new Headers(init))
|
|
119
|
+
headers.append(name, value);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return headers;
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Returns the identity nonce for every 402 issued on one request.
|
|
126
|
+
*
|
|
127
|
+
* Composed routes call each offer with the same request, so all offers share it. @internal
|
|
128
|
+
*/
|
|
129
|
+
export function nonceFor(request, create) {
|
|
130
|
+
let nonce = nonces.get(request);
|
|
131
|
+
if (!nonce)
|
|
132
|
+
nonces.set(request, (nonce = create()));
|
|
133
|
+
return nonce;
|
|
134
|
+
}
|
|
93
135
|
/** Builds the rejection response for a failed request-stage outcome. @internal */
|
|
94
136
|
export function toResponse(outcome) {
|
|
95
137
|
const headers = new Headers(outcome.headers);
|
|
@@ -112,6 +154,13 @@ export function stageOf(verifier) {
|
|
|
112
154
|
throw new TypeError(`Unknown identity verifier stage "${String(stage)}".`);
|
|
113
155
|
return stage;
|
|
114
156
|
}
|
|
157
|
+
/** Returns a verifier and, through `anyOf`, every member it wraps. */
|
|
158
|
+
function leaves(verifier) {
|
|
159
|
+
const nested = verifier[members];
|
|
160
|
+
return nested ? nested.flatMap(leaves) : [verifier];
|
|
161
|
+
}
|
|
162
|
+
const nonces = new WeakMap();
|
|
163
|
+
const challenges = new WeakMap();
|
|
115
164
|
const cache = new WeakMap();
|
|
116
165
|
function run(verifier, request, context) {
|
|
117
166
|
let byVerifier = cache.get(request);
|
|
@@ -121,7 +170,7 @@ function run(verifier, request, context) {
|
|
|
121
170
|
if (!byContext)
|
|
122
171
|
byVerifier.set(verifier, (byContext = new Map()));
|
|
123
172
|
// Decisions can depend on the credential, so each credential object gets its own entry.
|
|
124
|
-
const key = context.challenge?.id ?? ''
|
|
173
|
+
const key = `${context.challenge?.id ?? ''}|${context.nonce ?? ''}`;
|
|
125
174
|
let byCredential = byContext.get(key);
|
|
126
175
|
if (!byCredential)
|
|
127
176
|
byContext.set(key, (byCredential = new Map()));
|
package/dist/identity/Types.d.ts
CHANGED
|
@@ -5,23 +5,34 @@ import type { MaybePromise } from '../internal/types.js';
|
|
|
5
5
|
* When a verifier runs on the server.
|
|
6
6
|
*
|
|
7
7
|
* `request` verifiers run before any payment challenge is issued and reject
|
|
8
|
-
* the request with their own status code. `payment` verifiers
|
|
9
|
-
* challenge
|
|
10
|
-
*
|
|
8
|
+
* the request with their own status code. `payment` verifiers add their own
|
|
9
|
+
* challenge headers to the 402 (through `challenge`) and run on the paid
|
|
10
|
+
* retry, before the credential is settled.
|
|
11
11
|
*/
|
|
12
12
|
export type Stage = 'request' | 'payment';
|
|
13
13
|
/**
|
|
14
14
|
* Payment state passed to verifiers and provers.
|
|
15
15
|
*
|
|
16
16
|
* Request-stage verifiers receive an empty context. Payment-stage verifiers
|
|
17
|
-
* receive the challenge
|
|
18
|
-
* answered on the paid retry.
|
|
17
|
+
* receive the answered challenge, its credential and the 402's identity nonce.
|
|
19
18
|
*/
|
|
20
19
|
export type Context = {
|
|
21
|
-
/** The MPP challenge being
|
|
20
|
+
/** The MPP challenge being answered (or, for provers, selected). */
|
|
22
21
|
challenge?: Challenge.Challenge | undefined;
|
|
23
22
|
/** The payment credential submitted with the request. */
|
|
24
23
|
credential?: Credential.Credential | undefined;
|
|
24
|
+
/**
|
|
25
|
+
* The identity nonce of the 402 being answered.
|
|
26
|
+
*
|
|
27
|
+
* Every payment offer in one 402 carries the same HMAC-bound nonce, so a proof
|
|
28
|
+
* bound to it is bound to that 402 whichever offer the client pays.
|
|
29
|
+
*/
|
|
30
|
+
nonce?: string | undefined;
|
|
31
|
+
};
|
|
32
|
+
/** Context passed to a payment-stage verifier's `challenge` hook. */
|
|
33
|
+
export type ChallengeContext = {
|
|
34
|
+
/** The identity nonce shared by every payment offer in the 402. Bind the proof request to it. */
|
|
35
|
+
nonce: string;
|
|
25
36
|
};
|
|
26
37
|
/**
|
|
27
38
|
* The outcome of one identity verifier.
|
|
@@ -54,6 +65,12 @@ export type Verification<value = unknown> = {
|
|
|
54
65
|
export type Verifier<value = unknown> = {
|
|
55
66
|
/** When the verifier runs. @default 'request' */
|
|
56
67
|
stage?: Stage | undefined;
|
|
68
|
+
/**
|
|
69
|
+
* Returns the protocol's challenge headers to add to a 402, such as a proof request.
|
|
70
|
+
*
|
|
71
|
+
* Payment stage only. Runs once per 402, however many payment offers it carries.
|
|
72
|
+
*/
|
|
73
|
+
challenge?: ((request: Request, context: ChallengeContext) => MaybePromise<HeadersInit | undefined>) | undefined;
|
|
57
74
|
/** Returns the protocol's verification outcome for the request. */
|
|
58
75
|
verify: (request: Request, context: Context) => MaybePromise<Verification<value>>;
|
|
59
76
|
};
|
|
@@ -80,11 +97,16 @@ export type Prover = {
|
|
|
80
97
|
/** Returns a new request carrying the protocol's identity material. */
|
|
81
98
|
prove: (request: Request, context: ProveContext) => MaybePromise<Request>;
|
|
82
99
|
/**
|
|
83
|
-
*
|
|
100
|
+
* Prepares a proof from a 402 before the payment retry, such as answering its proof request.
|
|
84
101
|
*
|
|
85
|
-
*
|
|
86
|
-
*
|
|
102
|
+
* The context carries the selected challenge; `prove` receives the same challenge
|
|
103
|
+
* on the paid retry.
|
|
104
|
+
*/
|
|
105
|
+
prepare?: ((response: Response, context: Context) => MaybePromise<void>) | undefined;
|
|
106
|
+
/**
|
|
107
|
+
* Inspects a rejected (non-402) response. Return `true` to prove and resend the
|
|
108
|
+
* request once. Paid attempts are never resent.
|
|
87
109
|
*/
|
|
88
|
-
retry?: ((response: Response
|
|
110
|
+
retry?: ((response: Response) => MaybePromise<boolean | undefined>) | undefined;
|
|
89
111
|
};
|
|
90
112
|
//# sourceMappingURL=Types.d.ts.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.
|
|
2
|
+
export declare const version = "0.17.1";
|
|
3
3
|
/** Canonical SDK identifier for this mppx release. */
|
|
4
|
-
export declare const sdkIdentifier = "mppx/0.
|
|
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.
|
|
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`. */
|