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.
@@ -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.has(Constants.Headers.paymentReceipt))
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
- let pending = '';
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
- pending += text;
755
- // Keep a trailing CR: the next chunk may complete a CRLF.
756
- const end = done || !pending.endsWith('\r') ? pending.length : pending.length - 1;
757
- const events = pending
758
- .slice(0, end)
759
- .replace(/\r\n|\r/g, '\n')
760
- .split('\n\n');
761
- const rest = done ? '' : (events.pop() ?? '');
762
- if (skipping && (events.length > 0 || done)) {
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
- pending = rest + pending.slice(end);
767
- if (pending.length > maxPendingEventBytes) {
768
- // Keep a trailing line ending: it may be the first half of the event's boundary.
769
- pending = /[\r\n]*$/.exec(pending)?.[0] ?? '';
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
- !response.headers.has(Constants.Headers.wwwAuthenticate))
11
+ !header ||
12
+ !Challenge.hasPaymentScheme(header))
10
13
  return [];
11
14
  return Challenge.fromResponseList(response);
12
15
  },
@@ -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, context))
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.retry)
72
+ if (!prover.prepare)
73
73
  continue;
74
74
  const copy = response.clone();
75
75
  try {
76
- await prover.retry(copy, context);
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';
@@ -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()));
@@ -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 run when a
9
- * challenge is issued (to add their own challenge headers to the 402) and
10
- * again on the paid retry, before the credential is settled.
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 being issued, or the challenge and credential being
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 issued or answered. */
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
- * Inspects a rejected or 402 response and prepares a proof for the next attempt.
100
+ * Prepares a proof from a 402 before the payment retry, such as answering its proof request.
84
101
  *
85
- * Return `true` to resend a rejected (non-402) request once. On a 402 the
86
- * context carries the selected challenge and the payment retry always follows.
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, context: Context) => MaybePromise<boolean | undefined>) | undefined;
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.16.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.16.0";
4
+ export declare const sdkIdentifier = "mppx/0.17.0";
5
5
  //# sourceMappingURL=version.d.ts.map
@@ -1,6 +1,6 @@
1
1
  // Generated by scripts/sync-version.ts.
2
2
  /** Current mppx package version. */
3
- export const version = '0.16.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
@@ -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
- /** Whether the offer requires payment-stage identity. */
416
- _paymentIdentity?: boolean | undefined;
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
  };
@@ -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 (paymentVerifiers.length > 0 &&
549
+ if (identityNonce !== undefined &&
544
550
  input instanceof globalThis.Request &&
545
551
  response instanceof globalThis.Response) {
546
- const outcome = await IdentityServer.verify(input, paymentVerifiers, {
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: effectiveMeta,
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: effectiveMeta,
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) => entry.result.challenge.headers.get(paymentAuthChallengeHeader))
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, bound to one payment challenge.
1643
- if (handlers.flatMap(getConfiguredOffers).filter((offer) => offer._paymentIdentity).length > 1)
1644
- throw new TypeError('A composed route can include at most one offer with payment-stage identity.');
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
- // At most one offer carries an identity challenge (enforced when composing); forward it
1813
- // only if its payment challenge is still advertised after negotiation.
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
- // Identity WWW-Authenticate values already arrive in the merged offer value above.
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
- if (name !== paymentAuthChallengeHeader.toLowerCase())
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) + 25;
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',