@primitivedotdev/sdk 1.10.0 → 1.12.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.
@@ -197,12 +197,32 @@ declare function toPaymentPayload(network: string, auth: TransferAuthorization,
197
197
  */
198
198
  declare const DEFAULT_MAX_WINDOW_SEC: number;
199
199
  /**
200
- * Compute the EIP-3009 validity window for a payment. `validBefore` is the
201
- * value that governs on-chain validity, so it MUST cover the challenge's
202
- * `expires_at` plus a settlement margin; `validAfter` is set generously in the
203
- * past for clock skew. The total window is hard-capped at `maxWindowSec` so
204
- * neither a far-future `challengeExpiresAtSec` nor a widened margin can produce
205
- * a window the platform verifier would later reject.
200
+ * Minimum headroom between now and `validBefore`. The platform rejects a
201
+ * payment whose authorization is about to expire (it needs SMTP + DKIM + verify
202
+ * + settle latency to clear), so a `validBefore` less than this far in the
203
+ * future is a guaranteed-to-fail signature. The default window is minutes; this
204
+ * 60s floor is the absolute minimum the band tolerates.
205
+ */
206
+ declare const DEFAULT_MIN_SETTLEMENT_HEADROOM_SEC = 60;
207
+ /**
208
+ * Compute the EIP-3009 validity window for a payment, landing inside the band
209
+ * the platform accepts. `validBefore` governs on-chain validity, so it MUST
210
+ * stay far enough in the future to settle (>= `minHeadroomSec`) yet not so far
211
+ * that the total window exceeds the `maxWindowSec` cap; `validAfter` is set
212
+ * generously in the past for clock skew.
213
+ *
214
+ * Both ends of that band are payer landmines: a too-tight `validBefore` (low
215
+ * headroom, e.g. a near-expired challenge) is rejected for being about to
216
+ * expire, and a too-wide window (far-future expiry) is rejected as
217
+ * "authorization window too wide". By default this clamps the computed window
218
+ * into the band so a caller who does not override always gets a signable
219
+ * window.
220
+ *
221
+ * If the caller passes an explicit `validBeforeSec` or `validAfterSec`, that is
222
+ * an intent to pin the bound: when it falls outside the band this throws a
223
+ * specific error naming which bound was violated (rather than silently signing
224
+ * a doomed authorization), unless `clamp` is left enabled, in which case the
225
+ * pinned value is clamped into the band like the computed one.
206
226
  */
207
227
  declare function computePaymentValidityWindow(params: {
208
228
  /** The challenge's expires_at, unix seconds. */challengeExpiresAtSec: number; /** Current time, unix seconds. */
@@ -210,6 +230,27 @@ declare function computePaymentValidityWindow(params: {
210
230
  settlementMarginSec?: number; /** How far in the past to set validAfter for clock skew. Default 5 min. */
211
231
  clockSkewSec?: number; /** Hard ceiling on validBefore - validAfter. Default 24h. */
212
232
  maxWindowSec?: number;
233
+ /**
234
+ * Minimum `validBefore - nowSec`. Default 60s. A signature with less headroom
235
+ * cannot clear the SMTP+DKIM+settle latency and the platform rejects it.
236
+ */
237
+ minHeadroomSec?: number;
238
+ /**
239
+ * Explicit override for `validBefore` (unix seconds). When omitted it is
240
+ * derived from `challengeExpiresAtSec + settlementMarginSec`.
241
+ */
242
+ validBeforeSec?: number;
243
+ /**
244
+ * Explicit override for `validAfter` (unix seconds). When omitted it is
245
+ * derived from `nowSec - clockSkewSec`.
246
+ */
247
+ validAfterSec?: number;
248
+ /**
249
+ * When true (the default), an out-of-band window is clamped into the accepted
250
+ * band instead of throwing. Set `false` to reject a caller-pinned override
251
+ * that is out of band with a specific error rather than silently moving it.
252
+ */
253
+ clamp?: boolean;
213
254
  }): {
214
255
  validAfter: bigint;
215
256
  validBefore: bigint;
@@ -401,6 +442,27 @@ declare class X402Error extends Error {
401
442
  retryAfter?: string | null;
402
443
  });
403
444
  }
445
+ /**
446
+ * Parse the bytes of an inbound `interaction.json` MIME part into a typed
447
+ * {@link X402EmailChallenge} ready for {@link X402Client.payEmailChallenge}.
448
+ *
449
+ * A payer receives the x402 challenge as an `interaction.json` attachment on an
450
+ * inbound email (filename `interaction.json`, content type `application/json`).
451
+ * This validates the envelope (the strict snake_case wire shape, that it is the
452
+ * `x402.payment` `challenge` step, and that the embedded payload carries the
453
+ * fields a payer signs over) and re-assembles the nonce binding from the
454
+ * envelope's `interaction_id` + `step_id` + the payload's `challenge_nonce`, so
455
+ * the caller never has to hand-parse the part.
456
+ *
457
+ * Accepts the part body as a UTF-8 string, a `Uint8Array`/`Buffer`, or an
458
+ * already-parsed envelope object. Throws {@link X402Error} (status 0) on any
459
+ * malformed or non-challenge part.
460
+ *
461
+ * The resulting `challenge_id` is empty: the platform's private challenge id is
462
+ * not carried on the wire, and `payEmailChallenge` does not need it (it binds to
463
+ * the `interaction_id` and the challenge step id).
464
+ */
465
+ declare function parseEmailChallengeFromPart(part: string | Uint8Array | Record<string, unknown>): X402EmailChallenge;
404
466
  interface X402ClientOptions {
405
467
  /** API key. Defaults to `process.env.PRIMITIVE_API_KEY`. */
406
468
  apiKey?: string;
@@ -486,4 +548,4 @@ declare class X402Client {
486
548
  }
487
549
  declare function createX402Client(options?: X402ClientOptions): X402Client;
488
550
  //#endregion
489
- export { BuiltPaymentStep, DEFAULT_MAX_WINDOW_SEC, InteractionEnvelope, NonceBinding, PayoutRegistrationMessageInput, TRANSFER_WITH_AUTHORIZATION_TYPES, TokenDomain, TransferAuthorization, TransferWithAuthorizationTypedData, X402Challenge, X402ChargeInput, X402Client, X402ClientOptions, X402DeclinedPayment, X402EmailChallenge, X402EmailChallengeDetails, X402EmailChargeInput, X402Error, X402Network, X402NonceBinding, X402PaymentPayload, X402PaymentRequirements, X402PaymentStepPayload, X402PayoutAddress, X402Receipt, X402Signer, X402SpendPolicy, X402_INTERACTION_PROTOCOL, X402_INTERACTION_PROTOCOL_VERSION, buildExactEvmPaymentPayload, buildPaymentStepEnvelope, buildPayoutRegistrationMessage, computePaymentValidityWindow, createX402Client, deriveEip3009Nonce, signInteractionPayment, toPaymentPayload, transferWithAuthorizationTypedData };
551
+ export { BuiltPaymentStep, DEFAULT_MAX_WINDOW_SEC, DEFAULT_MIN_SETTLEMENT_HEADROOM_SEC, InteractionEnvelope, NonceBinding, PayoutRegistrationMessageInput, TRANSFER_WITH_AUTHORIZATION_TYPES, TokenDomain, TransferAuthorization, TransferWithAuthorizationTypedData, X402Challenge, X402ChargeInput, X402Client, X402ClientOptions, X402DeclinedPayment, X402EmailChallenge, X402EmailChallengeDetails, X402EmailChargeInput, X402Error, X402Network, X402NonceBinding, X402PaymentPayload, X402PaymentRequirements, X402PaymentStepPayload, X402PayoutAddress, X402Receipt, X402Signer, X402SpendPolicy, X402_INTERACTION_PROTOCOL, X402_INTERACTION_PROTOCOL_VERSION, buildExactEvmPaymentPayload, buildPaymentStepEnvelope, buildPayoutRegistrationMessage, computePaymentValidityWindow, createX402Client, deriveEip3009Nonce, parseEmailChallengeFromPart, signInteractionPayment, toPaymentPayload, transferWithAuthorizationTypedData };
@@ -164,24 +164,55 @@ function toPaymentPayload(network, auth, signature) {
164
164
  */
165
165
  const DEFAULT_MAX_WINDOW_SEC = 1440 * 60;
166
166
  /**
167
- * Compute the EIP-3009 validity window for a payment. `validBefore` is the
168
- * value that governs on-chain validity, so it MUST cover the challenge's
169
- * `expires_at` plus a settlement margin; `validAfter` is set generously in the
170
- * past for clock skew. The total window is hard-capped at `maxWindowSec` so
171
- * neither a far-future `challengeExpiresAtSec` nor a widened margin can produce
172
- * a window the platform verifier would later reject.
167
+ * Minimum headroom between now and `validBefore`. The platform rejects a
168
+ * payment whose authorization is about to expire (it needs SMTP + DKIM + verify
169
+ * + settle latency to clear), so a `validBefore` less than this far in the
170
+ * future is a guaranteed-to-fail signature. The default window is minutes; this
171
+ * 60s floor is the absolute minimum the band tolerates.
172
+ */
173
+ const DEFAULT_MIN_SETTLEMENT_HEADROOM_SEC = 60;
174
+ /**
175
+ * Compute the EIP-3009 validity window for a payment, landing inside the band
176
+ * the platform accepts. `validBefore` governs on-chain validity, so it MUST
177
+ * stay far enough in the future to settle (>= `minHeadroomSec`) yet not so far
178
+ * that the total window exceeds the `maxWindowSec` cap; `validAfter` is set
179
+ * generously in the past for clock skew.
180
+ *
181
+ * Both ends of that band are payer landmines: a too-tight `validBefore` (low
182
+ * headroom, e.g. a near-expired challenge) is rejected for being about to
183
+ * expire, and a too-wide window (far-future expiry) is rejected as
184
+ * "authorization window too wide". By default this clamps the computed window
185
+ * into the band so a caller who does not override always gets a signable
186
+ * window.
187
+ *
188
+ * If the caller passes an explicit `validBeforeSec` or `validAfterSec`, that is
189
+ * an intent to pin the bound: when it falls outside the band this throws a
190
+ * specific error naming which bound was violated (rather than silently signing
191
+ * a doomed authorization), unless `clamp` is left enabled, in which case the
192
+ * pinned value is clamped into the band like the computed one.
173
193
  */
174
194
  function computePaymentValidityWindow(params) {
175
195
  const margin = params.settlementMarginSec ?? 300;
176
196
  const skew = params.clockSkewSec ?? 300;
177
- const validBefore = BigInt(params.challengeExpiresAtSec + margin);
178
- const validAfter = BigInt(params.nowSec - skew);
179
- if (validBefore <= validAfter) throw new Error("invalid validity window: validBefore must be after validAfter (challenge already expired?)");
180
- const maxWindow = BigInt(params.maxWindowSec ?? 86400);
181
- if (validBefore - validAfter > maxWindow) throw new Error(`invalid validity window: total window exceeds the ${maxWindow}s cap (challenge expiry too far out?)`);
197
+ const maxWindow = params.maxWindowSec ?? 86400;
198
+ const minHeadroom = params.minHeadroomSec ?? 60;
199
+ const clamp = params.clamp ?? true;
200
+ if (maxWindow < minHeadroom) throw new Error(`invalid validity window config: maxWindowSec (${maxWindow}) is smaller than minHeadroomSec (${minHeadroom})`);
201
+ const validAfter = params.validAfterSec !== void 0 ? params.validAfterSec : params.nowSec - skew;
202
+ const rawValidBefore = params.validBeforeSec !== void 0 ? params.validBeforeSec : params.challengeExpiresAtSec + margin;
203
+ const floor = params.nowSec + minHeadroom;
204
+ const ceiling = validAfter + maxWindow;
205
+ const tooTight = rawValidBefore < floor;
206
+ const tooWide = rawValidBefore > ceiling;
207
+ if (!clamp && params.validBeforeSec !== void 0) {
208
+ if (tooTight) throw new Error(`invalid validity window: validBefore (${rawValidBefore}) is below the minimum settlement headroom (must be >= now + ${minHeadroom}s = ${floor}); the authorization would be rejected as about to expire`);
209
+ if (tooWide) throw new Error(`invalid validity window: validBefore (${rawValidBefore}) exceeds the ${maxWindow}s window cap (must be <= validAfter + ${maxWindow}s = ${ceiling}); the authorization window is too wide`);
210
+ }
211
+ const bandedValidBefore = tooTight ? floor : tooWide ? ceiling : rawValidBefore;
212
+ if (bandedValidBefore <= validAfter) throw new Error("invalid validity window: validBefore must be after validAfter (challenge already expired or validAfter pinned too late?)");
182
213
  return {
183
- validAfter,
184
- validBefore
214
+ validAfter: BigInt(validAfter),
215
+ validBefore: BigInt(bandedValidBefore)
185
216
  };
186
217
  }
187
218
  /**
@@ -280,6 +311,80 @@ var X402Error = class extends Error {
280
311
  this.retryAfter = options?.retryAfter ?? null;
281
312
  }
282
313
  };
314
+ /** A UUID, as carried by `interaction_id`'s local part and the step ids. */
315
+ const EXTRACT_UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
316
+ /** An interaction id is `uuid@domain`. */
317
+ const EXTRACT_WIRE_ID_RE = new RegExp(`^${EXTRACT_UUID_RE.source.slice(1, -1)}@[^\\s@]+$`, "i");
318
+ /**
319
+ * Parse the bytes of an inbound `interaction.json` MIME part into a typed
320
+ * {@link X402EmailChallenge} ready for {@link X402Client.payEmailChallenge}.
321
+ *
322
+ * A payer receives the x402 challenge as an `interaction.json` attachment on an
323
+ * inbound email (filename `interaction.json`, content type `application/json`).
324
+ * This validates the envelope (the strict snake_case wire shape, that it is the
325
+ * `x402.payment` `challenge` step, and that the embedded payload carries the
326
+ * fields a payer signs over) and re-assembles the nonce binding from the
327
+ * envelope's `interaction_id` + `step_id` + the payload's `challenge_nonce`, so
328
+ * the caller never has to hand-parse the part.
329
+ *
330
+ * Accepts the part body as a UTF-8 string, a `Uint8Array`/`Buffer`, or an
331
+ * already-parsed envelope object. Throws {@link X402Error} (status 0) on any
332
+ * malformed or non-challenge part.
333
+ *
334
+ * The resulting `challenge_id` is empty: the platform's private challenge id is
335
+ * not carried on the wire, and `payEmailChallenge` does not need it (it binds to
336
+ * the `interaction_id` and the challenge step id).
337
+ */
338
+ function parseEmailChallengeFromPart(part) {
339
+ const bad = (field) => {
340
+ throw new X402Error(`interaction.json part is not a valid x402 challenge: ${field}`, 0);
341
+ };
342
+ let envelope;
343
+ if (typeof part === "string" || part instanceof Uint8Array) {
344
+ const text = typeof part === "string" ? part : new TextDecoder().decode(part);
345
+ let parsed;
346
+ try {
347
+ parsed = JSON.parse(text);
348
+ } catch (cause) {
349
+ throw new X402Error(`interaction.json part is not valid JSON: ${cause instanceof Error ? cause.message : String(cause)}`, 0, void 0, { cause });
350
+ }
351
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) bad("envelope (expected a JSON object)");
352
+ envelope = parsed;
353
+ } else if (part && typeof part === "object" && !Array.isArray(part) && !(part instanceof Uint8Array)) envelope = part;
354
+ else return bad("envelope (expected JSON bytes, a string, or an object)");
355
+ if (envelope.interaction_version !== 1) bad("interaction_version (expected 1)");
356
+ const interactionId = envelope.interaction_id;
357
+ if (typeof interactionId !== "string" || !EXTRACT_WIRE_ID_RE.test(interactionId)) bad("interaction_id (expected uuid@domain)");
358
+ if (envelope.protocol !== "x402.payment") bad(`protocol (expected "${X402_INTERACTION_PROTOCOL}")`);
359
+ if (envelope.protocol_version !== 1) bad(`protocol_version (expected 1)`);
360
+ if (envelope.step !== "challenge") bad(`step (expected "challenge", got "${String(envelope.step)}")`);
361
+ const stepId = envelope.step_id;
362
+ if (typeof stepId !== "string" || !EXTRACT_UUID_RE.test(stepId)) bad("step_id (expected a uuid)");
363
+ const expiresAt = envelope.expires_at;
364
+ if (typeof expiresAt !== "string" || !expiresAt) bad("expires_at (expected an ISO-8601 timestamp on the challenge step)");
365
+ const payload = envelope.payload;
366
+ if (!payload || typeof payload !== "object" || Array.isArray(payload)) bad("payload (expected an object)");
367
+ const p = payload;
368
+ const challengeNonce = p.challenge_nonce;
369
+ if (typeof challengeNonce !== "string" || !/^[0-9a-f]{64}$/.test(challengeNonce)) bad("payload.challenge_nonce (expected 64 lowercase hex chars)");
370
+ const paymentRequirements = p.payment_requirements;
371
+ if (!paymentRequirements || typeof paymentRequirements !== "object" || Array.isArray(paymentRequirements)) bad("payload.payment_requirements (expected an object)");
372
+ const challenge = {
373
+ interaction_id: interactionId,
374
+ challenge_id: "",
375
+ challenge: {
376
+ payment_requirements: paymentRequirements,
377
+ nonce_binding: {
378
+ interaction_id: interactionId,
379
+ challenge_step_id: stepId,
380
+ challenge_nonce: challengeNonce
381
+ },
382
+ expires_at: expiresAt
383
+ }
384
+ };
385
+ validateEmailChallenge(challenge);
386
+ return challenge;
387
+ }
283
388
  /**
284
389
  * Assert a challenge is fully hydrated before signing, so a missing field fails
285
390
  * with a named X402Error instead of an opaque viem/BigInt error mid-sign.
@@ -599,4 +704,4 @@ function createX402Client(options = {}) {
599
704
  return new X402Client(options);
600
705
  }
601
706
  //#endregion
602
- export { DEFAULT_MAX_WINDOW_SEC, TRANSFER_WITH_AUTHORIZATION_TYPES, X402Client, X402Error, X402_INTERACTION_PROTOCOL, X402_INTERACTION_PROTOCOL_VERSION, buildExactEvmPaymentPayload, buildPaymentStepEnvelope, buildPayoutRegistrationMessage, computePaymentValidityWindow, createX402Client, deriveEip3009Nonce, signInteractionPayment, toPaymentPayload, transferWithAuthorizationTypedData };
707
+ export { DEFAULT_MAX_WINDOW_SEC, DEFAULT_MIN_SETTLEMENT_HEADROOM_SEC, TRANSFER_WITH_AUTHORIZATION_TYPES, X402Client, X402Error, X402_INTERACTION_PROTOCOL, X402_INTERACTION_PROTOCOL_VERSION, buildExactEvmPaymentPayload, buildPaymentStepEnvelope, buildPayoutRegistrationMessage, computePaymentValidityWindow, createX402Client, deriveEip3009Nonce, parseEmailChallengeFromPart, signInteractionPayment, toPaymentPayload, transferWithAuthorizationTypedData };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitivedotdev/sdk",
3
- "version": "1.10.0",
3
+ "version": "1.12.0",
4
4
  "description": "Official Primitive Node.js SDK: webhook, api, openapi, contract, and parser runtime modules.",
5
5
  "type": "module",
6
6
  "module": "./dist/index.js",