mppx 0.16.0 → 0.17.0
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 +19 -0
- package/dist/Challenge.d.ts +6 -0
- package/dist/Challenge.js +12 -0
- package/dist/Method.d.ts +2 -0
- package/dist/cli/cli.js +31 -25
- package/dist/cli/plugins/index.js +11 -11
- package/dist/cli/validate/payment.js +39 -15
- 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/version.d.ts +2 -2
- package/dist/internal/version.js +1 -1
- package/dist/server/Mppx.d.ts +2 -2
- package/dist/server/Mppx.js +49 -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 +11 -0
- package/dist/tempo/client/Charge.js +30 -1
- 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 +12 -1
- package/dist/tempo/server/internal/html.gen.js +1 -1
- package/package.json +1 -1
|
@@ -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) {
|
|
@@ -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
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
/** Current mppx package version. */
|
|
2
|
-
export declare const version = "0.
|
|
2
|
+
export declare const version = "0.17.0";
|
|
3
3
|
/** Canonical SDK identifier for this mppx release. */
|
|
4
|
-
export declare const sdkIdentifier = "mppx/0.
|
|
4
|
+
export declare const sdkIdentifier = "mppx/0.17.0";
|
|
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.0';
|
|
4
4
|
/** Canonical SDK identifier for this mppx release. */
|
|
5
5
|
export const sdkIdentifier = `mppx/${version}`;
|
|
6
6
|
//# sourceMappingURL=version.js.map
|
package/dist/server/Mppx.d.ts
CHANGED
|
@@ -412,8 +412,8 @@ type ConfiguredHandler<transport extends Transport.AnyTransport = Transport.Http
|
|
|
412
412
|
scope?: string | undefined;
|
|
413
413
|
_canonicalRequest: Record<string, unknown>;
|
|
414
414
|
_stableBinding?: Method.StableBindingFn<Method.Method> | undefined;
|
|
415
|
-
/**
|
|
416
|
-
_paymentIdentity?:
|
|
415
|
+
/** The offer's payment-stage identity verifiers, if any. */
|
|
416
|
+
_paymentIdentity?: readonly Identity.Verifier[] | undefined;
|
|
417
417
|
_transport: Transport.AnyTransport;
|
|
418
418
|
};
|
|
419
419
|
};
|
package/dist/server/Mppx.js
CHANGED
|
@@ -467,7 +467,7 @@ function createMethodFn(parameters) {
|
|
|
467
467
|
]),
|
|
468
468
|
],
|
|
469
469
|
_canonicalRequest: PaymentRequest.fromMethod(method, { ...defaults, ...rest }),
|
|
470
|
-
_paymentIdentity: paymentVerifiers.length > 0,
|
|
470
|
+
_paymentIdentity: paymentVerifiers.length > 0 ? paymentVerifiers : undefined,
|
|
471
471
|
_stableBinding: stableBinding,
|
|
472
472
|
_transport: transport,
|
|
473
473
|
};
|
|
@@ -500,6 +500,12 @@ function createMethodFn(parameters) {
|
|
|
500
500
|
const effectiveMeta = scope === undefined && input instanceof globalThis.Request
|
|
501
501
|
? Scope.merge({ meta: staticMeta, scope: Scope.get(input) })
|
|
502
502
|
: staticMeta;
|
|
503
|
+
// Every offer of one 402 shares the identity nonce (composed routes pass each offer the
|
|
504
|
+
// same request), so a payment-stage proof is bound to the 402, whichever offer is paid.
|
|
505
|
+
const identityNonce = paymentVerifiers.length > 0 && input instanceof globalThis.Request
|
|
506
|
+
? IdentityServer.nonceFor(input, ChallengeMeta.createNonce)
|
|
507
|
+
: undefined;
|
|
508
|
+
const challengeMeta = ChallengeMeta.withIdentity(effectiveMeta, identityNonce);
|
|
503
509
|
// Extract credential once — getCredential may have side effects (e.g. SSE transports).
|
|
504
510
|
let [credential, credentialError] = (() => {
|
|
505
511
|
try {
|
|
@@ -540,17 +546,12 @@ function createMethodFn(parameters) {
|
|
|
540
546
|
...(parameters.html && { html: parameters.html }),
|
|
541
547
|
});
|
|
542
548
|
// Payment-stage verifiers add their own challenge headers to the 402.
|
|
543
|
-
if (
|
|
549
|
+
if (identityNonce !== undefined &&
|
|
544
550
|
input instanceof globalThis.Request &&
|
|
545
551
|
response instanceof globalThis.Response) {
|
|
546
|
-
const
|
|
547
|
-
challenge: parameters.challenge,
|
|
548
|
-
});
|
|
549
|
-
const identityHeaders = new Headers();
|
|
550
|
-
if (outcome.status !== 'valid')
|
|
551
|
-
for (const [name, value] of outcome.headers)
|
|
552
|
-
identityHeaders.append(name, value);
|
|
552
|
+
const identityHeaders = await IdentityServer.challenge(input, paymentVerifiers, identityNonce);
|
|
553
553
|
if ([...identityHeaders].length > 0) {
|
|
554
|
+
const paymentChallenge = response.headers.get(paymentAuthChallengeHeader);
|
|
554
555
|
const headers = new Headers(response.headers);
|
|
555
556
|
for (const [name, value] of identityHeaders)
|
|
556
557
|
headers.append(name, value);
|
|
@@ -560,6 +561,7 @@ function createMethodFn(parameters) {
|
|
|
560
561
|
statusText: response.statusText,
|
|
561
562
|
});
|
|
562
563
|
identityChallenges.set(response, identityHeaders);
|
|
564
|
+
paymentChallenges.set(response, paymentChallenge);
|
|
563
565
|
}
|
|
564
566
|
}
|
|
565
567
|
if (isIssuedChallengeResponse(response))
|
|
@@ -594,7 +596,7 @@ function createMethodFn(parameters) {
|
|
|
594
596
|
description,
|
|
595
597
|
expires,
|
|
596
598
|
header: credentialHeader,
|
|
597
|
-
meta:
|
|
599
|
+
meta: challengeMeta,
|
|
598
600
|
method,
|
|
599
601
|
realm,
|
|
600
602
|
request: parameters.request,
|
|
@@ -609,7 +611,7 @@ function createMethodFn(parameters) {
|
|
|
609
611
|
description,
|
|
610
612
|
expires,
|
|
611
613
|
header: credentialHeader,
|
|
612
|
-
meta:
|
|
614
|
+
meta: challengeMeta,
|
|
613
615
|
method,
|
|
614
616
|
realm,
|
|
615
617
|
routeRequest: rest,
|
|
@@ -897,6 +899,7 @@ function createMethodFn(parameters) {
|
|
|
897
899
|
const outcome = await IdentityServer.verify(input, paymentVerifiers, {
|
|
898
900
|
challenge: credential.challenge,
|
|
899
901
|
credential: parsedCredential,
|
|
902
|
+
nonce: ChallengeMeta.identity(credential.challenge.meta),
|
|
900
903
|
});
|
|
901
904
|
if (outcome.status !== 'valid') {
|
|
902
905
|
const error = new Errors.VerificationFailedError({ reason: outcome.reason });
|
|
@@ -1348,6 +1351,7 @@ async function resolveRouteChallenge(parameters) {
|
|
|
1348
1351
|
? (await parameters.request({
|
|
1349
1352
|
capturedRequest: parameters.capturedRequest,
|
|
1350
1353
|
credential: parameters.credential,
|
|
1354
|
+
expires: parameters.credential?.challenge.expires ?? parameters.expires,
|
|
1351
1355
|
request: merged,
|
|
1352
1356
|
}))
|
|
1353
1357
|
: merged;
|
|
@@ -1588,17 +1592,30 @@ const paymentAuthChallengeHeader = Constants.Headers.wwwAuthenticate;
|
|
|
1588
1592
|
const identityChallenges = new WeakMap();
|
|
1589
1593
|
/** Identity rejections (401/403) returned on a route's non-success branch. @internal */
|
|
1590
1594
|
const identityRejections = new WeakSet();
|
|
1595
|
+
/**
|
|
1596
|
+
* A 402's Payment `WWW-Authenticate` value without its identity challenge, keyed by response.
|
|
1597
|
+
* Composers merge these and append the shared identity challenge once. @internal
|
|
1598
|
+
*/
|
|
1599
|
+
const paymentChallenges = new WeakMap();
|
|
1591
1600
|
/** Records a merged 402's identity challenge so an outer composer can forward it. @internal */
|
|
1592
|
-
function withIdentityChallenge(response, headers) {
|
|
1593
|
-
if (headers)
|
|
1601
|
+
function withIdentityChallenge(response, headers, paymentChallenge) {
|
|
1602
|
+
if (headers) {
|
|
1594
1603
|
identityChallenges.set(response, headers);
|
|
1604
|
+
paymentChallenges.set(response, paymentChallenge);
|
|
1605
|
+
}
|
|
1595
1606
|
return response;
|
|
1596
1607
|
}
|
|
1597
1608
|
const challengeHeaderMerges = [
|
|
1598
1609
|
{
|
|
1599
1610
|
name: paymentAuthChallengeHeader,
|
|
1600
1611
|
values: (context) => context.challengeEntries
|
|
1601
|
-
.map((entry) =>
|
|
1612
|
+
.map((entry) => {
|
|
1613
|
+
const response = entry.result.challenge;
|
|
1614
|
+
// Identity challenges are appended once by the composer, not once per offer.
|
|
1615
|
+
return paymentChallenges.has(response)
|
|
1616
|
+
? paymentChallenges.get(response)
|
|
1617
|
+
: response.headers.get(paymentAuthChallengeHeader);
|
|
1618
|
+
})
|
|
1602
1619
|
.filter((value) => value !== null),
|
|
1603
1620
|
merge: (values) => values,
|
|
1604
1621
|
},
|
|
@@ -1639,9 +1656,13 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
|
|
|
1639
1656
|
});
|
|
1640
1657
|
if (handlers.length === 0)
|
|
1641
1658
|
throw new Error('compose() requires at least one handler');
|
|
1642
|
-
// A 402 carries one identity challenge,
|
|
1643
|
-
|
|
1644
|
-
|
|
1659
|
+
// A 402 carries one identity challenge, shared by its offers through the identity nonce, so
|
|
1660
|
+
// every offer that requires payment-stage identity must require the same verifiers.
|
|
1661
|
+
const identityOffers = handlers
|
|
1662
|
+
.flatMap(getConfiguredOffers)
|
|
1663
|
+
.flatMap((offer) => (offer._paymentIdentity ? [offer._paymentIdentity] : []));
|
|
1664
|
+
if (identityOffers.some((verifiers) => !sameVerifiers(verifiers, identityOffers[0])))
|
|
1665
|
+
throw new TypeError('Offers in a composed route must require the same payment-stage identity verifiers.');
|
|
1645
1666
|
const offerSelector = selectOffers;
|
|
1646
1667
|
const offerEntries = handlers.map((handler) => {
|
|
1647
1668
|
const internal = handler._internal;
|
|
@@ -1809,8 +1830,8 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
|
|
|
1809
1830
|
...unnegotiatedX402Responses,
|
|
1810
1831
|
]
|
|
1811
1832
|
: challengeResponses;
|
|
1812
|
-
//
|
|
1813
|
-
//
|
|
1833
|
+
// Offers share one identity challenge (enforced when composing); forward it only if an
|
|
1834
|
+
// offer that carries it is still advertised after negotiation.
|
|
1814
1835
|
const identityHeaders = negotiatedChallengeResponses
|
|
1815
1836
|
.map((response) => identityChallenges.get(response))
|
|
1816
1837
|
.find((headers) => headers !== undefined);
|
|
@@ -1826,11 +1847,11 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
|
|
|
1826
1847
|
mergedHeaders.append(header.name, value);
|
|
1827
1848
|
}
|
|
1828
1849
|
}
|
|
1829
|
-
//
|
|
1850
|
+
// The merged offer values above exclude identity challenges; append the shared one once.
|
|
1851
|
+
const paymentChallenge = mergedHeaders.get(paymentAuthChallengeHeader);
|
|
1830
1852
|
if (identityHeaders)
|
|
1831
1853
|
for (const [name, value] of identityHeaders)
|
|
1832
|
-
|
|
1833
|
-
mergedHeaders.append(name, value);
|
|
1854
|
+
mergedHeaders.append(name, value);
|
|
1834
1855
|
// Collect html-enabled handlers and their challenges
|
|
1835
1856
|
const htmlEntries = challengeEntries.filter((entry) => entry.handler._internal?.html);
|
|
1836
1857
|
const wantsHtml = input.headers.get('Accept')?.includes('text/html');
|
|
@@ -1867,7 +1888,7 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
|
|
|
1867
1888
|
});
|
|
1868
1889
|
return {
|
|
1869
1890
|
status: 402,
|
|
1870
|
-
challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders),
|
|
1891
|
+
challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders, paymentChallenge),
|
|
1871
1892
|
};
|
|
1872
1893
|
}
|
|
1873
1894
|
// Non-HTML fallback: prefer the first Payment-auth body, otherwise use
|
|
@@ -1887,7 +1908,7 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
|
|
|
1887
1908
|
}
|
|
1888
1909
|
return {
|
|
1889
1910
|
status: 402,
|
|
1890
|
-
challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders),
|
|
1911
|
+
challenge: withIdentityChallenge(new Response(body, { status: 402, headers: mergedHeaders }), identityHeaders, paymentChallenge),
|
|
1891
1912
|
};
|
|
1892
1913
|
};
|
|
1893
1914
|
const offers = handlers.flatMap(getConfiguredOffers);
|
|
@@ -1896,6 +1917,10 @@ function composeHandlers(handlers, composeOptions, selectOffers) {
|
|
|
1896
1917
|
return composed;
|
|
1897
1918
|
}
|
|
1898
1919
|
/** Returns the flattened configured offers accepted by a handler. */
|
|
1920
|
+
/** Whether two verifier lists hold the same verifiers in the same order. */
|
|
1921
|
+
function sameVerifiers(a, b) {
|
|
1922
|
+
return a.length === b.length && a.every((verifier, i) => verifier === b[i]);
|
|
1923
|
+
}
|
|
1899
1924
|
function getConfiguredOffers(handler) {
|
|
1900
1925
|
const internal = handler._internal;
|
|
1901
1926
|
if (!internal)
|
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
/** Adds fresh issuance randomness while preserving application and route metadata. */
|
|
2
2
|
export declare function withNonce(meta: Record<string, string> | undefined): Record<string, string>;
|
|
3
|
+
/** Returns fresh randomness for an identity nonce. */
|
|
4
|
+
export declare function createNonce(): string;
|
|
5
|
+
/** Adds an identity nonce to challenge metadata. */
|
|
6
|
+
export declare function withIdentity(meta: Record<string, string> | undefined, nonce: string | undefined): Record<string, string> | undefined;
|
|
7
|
+
/** Reads the identity nonce from challenge metadata. */
|
|
8
|
+
export declare function identity(meta: Record<string, string> | undefined): string | undefined;
|
|
3
9
|
/** Removes issuance-only metadata when comparing stable route requirements. */
|
|
4
10
|
export declare function routeMeta(meta: Record<string, string> | undefined): Record<string, string> | undefined;
|
|
5
11
|
//# sourceMappingURL=challengeMeta.d.ts.map
|
|
@@ -6,9 +6,25 @@ export function withNonce(meta) {
|
|
|
6
6
|
// Ox uses runtime-specific secure randomness, including Node without global Web Crypto.
|
|
7
7
|
return { ...meta, [nonceKey]: Secp256k1.randomPrivateKey() };
|
|
8
8
|
}
|
|
9
|
+
/** Reserved metadata key for the HMAC-bound identity nonce shared by every offer in one 402. */
|
|
10
|
+
const identityKey = '_mppx_identity';
|
|
11
|
+
/** Returns fresh randomness for an identity nonce. */
|
|
12
|
+
export function createNonce() {
|
|
13
|
+
return Secp256k1.randomPrivateKey();
|
|
14
|
+
}
|
|
15
|
+
/** Adds an identity nonce to challenge metadata. */
|
|
16
|
+
export function withIdentity(meta, nonce) {
|
|
17
|
+
if (nonce === undefined)
|
|
18
|
+
return meta;
|
|
19
|
+
return { ...meta, [identityKey]: nonce };
|
|
20
|
+
}
|
|
21
|
+
/** Reads the identity nonce from challenge metadata. */
|
|
22
|
+
export function identity(meta) {
|
|
23
|
+
return meta?.[identityKey];
|
|
24
|
+
}
|
|
9
25
|
/** Removes issuance-only metadata when comparing stable route requirements. */
|
|
10
26
|
export function routeMeta(meta) {
|
|
11
|
-
const { [nonceKey]: _, ...rest } = meta ?? {};
|
|
27
|
+
const { [nonceKey]: _, [identityKey]: __, ...rest } = meta ?? {};
|
|
12
28
|
return Object.keys(rest).length ? rest : undefined;
|
|
13
29
|
}
|
|
14
30
|
//# sourceMappingURL=challengeMeta.js.map
|
|
@@ -6,6 +6,17 @@ import * as z from '../../zod.js';
|
|
|
6
6
|
import * as AutoSwap from '../internal/auto-swap.js';
|
|
7
7
|
import * as Methods from '../Methods.js';
|
|
8
8
|
import type * as AccountResolution from './ResolveAccount.js';
|
|
9
|
+
/**
|
|
10
|
+
* Seconds from now the client sets as the expiring-nonce `validBefore`,
|
|
11
|
+
* capped at the challenge expiry.
|
|
12
|
+
*
|
|
13
|
+
* Fee payers require at least `defaults.minRemainingValiditySeconds` (15s) of
|
|
14
|
+
* remaining validity when they co-sign, so a 25s window leaves about 10s for
|
|
15
|
+
* clock skew between client and server, signing and round trips. It stays inside
|
|
16
|
+
* the server's default 30s confirmation timeout and within sponsor policies whose
|
|
17
|
+
* `maxValidityWindowSeconds` is 25 or more.
|
|
18
|
+
*/
|
|
19
|
+
export declare const validityWindowSeconds = 25;
|
|
9
20
|
/** Runtime context accepted by the Tempo charge client method. */
|
|
10
21
|
export type ChargeContext = {
|
|
11
22
|
account?: Account.getResolver.Parameters['account'] | undefined;
|
|
@@ -17,6 +17,17 @@ import { defaultFeeTokens, resolveFeeToken } from '../internal/fee-token.js';
|
|
|
17
17
|
import * as MachineTokenCharge from '../internal/machine-token-charge.js';
|
|
18
18
|
import * as Proof from '../internal/proof.js';
|
|
19
19
|
import * as Methods from '../Methods.js';
|
|
20
|
+
/**
|
|
21
|
+
* Seconds from now the client sets as the expiring-nonce `validBefore`,
|
|
22
|
+
* capped at the challenge expiry.
|
|
23
|
+
*
|
|
24
|
+
* Fee payers require at least `defaults.minRemainingValiditySeconds` (15s) of
|
|
25
|
+
* remaining validity when they co-sign, so a 25s window leaves about 10s for
|
|
26
|
+
* clock skew between client and server, signing and round trips. It stays inside
|
|
27
|
+
* the server's default 30s confirmation timeout and within sponsor policies whose
|
|
28
|
+
* `maxValidityWindowSeconds` is 25 or more.
|
|
29
|
+
*/
|
|
30
|
+
export const validityWindowSeconds = 25;
|
|
20
31
|
const chargeContextSchema = z.object({
|
|
21
32
|
account: z.optional(z.custom()),
|
|
22
33
|
autoSwap: z.optional(z.custom()),
|
|
@@ -199,7 +210,7 @@ export function charge(parameters = {}) {
|
|
|
199
210
|
return supportedModes[0];
|
|
200
211
|
})();
|
|
201
212
|
const validBefore = (() => {
|
|
202
|
-
const defaultExpiry = Math.floor(Date.now() / 1000) +
|
|
213
|
+
const defaultExpiry = Math.floor(Date.now() / 1000) + validityWindowSeconds;
|
|
203
214
|
if (!challenge.expires)
|
|
204
215
|
return defaultExpiry;
|
|
205
216
|
// `validBefore` is an exclusive whole-second bound, so cap at the second
|
|
@@ -236,6 +247,22 @@ export function charge(parameters = {}) {
|
|
|
236
247
|
source: Proof.proofSource({ address: account.address, chainId }),
|
|
237
248
|
});
|
|
238
249
|
}
|
|
250
|
+
// The fee payer rejects a sponsored transaction with under the minimum remaining validity,
|
|
251
|
+
// so refuse before signing rather than produce a credential the server must reject.
|
|
252
|
+
// The fee payer checks the signed `validBefore`, which is fixed before preparation, so the
|
|
253
|
+
// re-check measures both it and the challenge expiry against the current time.
|
|
254
|
+
const assertSponsorable = () => {
|
|
255
|
+
if (methodDetails?.feePayer !== true)
|
|
256
|
+
return;
|
|
257
|
+
const minimumMs = defaults.minRemainingValiditySeconds * 1000;
|
|
258
|
+
const expiresMs = challenge.expires ? new Date(challenge.expires).getTime() : Infinity;
|
|
259
|
+
const now = Date.now();
|
|
260
|
+
if (!(expiresMs - now >= minimumMs))
|
|
261
|
+
throw new Error(`Sponsored challenge expires in less than ${defaults.minRemainingValiditySeconds} seconds; the fee payer would reject the transaction. Request a new challenge.`);
|
|
262
|
+
if (!(validBefore * 1000 - now >= minimumMs))
|
|
263
|
+
throw new Error(`Sponsored transaction preparation took too long: less than ${defaults.minRemainingValiditySeconds} seconds of its validity window remain, so the fee payer would reject it.`);
|
|
264
|
+
};
|
|
265
|
+
assertSponsorable();
|
|
239
266
|
const prepared = await prepareTransactionRequest(client, {
|
|
240
267
|
account,
|
|
241
268
|
calls,
|
|
@@ -252,6 +279,8 @@ export function charge(parameters = {}) {
|
|
|
252
279
|
delete sponsored.feeToken;
|
|
253
280
|
sponsored.feePayer = true;
|
|
254
281
|
}
|
|
282
|
+
// Preparation is asynchronous: check again immediately before signing.
|
|
283
|
+
assertSponsorable();
|
|
255
284
|
const signature = await signTransaction(client, prepared);
|
|
256
285
|
return Credential.serialize({
|
|
257
286
|
challenge,
|
|
@@ -37,6 +37,15 @@ export declare const machineToken: Readonly<{
|
|
|
37
37
|
* runtimes should set `decimals` explicitly to match their token.
|
|
38
38
|
*/
|
|
39
39
|
export declare const decimals = 6;
|
|
40
|
+
/**
|
|
41
|
+
* Minimum remaining validity, in seconds, of a fee-sponsored transaction.
|
|
42
|
+
*
|
|
43
|
+
* Servers refuse to co-sign a sponsored transaction whose `validBefore` is
|
|
44
|
+
* closer than this, clients refuse to sign for a sponsored challenge that
|
|
45
|
+
* expires sooner, and servers refuse to issue a sponsored challenge that
|
|
46
|
+
* expires sooner after issuance.
|
|
47
|
+
*/
|
|
48
|
+
export declare const minRemainingValiditySeconds = 15;
|
|
40
49
|
/** Default payment-channel escrow contract addresses per chain. */
|
|
41
50
|
export declare const escrowContract: {
|
|
42
51
|
readonly 4217: '0x33b901018174DDabE4841042ab76ba85D4e24f25';
|
|
@@ -37,6 +37,15 @@ export const machineToken = Object.freeze({
|
|
|
37
37
|
* runtimes should set `decimals` explicitly to match their token.
|
|
38
38
|
*/
|
|
39
39
|
export const decimals = 6;
|
|
40
|
+
/**
|
|
41
|
+
* Minimum remaining validity, in seconds, of a fee-sponsored transaction.
|
|
42
|
+
*
|
|
43
|
+
* Servers refuse to co-sign a sponsored transaction whose `validBefore` is
|
|
44
|
+
* closer than this, clients refuse to sign for a sponsored challenge that
|
|
45
|
+
* expires sooner, and servers refuse to issue a sponsored challenge that
|
|
46
|
+
* expires sooner after issuance.
|
|
47
|
+
*/
|
|
48
|
+
export const minRemainingValiditySeconds = 15;
|
|
40
49
|
/** Default payment-channel escrow contract addresses per chain. */
|
|
41
50
|
export const escrowContract = {
|
|
42
51
|
[chainId.mainnet]: '0x33b901018174DDabE4841042ab76ba85D4e24f25',
|